Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
045e726
db: add synced block query types
yyforyongyu Jun 8, 2026
8ae61c7
db/sql: add synced block list query
yyforyongyu Jun 8, 2026
1feeab2
kvdb: implement ListSyncedBlocks
yyforyongyu Jun 8, 2026
268ec2d
db/sql: add multi-row sqlite test helper
yyforyongyu Jun 8, 2026
98dd86a
sqlite: implement ListSyncedBlocks
yyforyongyu Jun 8, 2026
cda654d
pg: implement ListSyncedBlocks
yyforyongyu Jun 8, 2026
b3f0c78
db: add ListSyncedBlocks to WalletStore interface
yyforyongyu Jun 8, 2026
ad6a3cc
db: implement DeleteExpiredLeases on Store
yyforyongyu Jun 8, 2026
e39bf37
db: make RollbackToBlock rewind wallet sync state
yyforyongyu Jun 8, 2026
417926f
db/sql: add outputs-to-watch query
yyforyongyu Jun 8, 2026
6d60249
kvdb: implement ListOutputsToWatch
yyforyongyu Jun 8, 2026
28c23bf
db: add WatchOutputFromRow conversion helper
yyforyongyu Jun 8, 2026
d9901a8
sqlite: implement ListOutputsToWatch
yyforyongyu Jun 8, 2026
c17e382
pg: implement ListOutputsToWatch
yyforyongyu Jun 8, 2026
205a70a
db: add ListOutputsToWatch to UTXOStore interface
yyforyongyu Jun 8, 2026
6c048a6
db/itest: cover watched bare-multisig outputs
yyforyongyu Jun 8, 2026
6a893e9
db: add tx batch request types
yyforyongyu Jun 8, 2026
5047e1e
kvdb: add tx batch credit and block-meta helpers
yyforyongyu Jun 8, 2026
579e958
kvdb: implement ApplyTxBatch tx and sync-tip writes
yyforyongyu Jun 8, 2026
01b030c
kvdb: test ApplyTxBatch tx, sync-tip, and credit paths
yyforyongyu Jun 8, 2026
635d423
sqlite: implement ApplyTxBatch
yyforyongyu Jun 8, 2026
e37738b
pg: implement ApplyTxBatch
yyforyongyu Jun 8, 2026
bf3631a
db: add ApplyTxBatch to TxStore interface
yyforyongyu Jun 8, 2026
04092cf
db/itest: cover ApplyTxBatch conformance
yyforyongyu Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions wallet/internal/bwtest/mock/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ func (m *Store) IterWallets(ctx context.Context,
}
}

// ListSyncedBlocks implements the db.WalletStore interface.
func (m *Store) ListSyncedBlocks(ctx context.Context,
query db.ListSyncedBlocksQuery) ([]db.Block, error) {

args := m.Called(ctx, query)
if args.Get(0) == nil {
return nil, args.Error(1)
}

return args.Get(0).([]db.Block), args.Error(1)
}

// UpdateWallet implements the db.WalletStore interface.
func (m *Store) UpdateWallet(ctx context.Context,
params db.UpdateWalletParams) error {
Expand Down Expand Up @@ -359,6 +371,27 @@ func (m *Store) ListLeasedOutputs(ctx context.Context,
return args.Get(0).([]db.LeasedOutput), args.Error(1)
}

// DeleteExpiredLeases implements the db.UTXOStore interface.
func (m *Store) DeleteExpiredLeases(ctx context.Context,
walletID uint32) error {

args := m.Called(ctx, walletID)

return args.Error(0)
}

// ListOutputsToWatch implements the db.UTXOStore interface.
func (m *Store) ListOutputsToWatch(ctx context.Context,
walletID uint32) ([]db.UtxoInfo, error) {

args := m.Called(ctx, walletID)
if args.Get(0) == nil {
return nil, args.Error(1)
}

return args.Get(0).([]db.UtxoInfo), args.Error(1)
}

// Balance implements the db.UTXOStore interface.
func (m *Store) Balance(ctx context.Context,
params db.BalanceParams) (db.BalanceResult, error) {
Expand Down Expand Up @@ -389,6 +422,15 @@ func (m *Store) UpdateTx(ctx context.Context,
return args.Error(0)
}

// ApplyTxBatch implements the db.TxStore interface.
func (m *Store) ApplyTxBatch(ctx context.Context,
params db.TxBatchParams) error {

args := m.Called(ctx, params)

return args.Error(0)
}

// GetTx implements the db.TxStore interface.
func (m *Store) GetTx(ctx context.Context,
query db.GetTxQuery) (*db.TxInfo, error) {
Expand Down
26 changes: 26 additions & 0 deletions wallet/internal/db/data_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,18 @@ type ListWalletsQuery struct {
Page page.Request[uint32]
}

// ListSyncedBlocksQuery contains the requested synced block height range.
//
// Synced block metadata is shared chain state rather than wallet-scoped state,
// so the query carries no wallet ID.
type ListSyncedBlocksQuery struct {
// StartHeight is the first block height to include.
StartHeight uint32

// EndHeight is the final block height to include.
EndHeight uint32
}

// CreateWalletParams contains the parameters required to create a new wallet.
type CreateWalletParams struct {
// Name is the name of the new wallet.
Expand Down Expand Up @@ -1065,6 +1077,20 @@ type CreateTxParams struct {
Credits map[uint32]btcutil.Address
}

// TxBatchParams contains a wallet transaction batch and optional sync-tip
// update that should be applied atomically.
type TxBatchParams struct {
// WalletID is the ID of the wallet receiving the batch.
WalletID uint32

// Transactions contains the transaction records to apply.
Transactions []CreateTxParams

// SyncedTo optionally records the wallet's new chain sync tip as part of
// the same batch.
SyncedTo *Block
}

// UpdateTxState contains one requested transaction-state change.
type UpdateTxState struct {
// Block records the transaction as confirmed in the provided block.
Expand Down
16 changes: 16 additions & 0 deletions wallet/internal/db/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,11 @@ type WalletStore interface {
IterWallets(ctx context.Context,
query ListWalletsQuery) iter.Seq2[WalletInfo, error]

// ListSyncedBlocks returns the wallet's synced block metadata for the
// requested inclusive height range.
ListSyncedBlocks(ctx context.Context,
query ListSyncedBlocksQuery) ([]Block, error)

// UpdateWallet updates various properties of a wallet, such as its
// birthday, birthday block, or sync state. SQL multi-wallet backends
// return ErrWalletNotFound when the wallet ID is unknown. The legacy kvdb
Expand Down Expand Up @@ -406,6 +411,10 @@ type TxStore interface {
// graph-affecting lifecycle changes.
UpdateTx(ctx context.Context, params UpdateTxParams) error

// ApplyTxBatch atomically records a batch of transaction records and an
// optional wallet sync-tip update.
ApplyTxBatch(ctx context.Context, params TxBatchParams) 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.
Expand Down Expand Up @@ -509,6 +518,13 @@ type UTXOStore interface {
ListLeasedOutputs(ctx context.Context, walletID uint32) (
[]LeasedOutput, error)

// DeleteExpiredLeases removes expired UTXO lease records for the wallet.
DeleteExpiredLeases(ctx context.Context, walletID uint32) error

// ListOutputsToWatch returns UTXOs that recovery scans should watch.
ListOutputsToWatch(ctx context.Context, walletID uint32) ([]UtxoInfo,
error)

// Balance returns a wallet-scoped balance view for the current unspent UTXO
// set after applying any optional caller-supplied filters.
//
Expand Down
238 changes: 238 additions & 0 deletions wallet/internal/db/itest/tx_utxo_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2724,3 +2724,241 @@ func txDetailHashes(infos []db.TxDetailInfo) []chainhash.Hash {

return hashes
}

// TestListOutputsToWatchBareMultisigUsesOutputScript verifies that the recovery
// watch set reports the actual on-chain output script for a bare-multisig
// output the wallet partly owns, rather than the member address's own script.
//
// A bare-multisig output is credited to one of its member pubkey addresses, so
// the stored UTXO resolves to that member address. The member's own script
// (PayToAddrScript) differs from the full multisig output script. A rescan must
// watch the output script the chain actually carries, so ListOutputsToWatch
// must derive the watch script from the funding transaction's
// TxOut[output_index].PkScript, not from the credited address row. This locks
// in parity with the kvdb backend, whose credit walk records the on-chain
// output script.
//
// Without the fix, the query returns addresses.script_pub_key (the member's
// P2PK script), so the rescan would watch a script the multisig output never
// pays, and a recovered spend would be missed.
func TestListOutputsToWatchBareMultisigUsesOutputScript(t *testing.T) {
t.Parallel()

store := NewTestStore(t)

// A script-only import (no private key) requires a watch-only wallet per
// the ADR 0012 spendable-wallet invariant.
walletID := newWatchOnlyWallet(t, store, "wallet-watch-bare-multisig")

scope := db.KeyScopeBIP0084

// memberScript is the member's own P2PK script (registered as the wallet
// address); multiSigScript is the full on-chain output script, which is
// never registered as an address.
memberAddr, memberScript, multiSigScript := newMultisigScript(t)
require.NotEqual(t, memberScript, multiSigScript)

_, err := store.NewImportedAddress(
t.Context(), db.NewImportedAddressParams{
WalletID: walletID,
Scope: scope,
AddressType: db.RawPubKey,
PubKey: memberAddr.ScriptAddress(),
ScriptPubKey: memberScript,
EncryptedScript: RandomBytes(48),
},
)
require.NoError(t, err)

// The funding transaction pays the bare-multisig output; Credits[0]
// carries the resolved member address exactly as the publisher supplies
// it after filtering ownership by the member script.
tx := newRegularTx(
[]wire.OutPoint{randomOutPoint()},
[]*wire.TxOut{{Value: 7000, PkScript: multiSigScript}},
)
err = store.CreateTx(t.Context(), db.CreateTxParams{
WalletID: walletID,
Tx: tx,
Received: time.Unix(1710004500, 0),
Status: db.TxStatusPublished,
Credits: map[uint32]btcutil.Address{0: memberAddr},
})
require.NoError(t, err)

utxos, err := store.ListOutputsToWatch(t.Context(), walletID)
require.NoError(t, err)
require.Len(t, utxos, 1)

outPoint := wire.OutPoint{Hash: tx.TxHash(), Index: 0}
require.Equal(t, outPoint, utxos[0].OutPoint)

// The watch script must be the on-chain multisig output script, not the
// member address's own script that the UTXO row resolves to.
require.Equal(t, multiSigScript, utxos[0].PkScript)
require.NotEqual(t, memberScript, utxos[0].PkScript)
}

// TestApplyTxBatchStoresTxAndSyncTip verifies that a runtime batch can persist
// transaction history and advance the wallet sync tip atomically.
func TestApplyTxBatchStoresTxAndSyncTip(t *testing.T) {
t.Parallel()

store := NewTestStore(t)
walletName := "wallet-apply-tx-batch"
walletID := newWallet(t, store, walletName)
createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default")

addr := newDerivedAddress(
t, store, walletID, db.KeyScopeBIP0084, "default", false,
)
tx := newRegularTx(
[]wire.OutPoint{randomOutPoint()},
[]*wire.TxOut{{Value: 7000, PkScript: addr.ScriptPubKey}},
)
syncedTo := NewBlockFixture(212)

err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{
WalletID: walletID,
Transactions: []db.CreateTxParams{{
WalletID: walletID,
Tx: tx,
Received: time.Unix(1710000150, 0),
Status: db.TxStatusPending,
Credits: map[uint32]btcutil.Address{0: nil},
}},
SyncedTo: &syncedTo,
})
require.NoError(t, err)

txInfo, err := store.GetTx(t.Context(), db.GetTxQuery{
WalletID: walletID,
Txid: tx.TxHash(),
})
require.NoError(t, err)
require.Equal(t, db.TxStatusPending, txInfo.Status)
require.Nil(t, txInfo.Block)
require.True(t, walletUtxoExists(t, store, walletID, wire.OutPoint{
Hash: tx.TxHash(), Index: 0,
}))

walletInfo, err := store.GetWallet(t.Context(), walletName)
require.NoError(t, err)
require.NotNil(t, walletInfo.SyncedTo)
require.Equal(t, syncedTo.Hash, walletInfo.SyncedTo.Hash)
require.Equal(t, syncedTo.Height, walletInfo.SyncedTo.Height)
require.Equal(t, syncedTo.Timestamp.Unix(),
walletInfo.SyncedTo.Timestamp.Unix())
}

// TestApplyTxBatchConfirmsTxInSameBlock verifies that a batch can record a
// transaction confirmed in the very block the same batch introduces as the new
// sync tip. The confirming block row does not exist before the batch, so the
// batch must create the sync-tip block before recording the confirmed
// transaction; otherwise the confirmed insert fails with ErrBlockNotFound.
func TestApplyTxBatchConfirmsTxInSameBlock(t *testing.T) {
t.Parallel()

store := NewTestStore(t)
walletName := "wallet-apply-tx-batch-confirmed"
walletID := newWallet(t, store, walletName)
createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default")

addr := newDerivedAddress(
t, store, walletID, db.KeyScopeBIP0084, "default", false,
)
tx := newRegularTx(
[]wire.OutPoint{randomOutPoint()},
[]*wire.TxOut{{Value: 7000, PkScript: addr.ScriptPubKey}},
)

// The confirming block is also the batch's new sync tip. It is not
// inserted ahead of time, so the batch itself must create it before the
// confirmed transaction is recorded.
block := NewBlockFixture(213)

err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{
WalletID: walletID,
Transactions: []db.CreateTxParams{{
WalletID: walletID,
Tx: tx,
Received: time.Unix(1710000160, 0),
Block: &block,
Status: db.TxStatusPublished,
Credits: map[uint32]btcutil.Address{0: nil},
}},
SyncedTo: &block,
})
require.NoError(t, err)

// The transaction is recorded as confirmed in the batch's block and its
// credited output is in the wallet UTXO set.
txInfo, err := store.GetTx(t.Context(), db.GetTxQuery{
WalletID: walletID,
Txid: tx.TxHash(),
})
require.NoError(t, err)
require.Equal(t, db.TxStatusPublished, txInfo.Status)
require.NotNil(t, txInfo.Block)
require.Equal(t, block.Height, txInfo.Block.Height)
require.Equal(t, block.Hash, txInfo.Block.Hash)
require.True(t, walletUtxoExists(t, store, walletID, wire.OutPoint{
Hash: tx.TxHash(), Index: 0,
}))

// The sync tip advanced to the same block.
walletInfo, err := store.GetWallet(t.Context(), walletName)
require.NoError(t, err)
require.NotNil(t, walletInfo.SyncedTo)
require.Equal(t, block.Height, walletInfo.SyncedTo.Height)
require.Equal(t, block.Hash, walletInfo.SyncedTo.Hash)
}

// TestApplyTxBatchRejectsMismatchedWalletID verifies that a batch is rejected
// when any transaction is owned by a wallet other than the batch wallet, and
// that the rejection commits nothing: the sync tip is not advanced and no
// transaction row is written.
func TestApplyTxBatchRejectsMismatchedWalletID(t *testing.T) {
t.Parallel()

store := NewTestStore(t)
walletName := "wallet-apply-tx-batch-mismatch"
walletID := newWallet(t, store, walletName)
createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default")

addr := newDerivedAddress(
t, store, walletID, db.KeyScopeBIP0084, "default", false,
)
tx := newRegularTx(
[]wire.OutPoint{randomOutPoint()},
[]*wire.TxOut{{Value: 7000, PkScript: addr.ScriptPubKey}},
)
syncedTo := NewBlockFixture(214)

// The batch targets walletID, but the lone transaction claims a different
// wallet. The whole batch must be rejected before any write.
err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{
WalletID: walletID,
Transactions: []db.CreateTxParams{{
WalletID: walletID + 99,
Tx: tx,
Received: time.Unix(1710000170, 0),
Status: db.TxStatusPending,
Credits: map[uint32]btcutil.Address{0: nil},
}},
SyncedTo: &syncedTo,
})
require.ErrorIs(t, err, db.ErrInvalidParam)

// The sync tip was not advanced: the wallet is still unsynced.
walletInfo, err := store.GetWallet(t.Context(), walletName)
require.NoError(t, err)
require.Nil(t, walletInfo.SyncedTo)

// No transaction row was written for either wallet.
_, err = store.GetTx(t.Context(), db.GetTxQuery{
WalletID: walletID,
Txid: tx.TxHash(),
})
require.ErrorIs(t, err, db.ErrTxNotFound)
}
Loading
Loading