diff --git a/wallet/internal/bwtest/mock/store.go b/wallet/internal/bwtest/mock/store.go index fd3947472b..2353f21002 100644 --- a/wallet/internal/bwtest/mock/store.go +++ b/wallet/internal/bwtest/mock/store.go @@ -380,6 +380,18 @@ func (m *Store) DeleteExpiredLeases(ctx context.Context, 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) { @@ -410,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) { diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index bc7b8aa31e..c3c50a8254 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -1078,6 +1078,20 @@ type CreateTxParams struct { Credits map[uint32]address.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. diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index ab7a45e3cd..fb6220e8f4 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -411,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. @@ -517,6 +521,10 @@ type UTXOStore interface { // 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. // diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index 3cb8be8a62..93af4d6fc8 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -408,3 +408,29 @@ func walletUtxoExists(t *testing.T, store *pg.Store, return true } + +// walletUtxoSpent reports whether one wallet-scoped outpoint exists and is +// recorded as spent, i.e. its spend edge points at a spending transaction. +func walletUtxoSpent(t *testing.T, store *pg.Store, + walletID uint32, + outPoint wire.OutPoint) bool { + + t.Helper() + + spentBy, err := store.Queries().GetUtxoSpendByOutpoint( + t.Context(), sqlc.GetUtxoSpendByOutpointParams{ + 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 spentBy.SpentByTxID.Valid +} diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 2812d57e7a..7961bad5c2 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -153,3 +153,29 @@ func walletUtxoExists(t *testing.T, store *sqlite.Store, return true } + +// walletUtxoSpent reports whether one wallet-scoped outpoint exists and is +// recorded as spent, i.e. its spend edge points at a spending transaction. +func walletUtxoSpent(t *testing.T, store *sqlite.Store, + walletID uint32, + outPoint wire.OutPoint) bool { + + t.Helper() + + spentBy, err := store.Queries().GetUtxoSpendByOutpoint( + t.Context(), sqlc.GetUtxoSpendByOutpointParams{ + 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 spentBy.SpentByTxID.Valid +} diff --git a/wallet/internal/db/itest/txstore_create_edge_cases_test.go b/wallet/internal/db/itest/txstore_create_edge_cases_test.go index d558b8c4b8..175d777b40 100644 --- a/wallet/internal/db/itest/txstore_create_edge_cases_test.go +++ b/wallet/internal/db/itest/txstore_create_edge_cases_test.go @@ -66,9 +66,9 @@ func TestCreateTxRejectsInvalidParams(t *testing.T) { 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) { +// TestCreateTxSkipsDuplicateConfirmedTransaction verifies that an exact +// duplicate confirmed insert is idempotent instead of creating a second row. +func TestCreateTxSkipsDuplicateConfirmedTransaction(t *testing.T) { t.Parallel() store := NewTestStore(t) @@ -98,7 +98,7 @@ func TestCreateTxRejectsDuplicateConfirmedTransaction(t *testing.T) { require.NoError(t, err) err = store.CreateTx(t.Context(), params) - require.ErrorIs(t, err, db.ErrTxAlreadyExists) + require.NoError(t, err) } // TestCreateTxRejectsMissingConfirmingBlockForExistingUnminedRow verifies that diff --git a/wallet/internal/db/itest/txstore_utxostore_test.go b/wallet/internal/db/itest/txstore_utxostore_test.go index fa449e0b02..2650bf66c8 100644 --- a/wallet/internal/db/itest/txstore_utxostore_test.go +++ b/wallet/internal/db/itest/txstore_utxostore_test.go @@ -152,6 +152,109 @@ func TestCreateTxCreditBareMultisigMember(t *testing.T) { require.Equal(t, db.RawPubKey, utxo.AddrType) } +// TestCreateTxDuplicateCreditBareMultisigMemberMismatch verifies that an +// idempotent duplicate cannot change the wallet-owned member address recorded +// for a bare-multisig credit. +// +// Scenario: +// - The wallet imports both members of a 1-of-2 bare-multisig output. +// - The first CreateTx records the output as owned by the first member. +// - A duplicate CreateTx call reports the same transaction and output, but +// claims ownership through the second member. +// +// Assertions: +// - The duplicate is rejected with ErrTxAlreadyExists because it is not an +// exact replay of the stored ownership metadata. +func TestCreateTxDuplicateCreditBareMultisigMemberMismatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWatchOnlyWallet( + t, store, "wallet-bare-multisig-duplicate-mismatch", + ) + + firstKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + secondKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + firstAddr, err := address.NewAddressPubKey( + firstKey.PubKey().SerializeCompressed(), + &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + + secondAddr, err := address.NewAddressPubKey( + secondKey.PubKey().SerializeCompressed(), + &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + + firstScript, err := txscript.PayToAddrScript(firstAddr) + require.NoError(t, err) + + secondScript, err := txscript.PayToAddrScript(secondAddr) + require.NoError(t, err) + + for _, member := range []struct { + addr *address.AddressPubKey + script []byte + }{ + { + addr: firstAddr, + script: firstScript, + }, + { + addr: secondAddr, + script: secondScript, + }, + } { + _, err := store.NewImportedAddress( + t.Context(), db.NewImportedAddressParams{ + WalletID: walletID, + AddressType: db.RawPubKey, + PubKey: member.addr.ScriptAddress(), + ScriptPubKey: member.script, + EncryptedScript: RandomBytes(48), + }, + ) + require.NoError(t, err) + } + + multiSigScript, err := txscript.NewScriptBuilder(). + AddInt64(1). + AddData(firstKey.PubKey().SerializeCompressed()). + AddData(secondKey.PubKey().SerializeCompressed()). + AddInt64(2). + AddOp(txscript.OP_CHECKMULTISIG). + Script() + require.NoError(t, err) + + 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(1710004400, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: firstAddr}, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710004401, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: secondAddr}, + }) + require.ErrorIs(t, err, db.ErrTxAlreadyExists) +} + // TestCreateTxCreditAddrMismatch verifies that CreateTx rejects a non-nil // credit address that the credited output does not pay to, instead of // recording a UTXO owned by an unrelated address. @@ -408,8 +511,8 @@ func TestCreateTxRejectsSecondPendingSpend(t *testing.T) { require.False(t, ok) } -// TestCreateTxRejectsDuplicateTx verifies that CreateTx inserts one wallet- -// scoped transaction row only once. +// TestCreateTxSkipsDuplicateTx verifies that CreateTx inserts one wallet- +// scoped transaction row only once for an idempotent duplicate. // // Scenario: // - One wallet transaction hash is already present in the store. @@ -421,9 +524,9 @@ func TestCreateTxRejectsSecondPendingSpend(t *testing.T) { // - Attempt to insert the same transaction hash again. // // Assertions: -// - CreateTx returns ErrTxAlreadyExists. +// - CreateTx treats the duplicate as a no-op. // - The original row remains stored once. -func TestCreateTxRejectsDuplicateTx(t *testing.T) { +func TestCreateTxSkipsDuplicateTx(t *testing.T) { t.Parallel() store := NewTestStore(t) @@ -448,502 +551,610 @@ func TestCreateTxRejectsDuplicateTx(t *testing.T) { Received: time.Unix(1710000590, 0), Status: db.TxStatusPending, }) - require.ErrorIs(t, err, db.ErrTxAlreadyExists) + require.NoError(t, err) _, ok := txIDByHash(t, store, walletID, tx.TxHash()) 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) { +// TestCreateTxReplaysDuplicateCredit verifies that an idempotent duplicate +// transaction observation still records newly supplied wallet credit edges. +func TestCreateTxReplaysDuplicateCredit(t *testing.T) { t.Parallel() store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-confirm-existing-unmined") + walletID := newWallet(t, store, "wallet-create-tx-replay-credit") 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}}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, ) - - err := store.CreateTx(t.Context(), db.CreateTxParams{ + params := db.CreateTxParams{ WalletID: walletID, Tx: tx, - Received: time.Unix(1710000500, 0), + Received: time.Unix(1710000580, 0), Status: db.TxStatusPending, - Label: "seed", - Credits: map[uint32]address.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]address.Address{0: nil}, - }) - require.NoError(t, err) + } - info, err := store.GetTx(t.Context(), db.GetTxQuery{ - WalletID: walletID, - Txid: tx.TxHash(), - }) + err := store.CreateTx(t.Context(), params) 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) + outPoint := wire.OutPoint{Hash: tx.TxHash(), Index: 0} + require.False(t, walletUtxoExists(t, store, walletID, outPoint)) - confirmedTxs, err := store.ListTxns(t.Context(), db.ListTxnsQuery{ - WalletID: walletID, - StartHeight: confirmedBlock.Height, - EndHeight: confirmedBlock.Height, - }) + params.Credits = map[uint32]address.Address{0: nil} + err = store.CreateTx(t.Context(), params) require.NoError(t, err) - require.Len(t, confirmedTxs, 1) - require.Equal(t, tx.TxHash(), confirmedTxs[0].Hash) + require.True(t, walletUtxoExists(t, store, walletID, outPoint)) } -// 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) { +// TestCreateTxReplayedCreditMarksExistingChildSpent verifies that replaying a +// parent credit also reconciles an already-stored child that spends it. +func TestCreateTxReplayedCreditMarksExistingChildSpent(t *testing.T) { t.Parallel() store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-confirmed-conflict-winner") + walletID := newWallet(t, store, "wallet-create-tx-replay-spent") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") - addr := newDerivedAddress( + parentAddr := newDerivedAddress( t, store, walletID, db.KeyScopeBIP0084, "default", false, ) + childAddr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + parentTx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + []*wire.TxOut{{Value: 5000, PkScript: parentAddr.ScriptPubKey}}, ) - - err := store.CreateTx(t.Context(), db.CreateTxParams{ + parentParams := db.CreateTxParams{ WalletID: walletID, Tx: parentTx, - Received: time.Unix(1710001500, 0), + Received: time.Unix(1710000580, 0), Status: db.TxStatusPending, - Credits: map[uint32]address.Address{0: nil}, - }) + } + + err := store.CreateTx(t.Context(), parentParams) require.NoError(t, err) - spentOutPoint := wire.OutPoint{Hash: parentTx.TxHash(), Index: 0} - firstChild := newRegularTx( - []wire.OutPoint{spentOutPoint}, - []*wire.TxOut{{Value: 4000, PkScript: []byte{0x51}}}, + parentOutPoint := wire.OutPoint{Hash: parentTx.TxHash(), Index: 0} + require.False(t, walletUtxoExists(t, store, walletID, parentOutPoint)) + + childTx := newRegularTx( + []wire.OutPoint{parentOutPoint}, + []*wire.TxOut{{Value: 4000, PkScript: childAddr.ScriptPubKey}}, ) err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: firstChild, - Received: time.Unix(1710001510, 0), + Tx: childTx, + Received: time.Unix(1710000590, 0), Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) + require.False(t, walletUtxoExists(t, store, walletID, parentOutPoint)) - grandchild := newRegularTx( - []wire.OutPoint{{Hash: firstChild.TxHash(), Index: 0}}, - []*wire.TxOut{{Value: 3000, PkScript: []byte{0x52}}}, + parentParams.Credits = map[uint32]address.Address{0: nil} + err = store.CreateTx(t.Context(), parentParams) + require.NoError(t, err) + require.True(t, walletUtxoSpent(t, store, walletID, parentOutPoint)) +} + +// TestCreateTxReplayedCreditConfirmedChildReplacesUnmined verifies that a +// late-discovered parent credit reconciles already-stored conflicting children +// before recording the parent spend edge. +func TestCreateTxReplayedCreditConfirmedChildReplacesUnmined(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-tx-replay-replace") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + parentAddr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + unminedAddr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + confirmedAddr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: parentAddr.ScriptPubKey}}, + ) + parentParams := db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710000600, 0), + Status: db.TxStatusPending, + } + + err := store.CreateTx(t.Context(), parentParams) + require.NoError(t, err) + + parentOutPoint := wire.OutPoint{Hash: parentTx.TxHash(), Index: 0} + require.False(t, walletUtxoExists(t, store, walletID, parentOutPoint)) + + unminedChild := newRegularTx( + []wire.OutPoint{parentOutPoint}, + []*wire.TxOut{{Value: 4000, PkScript: unminedAddr.ScriptPubKey}}, ) err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: grandchild, - Received: time.Unix(1710001520, 0), + Tx: unminedChild, + Received: time.Unix(1710000610, 0), Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) - confirmedBlock := CreateBlockFixture(t, store.Queries(), 260) - confirmedWinner := newRegularTx( - []wire.OutPoint{spentOutPoint}, - []*wire.TxOut{{Value: 3500, PkScript: []byte{0x53}}}, + confirmedChild := newRegularTx( + []wire.OutPoint{parentOutPoint}, + []*wire.TxOut{{Value: 3000, PkScript: confirmedAddr.ScriptPubKey}}, ) + confirmedBlock := CreateBlockFixture(t, store.Queries(), 262) err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: confirmedWinner, - Received: time.Unix(1710001530, 0), + Tx: confirmedChild, + Received: time.Unix(1710000620, 0), Block: &confirmedBlock, Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) - childInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ - WalletID: walletID, - Txid: firstChild.TxHash(), - }) + parentParams.Credits = map[uint32]address.Address{0: nil} + err = store.CreateTx(t.Context(), parentParams) 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) + confirmedID, ok := txIDByHash( + t, store, walletID, confirmedChild.TxHash(), + ) + require.True(t, ok) + require.Equal( + t, []int64{confirmedID}, + childSpendingTxIDs(t, store, walletID, parentTx.TxHash()), + ) - winnerInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + unminedInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ WalletID: walletID, - Txid: confirmedWinner.TxHash(), + Txid: unminedChild.TxHash(), }) require.NoError(t, err) - require.NotNil(t, winnerInfo.Block) - require.Equal(t, db.TxStatusPublished, winnerInfo.Status) + require.Equal(t, db.TxStatusReplaced, unminedInfo.Status) - _, err = store.GetUtxo(t.Context(), db.GetUtxoQuery{ + confirmedInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ WalletID: walletID, - OutPoint: spentOutPoint, + Txid: confirmedChild.TxHash(), }) - require.ErrorIs(t, err, db.ErrUtxoNotFound) + require.NoError(t, err) + require.Equal(t, db.TxStatusPublished, confirmedInfo.Status) + require.NotNil(t, confirmedInfo.Block) + require.Equal(t, confirmedBlock.Height, confirmedInfo.Block.Height) } -// 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) { +// TestCreateTxReplayedCreditSkipsReplacedChildSnapshot verifies that replaying +// a parent credit does not reattach a child spend from a stale active-child +// snapshot after that child has already been replaced. +func TestCreateTxReplayedCreditSkipsReplacedChildSnapshot(t *testing.T) { t.Parallel() store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-get-tx") + walletID := newWallet(t, store, "wallet-create-tx-replay-stale-child") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") - tx := newRegularTx( + parentAddr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + unminedAddr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + confirmedAddr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + parentTx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, + []*wire.TxOut{ + {Value: 5000, PkScript: parentAddr.ScriptPubKey}, + {Value: 6000, PkScript: parentAddr.ScriptPubKey}, + }, ) + parentParams := db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710000630, 0), + Status: db.TxStatusPending, + } - err := store.CreateTx(t.Context(), db.CreateTxParams{ + err := store.CreateTx(t.Context(), parentParams) + require.NoError(t, err) + + firstOutPoint := wire.OutPoint{Hash: parentTx.TxHash(), Index: 0} + secondOutPoint := wire.OutPoint{Hash: parentTx.TxHash(), Index: 1} + unminedChild := newRegularTx( + []wire.OutPoint{firstOutPoint, secondOutPoint}, + []*wire.TxOut{{Value: 4000, PkScript: unminedAddr.ScriptPubKey}}, + ) + err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: tx, - Received: time.Unix(1710000600, 0), + Tx: unminedChild, + Received: time.Unix(1710000640, 0), Status: db.TxStatusPending, - Label: "pending-note", + Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) - info, err := store.GetTx(t.Context(), db.GetTxQuery{ + confirmedChild := newRegularTx( + []wire.OutPoint{firstOutPoint}, + []*wire.TxOut{{Value: 3000, PkScript: confirmedAddr.ScriptPubKey}}, + ) + confirmedBlock := CreateBlockFixture(t, store.Queries(), 263) + err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Txid: tx.TxHash(), + Tx: confirmedChild, + Received: time.Unix(1710000650, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, }) 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() + parentParams.Credits = map[uint32]address.Address{0: nil, 1: nil} + err = store.CreateTx(t.Context(), parentParams) + require.NoError(t, err) - store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-get-tx-missing") + confirmedID, ok := txIDByHash( + t, store, walletID, confirmedChild.TxHash(), + ) + require.True(t, ok) + require.Equal( + t, []int64{confirmedID}, + childSpendingTxIDs(t, store, walletID, parentTx.TxHash()), + ) + require.True(t, walletUtxoSpent(t, store, walletID, firstOutPoint)) + require.False(t, walletUtxoSpent(t, store, walletID, secondOutPoint)) - _, err := store.GetTx(t.Context(), db.GetTxQuery{ + unminedInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ WalletID: walletID, - Txid: RandomHash(), + Txid: unminedChild.TxHash(), }) - require.ErrorIs(t, err, db.ErrTxNotFound) + require.NoError(t, err) + require.Equal(t, db.TxStatusReplaced, unminedInfo.Status) } -// 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) { +// TestCreateTxReplayedCreditPreplansConfirmedReplacements verifies that late +// parent-credit replay computes confirmed child replacements across all newly +// discovered credits before enforcing the single-unmined-spender rule. +func TestCreateTxReplayedCreditPreplansConfirmedReplacements(t *testing.T) { t.Parallel() store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-confirmed-tx-missing-block") + walletID := newWallet(t, store, "wallet-create-tx-replay-preplan") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") - tx := newRegularTx( + parentAddr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + parentTx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, + []*wire.TxOut{ + {Value: 5000, PkScript: parentAddr.ScriptPubKey}, + {Value: 6000, PkScript: parentAddr.ScriptPubKey}, + }, ) - block := db.Block{ - Hash: RandomHash(), - Height: 240, - Timestamp: time.Unix(1710000560, 0), + parentParams := db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710000660, 0), + Status: db.TxStatusPending, } - err := store.CreateTx(t.Context(), db.CreateTxParams{ + err := store.CreateTx(t.Context(), parentParams) + require.NoError(t, err) + + firstOutPoint := wire.OutPoint{Hash: parentTx.TxHash(), Index: 0} + secondOutPoint := wire.OutPoint{Hash: parentTx.TxHash(), Index: 1} + staleChild := newRegularTx( + []wire.OutPoint{firstOutPoint, secondOutPoint}, + []*wire.TxOut{{Value: 4000, PkScript: []byte{0x51}}}, + ) + err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: tx, - Received: time.Unix(1710000570, 0), + Tx: staleChild, + Received: time.Unix(1710000670, 0), Status: db.TxStatusPending, }) require.NoError(t, err) - err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + activeChild := newRegularTx( + []wire.OutPoint{firstOutPoint}, + []*wire.TxOut{{Value: 3000, PkScript: []byte{0x52}}}, + ) + err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Txid: tx.TxHash(), - State: &db.UpdateTxState{ - Block: &block, - Status: db.TxStatusPublished, - }, + Tx: activeChild, + Received: time.Unix(1710000680, 0), + Status: db.TxStatusPending, }) - require.ErrorIs(t, err, db.ErrBlockNotFound) + require.NoError(t, err) - info, err := store.GetTx(t.Context(), db.GetTxQuery{ + confirmedChild := newRegularTx( + []wire.OutPoint{secondOutPoint}, + []*wire.TxOut{{Value: 2000, PkScript: []byte{0x53}}}, + ) + confirmedBlock := CreateBlockFixture(t, store.Queries(), 264) + err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Txid: tx.TxHash(), + Tx: confirmedChild, + Received: time.Unix(1710000690, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, }) require.NoError(t, err) - require.Nil(t, info.Block) + + parentParams.Credits = map[uint32]address.Address{0: nil, 1: nil} + err = store.CreateTx(t.Context(), parentParams) + require.NoError(t, err) + + activeID, ok := txIDByHash(t, store, walletID, activeChild.TxHash()) + require.True(t, ok) + confirmedID, ok := txIDByHash(t, store, walletID, confirmedChild.TxHash()) + require.True(t, ok) + require.ElementsMatch( + t, []int64{activeID, confirmedID}, + childSpendingTxIDs(t, store, walletID, parentTx.TxHash()), + ) + require.True(t, walletUtxoSpent(t, store, walletID, firstOutPoint)) + require.True(t, walletUtxoSpent(t, store, walletID, secondOutPoint)) + + staleInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: staleChild.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, db.TxStatusReplaced, staleInfo.Status) } -// TestUpdateTxRejectsMismatchedConfirmedBlock verifies that UpdateTx rejects a -// state patch when the supplied block metadata does not match the stored block -// row for that height. +// TestCreateTxConfirmsExistingUnminedRow verifies that CreateTx reuses one live +// unmined row when the same transaction later arrives with confirming block +// metadata. // // Scenario: -// - One stored pending transaction is later patched with mismatched block -// metadata for an existing height. +// - One wallet already stores the transaction in its unmined history. // // 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. +// - Create one wallet-owned credited transaction in pending state. +// - Create one matching confirming block fixture for the same transaction +// hash. // // Action: -// - Apply the mismatched confirmation patch through UpdateTx. +// - Call CreateTx again with the same transaction hash and confirming block. // // Assertions: -// - UpdateTx returns ErrBlockMismatch. -// - The existing transaction row remains unconfirmed and pending. -func TestUpdateTxRejectsMismatchedConfirmedBlock(t *testing.T) { +// - 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-update-tx-block-mismatch") + 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: 5000, PkScript: []byte{0x51}}}, + []*wire.TxOut{{Value: 7000, PkScript: addr.ScriptPubKey}}, ) err := store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, Tx: tx, - Received: time.Unix(1710000550, 0), + Received: time.Unix(1710000500, 0), Status: db.TxStatusPending, + Label: "seed", + Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) - block := CreateBlockFixture(t, queries, 240) - mismatchBlock := block - mismatchBlock.Hash = RandomHash() - - err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Txid: tx.TxHash(), - State: &db.UpdateTxState{ - Block: &mismatchBlock, - Status: db.TxStatusPublished, - }, + Tx: tx, + Received: time.Unix(1710000600, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Label: "ignored", + Credits: map[uint32]address.Address{0: nil}, }) - require.ErrorIs(t, err, db.ErrBlockMismatch) + require.NoError(t, err) 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) + 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) } -// TestUpdateTxUpdatesStoredLabel verifies that UpdateTx can patch the stored -// user-visible label without mutating chain-state metadata. +// TestCreateTxConfirmedWinnerInvalidatesConflictBranch verifies that a newly +// confirmed transaction invalidates the conflicting unmined branch before it +// claims the shared wallet-owned input. // // Scenario: -// - One pending wallet transaction already exists with an old label. +// - One wallet-owned output already has one unmined spend chain. +// - A confirmed conflicting transaction later arrives for the same outpoint. // // Setup: -// - Create one wallet and insert one pending transaction row with a label. +// - 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: -// - Patch only the label through UpdateTx. +// - Insert the confirmed conflicting transaction through CreateTx. // // Assertions: -// - The stored label changes. -// - The transaction stays pending and unconfirmed. -func TestUpdateTxUpdatesStoredLabel(t *testing.T) { +// - 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-update-tx-label") + walletID := newWallet(t, store, "wallet-confirmed-conflict-winner") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") - tx := newRegularTx( + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + parentTx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, ) err := store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: tx, - Received: time.Unix(1710000700, 0), + Tx: parentTx, + Received: time.Unix(1710001500, 0), Status: db.TxStatusPending, - Label: "old-label", + Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) - label := "new-label" - err = store.UpdateTx(t.Context(), db.UpdateTxParams{ - WalletID: walletID, - Txid: tx.TxHash(), - Label: &label, + 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) - info, err := store.GetTx(t.Context(), db.GetTxQuery{ + 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, - Txid: tx.TxHash(), + Tx: grandchild, + Received: time.Unix(1710001520, 0), + Status: db.TxStatusPending, }) require.NoError(t, err) - require.Equal(t, "new-label", info.Label) - require.Equal(t, db.TxStatusPending, info.Status) + + 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) } -// TestUpdateTxConfirmsStoredPendingTx verifies that UpdateTx can attach a -// confirming block to an already-stored unmined row. +// TestGetTxReturnsStoredPendingTx verifies that GetTx rebuilds the public +// transaction view for one stored unmined row. // // Scenario: -// - One pending wallet transaction is later observed in a block. +// - One pending wallet transaction has already been inserted. // // 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. +// - Retrieve the transaction through GetTx. // // Assertions: -// - The transaction now carries the block metadata. -// - The status becomes published. -func TestUpdateTxConfirmsStoredPendingTx(t *testing.T) { +// - 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-update-tx-confirm") - queries := store.Queries() + walletID := newWallet(t, store, "wallet-get-tx") tx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 6000, PkScript: []byte{0x51}}}, + []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, ) err := store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, Tx: tx, - Received: time.Unix(1710000710, 0), + Received: time.Unix(1710000600, 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, - }, + Label: "pending-note", }) require.NoError(t, err) @@ -952,71 +1163,76 @@ func TestUpdateTxConfirmsStoredPendingTx(t *testing.T) { 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) + 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) } -// TestUpdateTxNotFound verifies that UpdateTx returns ErrTxNotFound when the -// wallet has no matching transaction row. +// 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 one label patch. +// - Create one wallet and choose one random transaction hash. // // Action: -// - Apply the patch to a random missing tx hash. +// - Query the missing hash through GetTx. // // Assertions: -// - UpdateTx returns ErrTxNotFound. -func TestUpdateTxNotFound(t *testing.T) { +// - GetTx returns ErrTxNotFound. +func TestGetTxNotFound(t *testing.T) { t.Parallel() store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-update-label-missing") + walletID := newWallet(t, store, "wallet-get-tx-missing") - label := "new-label" - err := store.UpdateTx(t.Context(), db.UpdateTxParams{ + _, err := store.GetTx(t.Context(), db.GetTxQuery{ 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. +// TestUpdateTxRequiresExistingConfirmedBlock verifies that UpdateTx rejects a +// state patch whose referenced block height is missing from the shared blocks +// table. // // Scenario: -// - One wallet transaction exists, but the caller provides no label or state -// mutation. +// - 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: -// - Call UpdateTx with an empty patch. +// - Apply the confirmation patch through UpdateTx. // // Assertions: -// - UpdateTx returns ErrInvalidParam. -func TestUpdateTxRejectsEmptyPatch(t *testing.T) { +// - UpdateTx returns ErrBlockNotFound. +// - The transaction remains unconfirmed. +func TestUpdateTxRequiresExistingConfirmedBlock(t *testing.T) { t.Parallel() store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-update-empty-patch") + walletID := newWallet(t, store, "wallet-confirmed-tx-missing-block") tx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 6000, PkScript: []byte{0x51}}}, + []*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(1710000720, 0), + Received: time.Unix(1710000570, 0), Status: db.TxStatusPending, }) require.NoError(t, err) @@ -1024,983 +1240,1236 @@ func TestUpdateTxRejectsEmptyPatch(t *testing.T) { 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.ErrInvalidParam) + 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) } -// TestListTxnsReturnsRowsWithoutBlock verifies that the no-confirming-block -// query path excludes confirmed rows while still surfacing retained invalid -// history that no longer has block metadata. +// 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 wallet has confirmed history, active unmined history, and retained -// invalid history without blocks. +// - One stored pending transaction is later patched with mismatched block +// metadata for an existing height. // // Setup: -// - Insert one confirmed transaction, one pending transaction, and one failed -// transaction whose block is nil. +// - 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: -// - Query ListTxns with UnminedOnly set. +// - Apply the mismatched confirmation patch through UpdateTx. // // Assertions: -// - Only unmined rows are returned. -// - Both the active pending row and the failed history row are present. -func TestListTxnsReturnsRowsWithoutBlock(t *testing.T) { +// - 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-list-txns-without-block") + walletID := newWallet(t, store, "wallet-update-tx-block-mismatch") 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( + tx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 8100, PkScript: []byte{0x53}}}, + []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, ) err := store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: confirmedTx, - Received: time.Unix(1710000800, 0), - Status: db.TxStatusPublished, + 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: confirmedTx.TxHash(), + Txid: tx.TxHash(), State: &db.UpdateTxState{ - Block: &confirmedBlock, + Block: &mismatchBlock, 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) + require.ErrorIs(t, err, db.ErrBlockMismatch) - err = store.CreateTx(t.Context(), db.CreateTxParams{ + info, err := store.GetTx(t.Context(), db.GetTxQuery{ 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, + Txid: tx.TxHash(), }) 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()]) + require.Nil(t, info.Block) + require.Equal(t, db.TxStatusPending, info.Status) } -// TestListTxnsReturnsConfirmedTxsByHeightRange verifies that the -// confirmed-range query path excludes unmined rows and respects the height -// bounds. +// TestUpdateTxUpdatesStoredLabel verifies that UpdateTx can patch the stored +// user-visible label without mutating chain-state metadata. // // Scenario: -// - One wallet has confirmed transactions at multiple heights plus one -// unmined row. +// - One pending wallet transaction already exists with an old label. // // Setup: -// - Insert two confirmed transactions at different heights and one pending -// transaction without a block. +// - Create one wallet and insert one pending transaction row with a label. // // Action: -// - Query ListTxns for one exact confirmed height range. +// - Patch only the label through UpdateTx. // // Assertions: -// - Only the matching confirmed transaction is returned. -// - The unmined row is excluded. -func TestListTxnsReturnsConfirmedTxsByHeightRange(t *testing.T) { +// - 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-list-txns-confirmed") - queries := store.Queries() - - blockOne := CreateBlockFixture(t, queries, 210) - blockTwo := CreateBlockFixture(t, queries, 211) + walletID := newWallet(t, store, "wallet-update-tx-label") - 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( + tx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 11000, PkScript: []byte{0x53}}}, + []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, ) 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, - }, + Tx: tx, + Received: time.Unix(1710000700, 0), + Status: db.TxStatusPending, + Label: "old-label", }) 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) + label := "new-label" err = store.UpdateTx(t.Context(), db.UpdateTxParams{ WalletID: walletID, - Txid: txTwo.TxHash(), - State: &db.UpdateTxState{ - Block: &blockTwo, - Status: db.TxStatusPublished, - }, + Txid: tx.TxHash(), + Label: &label, }) require.NoError(t, err) - err = store.CreateTx(t.Context(), db.CreateTxParams{ + info, err := store.GetTx(t.Context(), db.GetTxQuery{ 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, + Txid: tx.TxHash(), }) 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) + require.Equal(t, "new-label", info.Label) + require.Equal(t, db.TxStatusPending, info.Status) } -// TestListTxnsUsesConfirmationOrder verifies that confirmed reads do not use -// row IDs as the same-block ordering tie-breaker. +// TestUpdateTxConfirmsStoredPendingTx verifies that UpdateTx can attach a +// confirming block to an already-stored unmined row. // // Scenario: -// - One wallet sees a transaction as unmined before another transaction that -// appears earlier in the same block. +// - One pending wallet transaction is later observed in a block. // // Setup: -// - Insert the later block transaction as unmined first. -// - Insert the earlier block transaction directly as confirmed. -// - Confirm the older unmined row in the same block. +// - Create one wallet and insert one pending transaction row. +// - Insert one matching block row. // // Action: -// - Query summary and detail transactions for that exact block height. +// - Apply a published state patch with that block through UpdateTx. // // Assertions: -// - Both SQL reader paths return the direct confirmed transaction before the -// older row that was confirmed later. -func TestListTxnsUsesConfirmationOrder(t *testing.T) { +// - The transaction now carries the block metadata. +// - The status becomes published. +func TestUpdateTxConfirmsStoredPendingTx(t *testing.T) { t.Parallel() - // Arrange: Give laterBlockTx the smaller row ID, which used to make it sort - // before earlierBlockTx after both rows were attached to the same block. store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-list-txns-confirm-order") + walletID := newWallet(t, store, "wallet-update-tx-confirm") queries := store.Queries() - const blockHeight = 222 - - block := CreateBlockFixture(t, queries, blockHeight) - - laterBlockTx := newRegularTx( - []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 17000, PkScript: []byte{0x51}}}, - ) - earlierBlockTx := newRegularTx( + tx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 18000, PkScript: []byte{0x52}}}, + []*wire.TxOut{{Value: 6000, PkScript: []byte{0x51}}}, ) err := store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: laterBlockTx, - Received: time.Unix(1710000980, 0), + Tx: tx, + Received: time.Unix(1710000710, 0), Status: db.TxStatusPending, }) require.NoError(t, err) - err = store.CreateTx(t.Context(), db.CreateTxParams{ + block := CreateBlockFixture(t, queries, 220) + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ WalletID: walletID, - Tx: earlierBlockTx, - Received: time.Unix(1710000990, 0), - Block: &block, - Status: db.TxStatusPublished, + Txid: tx.TxHash(), + State: &db.UpdateTxState{ + Block: &block, + Status: db.TxStatusPublished, + }, }) require.NoError(t, err) - err = store.CreateTx(t.Context(), db.CreateTxParams{ + info, err := store.GetTx(t.Context(), db.GetTxQuery{ WalletID: walletID, - Tx: laterBlockTx, - Received: time.Unix(1710001000, 0), - Block: &block, - Status: db.TxStatusPublished, + 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) +} - // Act: Read the same confirmed block through summary and detail APIs. - infos, err := store.ListTxns(t.Context(), db.ListTxnsQuery{ - WalletID: walletID, - StartHeight: blockHeight, - EndHeight: blockHeight, - }) - require.NoError(t, err) +// 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() - details, err := store.ListTxDetails(t.Context(), db.ListTxDetailsQuery{ - WalletID: walletID, - StartHeight: blockHeight, - EndHeight: blockHeight, - }) - require.NoError(t, err) + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-update-label-missing") - // Assert: The later-confirmed old row stays after the direct confirmed row. - wantOrder := []chainhash.Hash{ - earlierBlockTx.TxHash(), - laterBlockTx.TxHash(), - } - require.Equal(t, wantOrder, txHashes(infos)) - require.Equal(t, wantOrder, txDetailHashes(details)) + label := "new-label" + err := store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: RandomHash(), + Label: &label, + }) + require.ErrorIs(t, err, db.ErrTxNotFound) } -// TestGetTxDetailFindsOwnedInputAfterSpendEdgeCleared verifies that detail -// reads reconstruct historical debits after invalidation clears spent_by_tx_id. +// TestUpdateTxRejectsEmptyPatch verifies that UpdateTx rejects a request that +// does not ask to mutate any transaction field. // // Scenario: -// - One wallet-owned parent output is spent by an unmined child transaction. -// - The child is invalidated, clearing the mutable spend edge while retaining -// the child transaction row as failed history. +// - One wallet transaction exists, but the caller provides no label or state +// mutation. // // Setup: -// - Insert one wallet-owned parent credit and one child that spends it. -// - Invalidate the child transaction. +// - Create one wallet and insert one pending transaction row. // // Action: -// - Query the failed child through GetTxDetail. +// - Call UpdateTx with an empty patch. // // Assertions: -// - The parent output is spendable again, proving the spend edge was cleared. -// - The child detail still reports the wallet-owned input from its raw tx. -func TestGetTxDetailFindsOwnedInputAfterSpendEdgeCleared(t *testing.T) { +// - UpdateTx returns ErrInvalidParam. +func TestUpdateTxRejectsEmptyPatch(t *testing.T) { t.Parallel() - // Arrange: Create a wallet-owned parent credit and a child that spends it. store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-detail-cleared-spend-edge") - createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + walletID := newWallet(t, store, "wallet-update-empty-patch") - addr := newDerivedAddress( - t, store, walletID, db.KeyScopeBIP0084, "default", false, - ) - parentTx := newRegularTx( + tx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + []*wire.TxOut{{Value: 6000, PkScript: []byte{0x51}}}, ) err := store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: parentTx, - Received: time.Unix(1710001010, 0), - Status: db.TxStatusPending, - Credits: map[uint32]address.Address{0: nil}, - }) - require.NoError(t, err) - - spentOutPoint := wire.OutPoint{Hash: parentTx.TxHash(), Index: 0} - childTx := newRegularTx( - []wire.OutPoint{spentOutPoint}, - []*wire.TxOut{{Value: 4000, PkScript: []byte{0x51}}}, - ) - err = store.CreateTx(t.Context(), db.CreateTxParams{ - WalletID: walletID, - Tx: childTx, - Received: time.Unix(1710001020, 0), + Tx: tx, + Received: time.Unix(1710000720, 0), Status: db.TxStatusPending, }) require.NoError(t, err) - err = store.InvalidateUnminedTx(t.Context(), db.InvalidateUnminedTxParams{ - WalletID: walletID, - Txid: childTx.TxHash(), - }) - require.NoError(t, err) - - // Act: Read the failed child detail after its spend edge has been cleared. - detail, err := store.GetTxDetail(t.Context(), db.GetTxDetailQuery{ + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ WalletID: walletID, - Txid: childTx.TxHash(), + Txid: tx.TxHash(), }) - require.NoError(t, err) + require.ErrorIs(t, err, db.ErrInvalidParam) +} - parentUtxo, err := store.GetUtxo(t.Context(), db.GetUtxoQuery{ - WalletID: walletID, - OutPoint: spentOutPoint, - }) - require.NoError(t, err) - - // Assert: The current UTXO set is restored, but historical child details - // still report the wallet-owned debit from the serialized child - // transaction. - require.Equal(t, btcutil.Amount(5000), parentUtxo.Amount) - require.Equal(t, db.TxStatusFailed, detail.Status) - require.Len(t, detail.OwnedInputs, 1) - require.Equal(t, uint32(0), detail.OwnedInputs[0].Index) - require.Equal(t, btcutil.Amount(5000), detail.OwnedInputs[0].Amount) -} - -// TestDeleteTxRemovesLeafUnminedTx verifies that DeleteTx removes a leaf -// unmined row and restores any parent spend markers it introduced. +// 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 unmined child transaction is the only spender of one wallet-owned -// parent output. +// - One wallet has confirmed history, active unmined history, and retained +// invalid history without blocks. // // Setup: -// - Create one wallet-owned parent credit and one unmined child spender. +// - Insert one confirmed transaction, one pending transaction, and one failed +// transaction whose block is nil. // // Action: -// - Delete the child through DeleteTx. +// - Query ListTxns with UnminedOnly set. // // Assertions: -// - The child row is removed. -// - The parent output becomes spendable again. -// - No child spend edges remain. -func TestDeleteTxRemovesLeafUnminedTx(t *testing.T) { +// - 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-delete-leaf") - createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + walletID := newWallet(t, store, "wallet-list-txns-without-block") + queries := store.Queries() - addr := newDerivedAddress( - t, store, walletID, db.KeyScopeBIP0084, "default", false, + confirmedBlock := CreateBlockFixture(t, queries, 200) + confirmedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 7000, PkScript: []byte{0x51}}}, ) - - parentTx := newRegularTx( + unminedTx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + []*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: parentTx, - Received: time.Unix(1710001000, 0), - Status: db.TxStatusPending, - Credits: map[uint32]address.Address{0: nil}, + 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) - - 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), + Tx: unminedTx, + Received: time.Unix(1710000810, 0), Status: db.TxStatusPending, }) require.NoError(t, err) - err = store.DeleteTx(t.Context(), db.DeleteTxParams{ + err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Txid: childTx.TxHash(), + Tx: failedTx, + Received: time.Unix(1710000815, 0), + Status: db.TxStatusPending, }) 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, - })) + 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()]) } -// TestDeleteTxRejectsNonLeafTx verifies that DeleteTx refuses to erase an -// unmined transaction that still has direct child spenders. +// TestListTxnsReturnsConfirmedTxsByHeightRange verifies that the +// confirmed-range query path excludes unmined rows and respects the height +// bounds. // // Scenario: -// - One parent transaction still has one direct unmined child spender. +// - One wallet has confirmed transactions at multiple heights plus one +// unmined row. // // Setup: -// - Create one wallet-owned parent credit and one child that spends it. +// - Insert two confirmed transactions at different heights and one pending +// transaction without a block. // // Action: -// - Attempt to delete the parent through DeleteTx. +// - Query ListTxns for one exact confirmed height range. // // Assertions: -// - DeleteTx returns ErrDeleteRequiresLeaf. -// - Both parent and child rows remain stored. -func TestDeleteTxRejectsNonLeafTx(t *testing.T) { +// - 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-delete-non-leaf") - createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + walletID := newWallet(t, store, "wallet-list-txns-confirmed") + queries := store.Queries() - addr := newDerivedAddress( - t, store, walletID, db.KeyScopeBIP0084, "default", false, - ) + blockOne := CreateBlockFixture(t, queries, 210) + blockTwo := CreateBlockFixture(t, queries, 211) - parentTx := newRegularTx( + txOne := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + []*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: parentTx, - Received: time.Unix(1710001100, 0), - Status: db.TxStatusPending, - Credits: map[uint32]address.Address{0: nil}, + 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) - 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: 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: childTx, - Received: time.Unix(1710001110, 0), + Tx: unminedTx, + Received: time.Unix(1710000920, 0), Status: db.TxStatusPending, - Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) - err = store.DeleteTx(t.Context(), db.DeleteTxParams{ - WalletID: walletID, - Txid: parentTx.TxHash(), + infos, err := store.ListTxns(t.Context(), db.ListTxnsQuery{ + WalletID: walletID, + StartHeight: 211, + EndHeight: 211, }) - 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) + 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) } -// TestDeleteTxRemovesParentWithFailedChild verifies that DeleteTx only treats -// still-active unmined children as leaf blockers. +// TestListTxnsUsesConfirmationOrder verifies that confirmed reads do not use +// row IDs as the same-block ordering tie-breaker. // // Scenario: -// - One parent transaction still has one direct child row, but that child has -// already been marked failed. +// - One wallet sees a transaction as unmined before another transaction that +// appears earlier in the same block. // // Setup: -// - Create one wallet-owned parent credit and one child that spends it. -// - Mark the child failed to simulate an already-invalid branch. +// - Insert the later block transaction as unmined first. +// - Insert the earlier block transaction directly as confirmed. +// - Confirm the older unmined row in the same block. // // Action: -// - Delete the parent through DeleteTx. +// - Query summary and detail transactions for that exact block height. // // 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) { +// - Both SQL reader paths return the direct confirmed transaction before the +// older row that was confirmed later. +func TestListTxnsUsesConfirmationOrder(t *testing.T) { t.Parallel() + // Arrange: Give laterBlockTx the smaller row ID, which used to make it sort + // before earlierBlockTx after both rows were attached to the same block. store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-delete-parent-failed-child") - createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + walletID := newWallet(t, store, "wallet-list-txns-confirm-order") + queries := store.Queries() - addr := newDerivedAddress( - t, store, walletID, db.KeyScopeBIP0084, "default", false, - ) + const blockHeight = 222 - parentTx := newRegularTx( + block := CreateBlockFixture(t, queries, blockHeight) + + laterBlockTx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + []*wire.TxOut{{Value: 17000, PkScript: []byte{0x51}}}, + ) + earlierBlockTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 18000, PkScript: []byte{0x52}}}, ) err := store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: parentTx, - Received: time.Unix(1710001115, 0), + Tx: laterBlockTx, + Received: time.Unix(1710000980, 0), Status: db.TxStatusPending, - Credits: map[uint32]address.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]address.Address{0: nil}, + Tx: earlierBlockTx, + Received: time.Unix(1710000990, 0), + Block: &block, + Status: db.TxStatusPublished, }) require.NoError(t, err) - setTxStatus(t, store, walletID, childTx.TxHash(), db.TxStatusFailed) - err = store.DeleteTx(t.Context(), db.DeleteTxParams{ + err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Txid: parentTx.TxHash(), + Tx: laterBlockTx, + Received: time.Unix(1710001000, 0), + Block: &block, + Status: db.TxStatusPublished, }) require.NoError(t, err) - _, ok := txIDByHash(t, store, walletID, parentTx.TxHash()) - require.False(t, ok) + // Act: Read the same confirmed block through summary and detail APIs. + infos, err := store.ListTxns(t.Context(), db.ListTxnsQuery{ + WalletID: walletID, + StartHeight: blockHeight, + EndHeight: blockHeight, + }) + require.NoError(t, err) + + details, err := store.ListTxDetails(t.Context(), db.ListTxDetailsQuery{ + WalletID: walletID, + StartHeight: blockHeight, + EndHeight: blockHeight, + }) + require.NoError(t, err) + + // Assert: The later-confirmed old row stays after the direct confirmed row. + wantOrder := []chainhash.Hash{ + earlierBlockTx.TxHash(), + laterBlockTx.TxHash(), + } + require.Equal(t, wantOrder, txHashes(infos)) + require.Equal(t, wantOrder, txDetailHashes(details)) } -// TestRollbackToBlockFailsCoinbaseDescendants verifies that RollbackToBlock -// marks every unmined descendant of a disconnected coinbase root as failed and -// clears the recorded spend edges they had claimed. +// TestGetTxDetailFindsOwnedInputAfterSpendEdgeCleared verifies that detail +// reads reconstruct historical debits after invalidation clears spent_by_tx_id. // // Scenario: -// - One confirmed coinbase credit has one unmined child spender and one -// unmined grandchild spender beneath it. +// - One wallet-owned parent output is spent by an unmined child transaction. +// - The child is invalidated, clearing the mutable spend edge while retaining +// the child transaction row as failed history. // // 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. +// - Insert one wallet-owned parent credit and one child that spends it. +// - Invalidate the child transaction. // // Action: -// - Roll back the block that confirmed the coinbase root. +// - Query the failed child through GetTxDetail. // // 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) { +// - The parent output is spendable again, proving the spend edge was cleared. +// - The child detail still reports the wallet-owned input from its raw tx. +func TestGetTxDetailFindsOwnedInputAfterSpendEdgeCleared(t *testing.T) { t.Parallel() + // Arrange: Create a wallet-owned parent credit and a child that spends it. store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-rollback-coinbase-descendants") + walletID := newWallet(t, store, "wallet-detail-cleared-spend-edge") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") - queries := store.Queries() addr := newDerivedAddress( t, store, walletID, db.KeyScopeBIP0084, "default", false, ) - coinbaseTx := newCoinbaseTx(addr.ScriptPubKey) + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: 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, + Tx: parentTx, + Received: time.Unix(1710001010, 0), + Status: db.TxStatusPending, Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) + spentOutPoint := wire.OutPoint{Hash: parentTx.TxHash(), Index: 0} childTx := newRegularTx( - []wire.OutPoint{{Hash: coinbaseTx.TxHash(), Index: 0}}, - []*wire.TxOut{{Value: 4000, PkScript: addr.ScriptPubKey}}, + []wire.OutPoint{spentOutPoint}, + []*wire.TxOut{{Value: 4000, PkScript: []byte{0x51}}}, ) err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, Tx: childTx, - Received: time.Unix(1710001210, 0), - Status: db.TxStatusPending, - Credits: map[uint32]address.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), + Received: time.Unix(1710001020, 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) - - coinbaseInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + err = store.InvalidateUnminedTx(t.Context(), db.InvalidateUnminedTxParams{ WalletID: walletID, - Txid: coinbaseTx.TxHash(), + Txid: childTx.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{ + // Act: Read the failed child detail after its spend edge has been cleared. + detail, err := store.GetTxDetail(t.Context(), db.GetTxDetailQuery{ 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{ + parentUtxo, err := store.GetUtxo(t.Context(), db.GetUtxoQuery{ WalletID: walletID, - Txid: grandchildTx.TxHash(), + OutPoint: spentOutPoint, }) 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())) + // Assert: The current UTXO set is restored, but historical child details + // still report the wallet-owned debit from the serialized child + // transaction. + require.Equal(t, btcutil.Amount(5000), parentUtxo.Amount) + require.Equal(t, db.TxStatusFailed, detail.Status) + require.Len(t, detail.OwnedInputs, 1) + require.Equal(t, uint32(0), detail.OwnedInputs[0].Index) + require.Equal(t, btcutil.Amount(5000), detail.OwnedInputs[0].Amount) } -// TestRollbackToBlockRewindsSyncToStoredLowerBlock verifies rollback rewinds -// wallet sync references to the greatest stored block below the rollback -// boundary instead of assuming height-1 exists in sparse block tables. -func TestRollbackToBlockRewindsSyncToStoredLowerBlock(t *testing.T) { +// 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) - queries := store.Queries() - walletName := "wallet-rollback-sparse-sync" - walletID := newWallet(t, store, walletName) + walletID := newWallet(t, store, "wallet-delete-leaf") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") - forkBlock := CreateBlockFixture(t, queries, 5) - rollbackBlock := CreateBlockFixture(t, queries, 10) + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) - err := store.UpdateWallet(t.Context(), db.UpdateWalletParams{ + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - SyncedTo: &forkBlock, + Tx: parentTx, + Received: time.Unix(1710001000, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) - err = store.UpdateWallet(t.Context(), db.UpdateWalletParams{ + 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, - SyncedTo: &rollbackBlock, + Tx: childTx, + Received: time.Unix(1710001010, 0), + Status: db.TxStatusPending, }) require.NoError(t, err) - err = store.RollbackToBlock(t.Context(), rollbackBlock.Height) - require.NoError(t, err) - - walletInfo, err := store.GetWallet(t.Context(), walletName) + err = store.DeleteTx(t.Context(), db.DeleteTxParams{ + WalletID: walletID, + Txid: childTx.TxHash(), + }) require.NoError(t, err) - require.NotNil(t, walletInfo.SyncedTo) - require.Equal(t, forkBlock.Height, walletInfo.SyncedTo.Height) - require.Equal(t, forkBlock.Hash, walletInfo.SyncedTo.Hash) + 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, + })) } -// TestCreateTxReconfirmsOrphanedCoinbase verifies that CreateTx can restore an -// orphaned coinbase row to confirmed history when the same coinbase hash later -// re-enters the best chain. +// TestDeleteTxRejectsNonLeafTx verifies that DeleteTx refuses to erase an +// unmined transaction that still has direct child spenders. // // Scenario: -// - One wallet already stores one orphaned coinbase transaction after -// rollback. +// - One parent transaction still has one direct unmined child spender. // // 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. +// - Create one wallet-owned parent credit and one child that spends it. // // Action: -// - Call CreateTx again with the same coinbase transaction and new block. +// - Attempt to delete the parent through DeleteTx. // // 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) { +// - 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-reconfirm-orphaned-coinbase") + walletID := newWallet(t, store, "wallet-delete-non-leaf") 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) + + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) err := store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: coinbaseTx, - Received: time.Unix(1710001540, 0), - Block: &block, - Status: db.TxStatusPublished, + Tx: parentTx, + Received: time.Unix(1710001100, 0), + Status: db.TxStatusPending, Credits: map[uint32]address.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) + childTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 4000, PkScript: addr.ScriptPubKey}}, + ) - 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, + Tx: childTx, + Received: time.Unix(1710001110, 0), + Status: db.TxStatusPending, Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) - info, err := store.GetTx(t.Context(), db.GetTxQuery{ + err = store.DeleteTx(t.Context(), db.DeleteTxParams{ WalletID: walletID, - Txid: coinbaseTx.TxHash(), + Txid: parentTx.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, - })) + 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) } -// TestGetUtxoReturnsCurrentWalletOutput verifies that GetUtxo returns a stored -// wallet-owned output created by an unmined transaction. -func TestGetUtxoReturnsCurrentWalletOutput(t *testing.T) { +// 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-get-utxo") + 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, ) - tx := newRegularTx( + parentTx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 15000, PkScript: addr.ScriptPubKey}}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, ) err := store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: tx, - Received: time.Unix(1710001400, 0), + Tx: parentTx, + Received: time.Unix(1710001115, 0), Status: db.TxStatusPending, Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) - utxo, err := store.GetUtxo(t.Context(), db.GetUtxoQuery{ + 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, - OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Tx: childTx, + Received: time.Unix(1710001120, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, }) - 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") + setTxStatus(t, store, walletID, childTx.TxHash(), db.TxStatusFailed) - _, err := store.GetUtxo(t.Context(), db.GetUtxoQuery{ + err = store.DeleteTx(t.Context(), db.DeleteTxParams{ WalletID: walletID, - OutPoint: randomOutPoint(), + Txid: parentTx.TxHash(), }) + require.NoError(t, err) - require.ErrorIs(t, err, db.ErrUtxoNotFound) + _, ok := txIDByHash(t, store, walletID, parentTx.TxHash()) + require.False(t, ok) } -// TestListUTXOsReturnsCurrentWalletOutputs verifies that ListUTXOs returns the -// current wallet-owned outputs created by pending transactions. -func TestListUTXOsReturnsCurrentWalletOutputs(t *testing.T) { +// 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: +// - 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) { t.Parallel() store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-list-utxos") + 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) - txOne := newRegularTx( - []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 15000, PkScript: addr.ScriptPubKey}}, - ) - txTwo := newRegularTx( - []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 12000, PkScript: addr.ScriptPubKey}}, - ) - + block := CreateBlockFixture(t, queries, 300) err := store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: txOne, - Received: time.Unix(1710001500, 0), - Status: db.TxStatusPending, + Tx: coinbaseTx, + Received: time.Unix(1710001200, 0), + Block: &block, + Status: db.TxStatusPublished, Credits: map[uint32]address.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: txTwo, - Received: time.Unix(1710001510, 0), + Tx: childTx, + Received: time.Unix(1710001210, 0), Status: db.TxStatusPending, Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) - utxos, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + 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, utxos, 2) - require.Equal(t, txTwo.TxHash(), utxos[0].OutPoint.Hash) - require.Equal(t, txOne.TxHash(), utxos[1].OutPoint.Hash) -} - -// TestUTXOEnrichmentFields verifies that ListUTXOs and GetUtxo populate the -// UtxoInfo enrichment fields (AccountName, AddrType, HasScript, IsLocked, -// KeyScope) from the backing account/address/lease joins. It pairs a positive -// case (an imported script address with an active lease) against a negative -// case (a derived address with neither a script nor a lease) so a regression in -// any join or row-to-UtxoInfo conversion fails the assertions. -func TestUTXOEnrichmentFields(t *testing.T) { - t.Parallel() - - store := NewTestStore(t) - - // A watch-only wallet can host an imported script-only address: such an - // address carries a persisted encrypted script (so HasScript is true) - // but no private key, which the ADR 0012 invariant only permits on a - // watch-only wallet. - walletID := newWatchOnlyWallet(t, store, "wallet-utxo-enrichment") - - const derivedName = "derived" - scope := db.KeyScopeBIP0084 + require.Len(t, childSpendingTxIDs(t, store, walletID, coinbaseTx.TxHash()), + 1) + require.Len(t, childSpendingTxIDs(t, store, walletID, childTx.TxHash()), 1) - // Imported script address: the encrypted script secret drives HasScript, - // while the raw import stays accountless. - importedScript := RandomBytes(32) - _, err := store.NewImportedAddress( - t.Context(), db.NewImportedAddressParams{ - WalletID: walletID, - AddressType: db.WitnessScript, - PubKey: RandomBytes(33), - ScriptPubKey: importedScript, - EncryptedScript: RandomBytes(48), - }, - ) + err = store.RollbackToBlock(t.Context(), block.Height) require.NoError(t, err) - // Derived address: no script secret, so HasScript stays false. - createDerivedAccount(t, store, walletID, scope, derivedName) - derivedAddr := newDerivedAddress( - t, store, walletID, scope, derivedName, false, - ) - - // Record one UTXO under each address. - importedTx := newRegularTx( - []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 20000, PkScript: importedScript}}, - ) - derivedTx := newRegularTx( - []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 30000, PkScript: derivedAddr.ScriptPubKey}}, - ) - - for _, tx := range []*wire.MsgTx{importedTx, derivedTx} { - err = store.CreateTx(t.Context(), db.CreateTxParams{ - WalletID: walletID, - Tx: tx, - Received: time.Unix(1710002000, 0), - Status: db.TxStatusPending, - Credits: map[uint32]address.Address{0: nil}, - }) - require.NoError(t, err) - } - - importedOutPoint := wire.OutPoint{Hash: importedTx.TxHash(), Index: 0} - derivedOutPoint := wire.OutPoint{Hash: derivedTx.TxHash(), Index: 0} - - // Lease only the imported UTXO so IsLocked distinguishes the two rows. - _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + coinbaseInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ WalletID: walletID, - OutPoint: importedOutPoint, - ID: db.LockID{1}, - Duration: 30 * time.Minute, + Txid: coinbaseTx.TxHash(), }) require.NoError(t, err) + require.Equal(t, db.TxStatusOrphaned, coinbaseInfo.Status) + require.Nil(t, coinbaseInfo.Block) - // Index ListUTXOs results by outpoint so assertions don't depend on the - // result ordering. - utxos, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + childInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ WalletID: walletID, + Txid: childTx.TxHash(), }) require.NoError(t, err) - require.Len(t, utxos, 2) - - byOutPoint := make(map[wire.OutPoint]db.UtxoInfo, len(utxos)) - for _, u := range utxos { - byOutPoint[u.OutPoint] = u - } + 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())) +} + +// TestRollbackToBlockRewindsSyncToStoredLowerBlock verifies rollback rewinds +// wallet sync references to the greatest stored block below the rollback +// boundary instead of assuming height-1 exists in sparse block tables. +func TestRollbackToBlockRewindsSyncToStoredLowerBlock(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + queries := store.Queries() + walletName := "wallet-rollback-sparse-sync" + walletID := newWallet(t, store, walletName) + + forkBlock := CreateBlockFixture(t, queries, 5) + rollbackBlock := CreateBlockFixture(t, queries, 10) + + err := store.UpdateWallet(t.Context(), db.UpdateWalletParams{ + WalletID: walletID, + SyncedTo: &forkBlock, + }) + require.NoError(t, err) + + err = store.UpdateWallet(t.Context(), db.UpdateWalletParams{ + WalletID: walletID, + SyncedTo: &rollbackBlock, + }) + require.NoError(t, err) + + err = store.RollbackToBlock(t.Context(), rollbackBlock.Height) + require.NoError(t, err) + + walletInfo, err := store.GetWallet(t.Context(), walletName) + require.NoError(t, err) + require.NotNil(t, walletInfo.SyncedTo) + require.Equal(t, forkBlock.Height, walletInfo.SyncedTo.Height) + require.Equal(t, forkBlock.Hash, walletInfo.SyncedTo.Hash) +} + +// 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]address.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]address.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) { + 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]address.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) +} + +// 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]address.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]address.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) +} + +// TestUTXOEnrichmentFields verifies that ListUTXOs and GetUtxo populate the +// UtxoInfo enrichment fields (AccountName, AddrType, HasScript, IsLocked, +// KeyScope) from the backing account/address/lease joins. It pairs a positive +// case (an imported script address with an active lease) against a negative +// case (a derived address with neither a script nor a lease) so a regression in +// any join or row-to-UtxoInfo conversion fails the assertions. +func TestUTXOEnrichmentFields(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + + // A watch-only wallet can host an imported script-only address: such an + // address carries a persisted encrypted script (so HasScript is true) + // but no private key, which the ADR 0012 invariant only permits on a + // watch-only wallet. + walletID := newWatchOnlyWallet(t, store, "wallet-utxo-enrichment") + + const derivedName = "derived" + + scope := db.KeyScopeBIP0084 + + // Imported script address: the encrypted script secret drives HasScript, + // while the raw import stays accountless. + importedScript := RandomBytes(32) + _, err := store.NewImportedAddress( + t.Context(), db.NewImportedAddressParams{ + WalletID: walletID, + AddressType: db.WitnessScript, + PubKey: RandomBytes(33), + ScriptPubKey: importedScript, + EncryptedScript: RandomBytes(48), + }, + ) + require.NoError(t, err) + + // Derived address: no script secret, so HasScript stays false. + createDerivedAccount(t, store, walletID, scope, derivedName) + derivedAddr := newDerivedAddress( + t, store, walletID, scope, derivedName, false, + ) + + // Record one UTXO under each address. + importedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 20000, PkScript: importedScript}}, + ) + derivedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 30000, PkScript: derivedAddr.ScriptPubKey}}, + ) + + for _, tx := range []*wire.MsgTx{importedTx, derivedTx} { + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710002000, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + } + + importedOutPoint := wire.OutPoint{Hash: importedTx.TxHash(), Index: 0} + derivedOutPoint := wire.OutPoint{Hash: derivedTx.TxHash(), Index: 0} + + // Lease only the imported UTXO so IsLocked distinguishes the two rows. + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + OutPoint: importedOutPoint, + ID: db.LockID{1}, + Duration: 30 * time.Minute, + }) + require.NoError(t, err) + + // Index ListUTXOs results by outpoint so assertions don't depend on the + // result ordering. + utxos, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + WalletID: walletID, + }) + require.NoError(t, err) + require.Len(t, utxos, 2) + + byOutPoint := make(map[wire.OutPoint]db.UtxoInfo, len(utxos)) + for _, u := range utxos { + byOutPoint[u.OutPoint] = u + } imported, ok := byOutPoint[importedOutPoint] require.True(t, ok, "imported UTXO missing from ListUTXOs") @@ -2010,200 +2479,696 @@ func TestUTXOEnrichmentFields(t *testing.T) { require.True(t, imported.IsLocked) require.Equal(t, db.KeyScope{}, imported.KeyScope) - importedName := db.DefaultImportedAccountName - filteredImported, err := store.ListUTXOs( - t.Context(), db.ListUtxosQuery{ - WalletID: walletID, - Scope: &scope, - AccountName: &importedName, + importedName := db.DefaultImportedAccountName + filteredImported, err := store.ListUTXOs( + t.Context(), db.ListUtxosQuery{ + WalletID: walletID, + Scope: &scope, + AccountName: &importedName, + }, + ) + require.NoError(t, err) + require.Empty(t, filteredImported) + + derived, ok := byOutPoint[derivedOutPoint] + require.True(t, ok, "derived UTXO missing from ListUTXOs") + require.Equal(t, derivedName, derived.AccountName) + require.Equal(t, db.WitnessPubKey, derived.AddrType) + require.False(t, derived.HasScript) + require.False(t, derived.IsLocked) + require.Equal(t, scope, derived.KeyScope) + + // GetUtxo surfaces the same enriched view as ListUTXOs. + got, err := store.GetUtxo(t.Context(), db.GetUtxoQuery{ + WalletID: walletID, + OutPoint: importedOutPoint, + }) + require.NoError(t, err) + require.Equal(t, db.WitnessScript, got.AddrType) + require.True(t, got.HasScript) + require.True(t, got.IsLocked) + require.Equal(t, db.KeyScope{}, got.KeyScope) +} + +// 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]address.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]address.Address{0: nil}, + }) + require.NoError(t, err) + + scope := db.KeyScopeBIP0084 + account := uint32(1) + utxos, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + WalletID: walletID, + Scope: &scope, + Account: &account, + }) + + require.NoError(t, err) + require.Len(t, utxos, 1) + require.Equal(t, txSavings.TxHash(), utxos[0].OutPoint.Hash) +} + +// TestAccountNumberFiltersExcludeImportedAccounts verifies that numeric account +// filters do not match imported-xpub accounts even if corrupt metadata gives +// the imported account an account number. +func TestAccountNumberFiltersExcludeImportedAccounts(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + queries := store.Queries() + dbConn := store.DB() + walletID := newWatchOnlyWallet( + t, store, "wallet-utxo-imported-account-number", + ) + + accountName := hardwareAccountName + CreateImportedAccount( + t, store, walletID, db.KeyScopeBIP0084, accountName, true, + ) + + scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) + accountID := GetAccountID(t, queries, scopeID, accountName) + scriptPubKey := RandomBytes(22) + err := createDerivedAddressRaw( + t, queries, walletID, accountID, 0, 0, scriptPubKey, + ) + require.NoError(t, err) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 19000, PkScript: scriptPubKey}}, + ) + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001650, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + accountNumber := uint32(7) + err = updateAccountNumberRaw(t, dbConn, accountID, accountNumber) + require.Error(t, err) + requireDriverConstraintError(t, err) + + unfiltered, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + WalletID: walletID, + }) + require.NoError(t, err) + require.Len(t, unfiltered, 1) + require.Equal(t, tx.TxHash(), unfiltered[0].OutPoint.Hash) + + scope := db.KeyScopeBIP0084 + filtered, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + WalletID: walletID, + Scope: &scope, + Account: &accountNumber, + }) + require.NoError(t, err) + require.Empty(t, filtered) + + balance, err := store.Balance(t.Context(), db.BalanceParams{ + WalletID: walletID, + Scope: &scope, + Account: &accountNumber, + }) + require.NoError(t, err) + require.Zero(t, balance.Total) +} + +// TestUTXOReadsRejectMalformedDerivedAddressShape verifies that per-row UTXO +// reads reject malformed derived address shape instead of silently reporting +// the output under the raw imported alias. Balance aggregation should exclude +// the malformed row and still count well-formed UTXOs. +func TestUTXOReadsRejectMalformedDerivedAddressShape(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + queries := store.Queries() + walletID := newWallet(t, store, "wallet-utxo-malformed-address") + accountName := defaultAccountName + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) + + goodAddr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, accountName, false, + ) + scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) + accountID := GetAccountID(t, queries, scopeID, accountName) + badScript := RandomBytes(22) + _, err := createDerivedAddressParentRaw( + t, queries, walletID, accountID, badScript, + ) + require.NoError(t, err) + + goodTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 41000, PkScript: goodAddr.ScriptPubKey}}, + ) + badTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 42000, PkScript: badScript}}, + ) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: goodTx, + Received: time.Unix(1710001660, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: badTx, + Received: time.Unix(1710001670, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + _, err = store.GetUtxo(t.Context(), db.GetUtxoQuery{ + WalletID: walletID, + OutPoint: wire.OutPoint{Hash: badTx.TxHash(), Index: 0}, + }) + require.Error(t, err) + require.ErrorContains(t, err, "address subtype invariant violated") + + _, err = store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + WalletID: walletID, + }) + require.Error(t, err) + require.ErrorContains(t, err, "address subtype invariant violated") + + balance, err := store.Balance(t.Context(), db.BalanceParams{ + WalletID: walletID, + }) + require.NoError(t, err) + require.Equal(t, btcutil.Amount(41000), balance.Total) + require.Zero(t, balance.Locked) +} + +// TestListUTXOsFiltersByAccountName verifies that ListUTXOs filters by the +// paired (Scope, AccountName) combination. Account names are unique only +// within a scope, so the Scope is required alongside AccountName. +func TestListUTXOsFiltersByAccountName(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet( + t, store, "wallet-list-utxos-account-name", + ) + 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: 22000, PkScript: defaultAddr.ScriptPubKey}, + }, + ) + txSavings := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{ + {Value: 23000, PkScript: savingsAddr.ScriptPubKey}, }, ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txDefault, + Received: time.Unix(1710001700, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txSavings, + Received: time.Unix(1710001710, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + scope := db.KeyScopeBIP0084 + name := "savings" + utxos, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + WalletID: walletID, + Scope: &scope, + AccountName: &name, + }) + + require.NoError(t, err) + require.Len(t, utxos, 1) + require.Equal(t, txSavings.TxHash(), utxos[0].OutPoint.Hash) +} + +// TestListUTXOsRejectsAccountWithoutScope verifies that the +// ListUtxosQuery validator rejects a numeric account filter that arrives +// without the matching key scope. Account numbers are allocated per scope, +// so a number-only filter would silently mix outputs across scopes. +func TestListUTXOsRejectsAccountWithoutScope(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet( + t, store, "wallet-list-utxos-account-without-scope", + ) + + account := uint32(0) + _, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + WalletID: walletID, + Account: &account, + }) + + require.ErrorIs( + t, err, db.ErrListUtxosQueryAccountWithoutScope, + ) +} + +// TestListUTXOsRejectsNameWithoutScope verifies that AccountName-only +// filters are rejected for the same scope-uniqueness reason that +// BalanceParams enforces. +func TestListUTXOsRejectsNameWithoutScope(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet( + t, store, "wallet-list-utxos-name-without-scope", + ) + + name := defaultAccountName + _, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + WalletID: walletID, + AccountName: &name, + }) + + require.ErrorIs( + t, err, db.ErrListUtxosQueryNameWithoutScope, + ) +} + +// TestListUTXOsRejectsAccountAndName verifies that callers cannot pass +// both Account and AccountName: those fields are mutually exclusive +// disambiguation handles. +func TestListUTXOsRejectsAccountAndName(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet( + t, store, "wallet-list-utxos-account-and-name", + ) + + scope := db.KeyScopeBIP0084 + account := uint32(0) + name := defaultAccountName + _, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + WalletID: walletID, + Scope: &scope, + Account: &account, + AccountName: &name, + }) + + require.ErrorIs( + t, err, db.ErrListUtxosQueryAccountAndName, + ) +} + +// 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]address.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]address.Address{0: nil}, + }) require.NoError(t, err) - require.Empty(t, filteredImported) - - derived, ok := byOutPoint[derivedOutPoint] - require.True(t, ok, "derived UTXO missing from ListUTXOs") - require.Equal(t, derivedName, derived.AccountName) - require.Equal(t, db.WitnessPubKey, derived.AddrType) - require.False(t, derived.HasScript) - require.False(t, derived.IsLocked) - require.Equal(t, scope, derived.KeyScope) - // GetUtxo surfaces the same enriched view as ListUTXOs. - got, err := store.GetUtxo(t.Context(), db.GetUtxoQuery{ + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ WalletID: walletID, - OutPoint: importedOutPoint, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + ID: db.LockID{1}, + Duration: 30 * time.Minute, }) require.NoError(t, err) - require.Equal(t, db.WitnessScript, got.AddrType) - require.True(t, got.HasScript) - require.True(t, got.IsLocked) - require.Equal(t, db.KeyScope{}, got.KeyScope) + + _, 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) } -// TestListUTXOsFiltersByAccount verifies that ListUTXOs applies the optional -// account filter without affecting the underlying wallet ownership checks. -func TestListUTXOsFiltersByAccount(t *testing.T) { +// 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-list-utxos-account") + 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) +} + +// 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") - createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "savings") - defaultAddr := newDerivedAddress( + addr := 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( + tx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 17000, PkScript: savingsAddr.ScriptPubKey}}, + []*wire.TxOut{{Value: 20000, PkScript: addr.ScriptPubKey}}, ) err := store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: txDefault, - Received: time.Unix(1710001600, 0), + Tx: tx, + Received: time.Unix(1710001900, 0), Status: db.TxStatusPending, Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) - err = store.CreateTx(t.Context(), db.CreateTxParams{ + leaseID := RandomHash() + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ WalletID: walletID, - Tx: txSavings, - Received: time.Unix(1710001610, 0), - Status: db.TxStatusPending, - Credits: map[uint32]address.Address{0: nil}, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, }) require.NoError(t, err) - scope := db.KeyScopeBIP0084 - account := uint32(1) - utxos, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + err = store.ReleaseOutput(t.Context(), db.ReleaseOutputParams{ WalletID: walletID, - Scope: &scope, - Account: &account, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, }) require.NoError(t, err) - require.Len(t, utxos, 1) - require.Equal(t, txSavings.TxHash(), utxos[0].OutPoint.Hash) + + 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) } -// TestAccountNumberFiltersExcludeImportedAccounts verifies that numeric account -// filters do not match imported-xpub accounts even if corrupt metadata gives -// the imported account an account number. -func TestAccountNumberFiltersExcludeImportedAccounts(t *testing.T) { +// 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) - queries := store.Queries() - dbConn := store.DB() - walletID := newWatchOnlyWallet( - t, store, "wallet-utxo-imported-account-number", - ) + walletID := newWallet(t, store, "wallet-release-conflict") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") - accountName := hardwareAccountName - CreateImportedAccount( - t, store, walletID, db.KeyScopeBIP0084, accountName, true, + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, ) - scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) - accountID := GetAccountID(t, queries, scopeID, accountName) - scriptPubKey := RandomBytes(22) - err := createDerivedAddressRaw( - t, queries, walletID, accountID, 0, 0, scriptPubKey, + 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]address.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) +} + +// 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: 19000, PkScript: scriptPubKey}}, + []*wire.TxOut{{Value: 22000, PkScript: addr.ScriptPubKey}}, ) - err = store.CreateTx(t.Context(), db.CreateTxParams{ + + err := store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, Tx: tx, - Received: time.Unix(1710001650, 0), + Received: time.Unix(1710002100, 0), Status: db.TxStatusPending, Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) - accountNumber := uint32(7) - err = updateAccountNumberRaw(t, dbConn, accountID, accountNumber) - require.Error(t, err) - requireDriverConstraintError(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) - unfiltered, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + 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]address.Address{0: nil}, }) require.NoError(t, err) - require.Len(t, unfiltered, 1) - require.Equal(t, tx.TxHash(), unfiltered[0].OutPoint.Hash) - scope := db.KeyScopeBIP0084 - filtered, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + leaseID := RandomHash() + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ WalletID: walletID, - Scope: &scope, - Account: &accountNumber, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, }) require.NoError(t, err) - require.Empty(t, filtered) - balance, err := store.Balance(t.Context(), db.BalanceParams{ + err = store.ReleaseOutput(t.Context(), db.ReleaseOutputParams{ WalletID: walletID, - Scope: &scope, - Account: &accountNumber, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, }) require.NoError(t, err) - require.Zero(t, balance.Total) + + leases, err := store.ListLeasedOutputs(t.Context(), walletID) + + require.NoError(t, err) + require.Empty(t, leases) } -// TestUTXOReadsRejectMalformedDerivedAddressShape verifies that per-row UTXO -// reads reject malformed derived address shape instead of silently reporting -// the output under the raw imported alias. Balance aggregation should exclude -// the malformed row and still count well-formed UTXOs. -func TestUTXOReadsRejectMalformedDerivedAddressShape(t *testing.T) { +// 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) - queries := store.Queries() - walletID := newWallet(t, store, "wallet-utxo-malformed-address") - accountName := defaultAccountName - createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) + walletID := newWallet(t, store, "wallet-balance") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") - goodAddr := newDerivedAddress( - t, store, walletID, db.KeyScopeBIP0084, accountName, false, - ) - scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) - accountID := GetAccountID(t, queries, scopeID, accountName) - badScript := RandomBytes(22) - _, err := createDerivedAddressParentRaw( - t, queries, walletID, accountID, badScript, + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, ) - require.NoError(t, err) - goodTx := newRegularTx( + txOne := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 41000, PkScript: goodAddr.ScriptPubKey}}, + []*wire.TxOut{{Value: 24000, PkScript: addr.ScriptPubKey}}, ) - badTx := newRegularTx( + txTwo := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 42000, PkScript: badScript}}, + []*wire.TxOut{{Value: 26000, PkScript: addr.ScriptPubKey}}, ) - err = store.CreateTx(t.Context(), db.CreateTxParams{ + err := store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: goodTx, - Received: time.Unix(1710001660, 0), + Tx: txOne, + Received: time.Unix(1710002300, 0), Status: db.TxStatusPending, Credits: map[uint32]address.Address{0: nil}, }) @@ -2211,693 +3176,849 @@ func TestUTXOReadsRejectMalformedDerivedAddressShape(t *testing.T) { err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: badTx, - Received: time.Unix(1710001670, 0), + Tx: txTwo, + Received: time.Unix(1710002310, 0), Status: db.TxStatusPending, Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) - _, err = store.GetUtxo(t.Context(), db.GetUtxoQuery{ - WalletID: walletID, - OutPoint: wire.OutPoint{Hash: badTx.TxHash(), Index: 0}, - }) - require.Error(t, err) - require.ErrorContains(t, err, "address subtype invariant violated") - - _, err = store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + 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.Error(t, err) - require.ErrorContains(t, err, "address subtype invariant violated") + require.NoError(t, err) balance, err := store.Balance(t.Context(), db.BalanceParams{ WalletID: walletID, }) + require.NoError(t, err) - require.Equal(t, btcutil.Amount(41000), balance.Total) - require.Zero(t, balance.Locked) + require.Equal(t, btcutil.Amount(50000), balance.Total) + require.Equal(t, btcutil.Amount(24000), balance.Locked) } -// TestListUTXOsFiltersByAccountName verifies that ListUTXOs filters by the -// paired (Scope, AccountName) combination. Account names are unique only -// within a scope, so the Scope is required alongside AccountName. -func TestListUTXOsFiltersByAccountName(t *testing.T) { +// TestBalanceNameFilterDisambiguatesImportedXpub verifies that the account-name +// balance filter isolates imported-xpub child rows even though the imported +// account does not expose a wallet-derived account number. +func TestBalanceNameFilterDisambiguatesImportedXpub(t *testing.T) { t.Parallel() store := NewTestStore(t) - walletID := newWallet( - t, store, "wallet-list-utxos-account-name", + queries := store.Queries() + walletID := newWatchOnlyWallet( + t, store, "wallet-balance-name-filter", ) - createDerivedAccount( - t, store, walletID, db.KeyScopeBIP0084, "default", + + const ( + derivedName = defaultAccountName + importedName = hardwareAccountName ) - createDerivedAccount( - t, store, walletID, db.KeyScopeBIP0084, "savings", + + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, derivedName) + CreateImportedAccount( + t, store, walletID, db.KeyScopeBIP0084, importedName, true, ) - defaultAddr := newDerivedAddress( - t, store, walletID, db.KeyScopeBIP0084, "default", false, + derivedAddr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, derivedName, false, ) - savingsAddr := newDerivedAddress( - t, store, walletID, db.KeyScopeBIP0084, "savings", false, + importedScript := RandomBytes(22) + scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) + importedAccountID := GetAccountID(t, queries, scopeID, importedName) + err := createDerivedAddressRaw( + t, queries, walletID, importedAccountID, 0, 0, + importedScript, ) + require.NoError(t, err) - txDefault := newRegularTx( + block := CreateBlockFixture(t, queries, 280) + derivedTx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{ - {Value: 22000, PkScript: defaultAddr.ScriptPubKey}, - }, + []*wire.TxOut{{Value: 11000, PkScript: derivedAddr.ScriptPubKey}}, ) - txSavings := newRegularTx( + importedTx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{ - {Value: 23000, PkScript: savingsAddr.ScriptPubKey}, - }, + []*wire.TxOut{{Value: 22000, PkScript: importedScript}}, ) - err := store.CreateTx(t.Context(), db.CreateTxParams{ + err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: txDefault, - Received: time.Unix(1710001700, 0), - Status: db.TxStatusPending, + Tx: derivedTx, + Received: time.Unix(1710002320, 0), + Block: &block, + Status: db.TxStatusPublished, Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, - Tx: txSavings, - Received: time.Unix(1710001710, 0), - Status: db.TxStatusPending, + Tx: importedTx, + Received: time.Unix(1710002330, 0), + Block: &block, + Status: db.TxStatusPublished, Credits: map[uint32]address.Address{0: nil}, }) require.NoError(t, err) scope := db.KeyScopeBIP0084 - name := "savings" - utxos, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ - WalletID: walletID, - Scope: &scope, - AccountName: &name, + importedBalanceName := importedName + balance, err := store.Balance(t.Context(), db.BalanceParams{ + WalletID: walletID, + Scope: &scope, + Name: &importedBalanceName, }) + require.NoError(t, err) + require.Equal(t, btcutil.Amount(22000), balance.Total) + derivedBalanceName := derivedName + balance, err = store.Balance(t.Context(), db.BalanceParams{ + WalletID: walletID, + Scope: &scope, + Name: &derivedBalanceName, + }) require.NoError(t, err) - require.Len(t, utxos, 1) - require.Equal(t, txSavings.TxHash(), utxos[0].OutPoint.Hash) + require.Equal(t, btcutil.Amount(11000), balance.Total) } -// TestListUTXOsRejectsAccountWithoutScope verifies that the -// ListUtxosQuery validator rejects a numeric account filter that arrives -// without the matching key scope. Account numbers are allocated per scope, -// so a number-only filter would silently mix outputs across scopes. -func TestListUTXOsRejectsAccountWithoutScope(t *testing.T) { - t.Parallel() +// 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}) - store := NewTestStore(t) - walletID := newWallet( - t, store, "wallet-list-utxos-account-without-scope", - ) + return tx +} - account := uint32(0) - _, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ - WalletID: walletID, - Account: &account, - }) +// 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) - require.ErrorIs( - t, err, db.ErrListUtxosQueryAccountWithoutScope, - ) + for _, prevOut := range inputs { + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: prevOut}) + } + + for _, txOut := range outputs { + tx.AddTxOut(txOut) + } + + return tx } -// TestListUTXOsRejectsNameWithoutScope verifies that AccountName-only -// filters are rejected for the same scope-uniqueness reason that -// BalanceParams enforces. -func TestListUTXOsRejectsNameWithoutScope(t *testing.T) { - t.Parallel() +// randomOutPoint returns one fixture outpoint backed by a random hash. +func randomOutPoint() wire.OutPoint { + return wire.OutPoint{Hash: RandomHash(), Index: 0} +} - store := NewTestStore(t) - walletID := newWallet( - t, store, "wallet-list-utxos-name-without-scope", - ) +// newMultisigScript builds a 1-of-2 bare-multisig output script and returns the +// first member's pubkey address, that member's own P2PK script, and the full +// multisig output script. The member script is what PayToAddrScript(memberAddr) +// yields and is what a wallet would register as an address; the multisig script +// is the on-chain output script, which is never a wallet address by itself. +func newMultisigScript(t *testing.T) (*address.AddressPubKey, []byte, []byte) { + t.Helper() - name := defaultAccountName - _, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ - WalletID: walletID, - AccountName: &name, - }) + firstKey, err := btcec.NewPrivateKey() + require.NoError(t, err) - require.ErrorIs( - t, err, db.ErrListUtxosQueryNameWithoutScope, + secondKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + memberAddr, err := address.NewAddressPubKey( + firstKey.PubKey().SerializeCompressed(), + &chaincfg.RegressionNetParams, ) -} + require.NoError(t, err) -// TestListUTXOsRejectsAccountAndName verifies that callers cannot pass -// both Account and AccountName: those fields are mutually exclusive -// disambiguation handles. -func TestListUTXOsRejectsAccountAndName(t *testing.T) { - t.Parallel() + memberScript, err := txscript.PayToAddrScript(memberAddr) + require.NoError(t, err) - store := NewTestStore(t) - walletID := newWallet( - t, store, "wallet-list-utxos-account-and-name", - ) + builder := txscript.NewScriptBuilder() + builder.AddInt64(1) + builder.AddData(firstKey.PubKey().SerializeCompressed()) + builder.AddData(secondKey.PubKey().SerializeCompressed()) + builder.AddInt64(2) + builder.AddOp(txscript.OP_CHECKMULTISIG) - scope := db.KeyScopeBIP0084 - account := uint32(0) - name := defaultAccountName - _, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ - WalletID: walletID, - Scope: &scope, - Account: &account, - AccountName: &name, - }) + multiSigScript, err := builder.Script() + require.NoError(t, err) + + return memberAddr, memberScript, multiSigScript +} + +// txHashes returns transaction hashes in result order. +func txHashes(infos []db.TxInfo) []chainhash.Hash { + hashes := make([]chainhash.Hash, 0, len(infos)) + for _, info := range infos { + hashes = append(hashes, info.Hash) + } - require.ErrorIs( - t, err, db.ErrListUtxosQueryAccountAndName, - ) + return hashes } -// TestLeaseOutputLocksCurrentUtxo verifies that LeaseOutput returns the active -// lease metadata for a current wallet-owned output. -func TestLeaseOutputLocksCurrentUtxo(t *testing.T) { +// txDetailHashes returns transaction-detail hashes in result order. +func txDetailHashes(infos []db.TxDetailInfo) []chainhash.Hash { + hashes := make([]chainhash.Hash, 0, len(infos)) + for _, info := range infos { + hashes = append(hashes, info.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) - walletID := newWallet(t, store, "wallet-lease-output") - createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") - addr := newDerivedAddress( - t, store, walletID, db.KeyScopeBIP0084, "default", false, + // 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") + + // 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, + 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: 18000, PkScript: addr.ScriptPubKey}}, + []*wire.TxOut{{Value: 7000, PkScript: multiSigScript}}, ) - - err := store.CreateTx(t.Context(), db.CreateTxParams{ + err = store.CreateTx(t.Context(), db.CreateTxParams{ WalletID: walletID, Tx: tx, - Received: time.Unix(1710001700, 0), - Status: db.TxStatusPending, - Credits: map[uint32]address.Address{0: nil}, + Received: time.Unix(1710004500, 0), + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: memberAddr}, }) 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, - }) - + utxos, err := store.ListOutputsToWatch(t.Context(), walletID) 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())) + 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) } -// TestLeaseOutputRejectsAlreadyLeasedUtxo verifies that LeaseOutput reports -// ErrOutputAlreadyLeased when another active lock already owns the same output. -func TestLeaseOutputRejectsAlreadyLeasedUtxo(t *testing.T) { +// TestApplyTxBatchChildBeforeParent verifies that ApplyTxBatch records the +// parent->child spend edge even when the child transaction is listed before +// the in-batch parent whose output it spends. Each transaction claims its spent +// parent inputs by updating the parent credit's UTXO row, so a child applied +// before its parent would update no row and, finding no conflicting spend, +// silently drop the spend edge while still succeeding. ApplyTxBatch must apply +// the batch parents-first so the parent credit ends up marked spent regardless +// of caller order. +func TestApplyTxBatchChildBeforeParent(t *testing.T) { t.Parallel() store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-lease-output-conflict") + walletID := newWallet(t, store, "wallet-apply-tx-batch-child-first") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") - addr := newDerivedAddress( + parentAddr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + childAddr := newDerivedAddress( t, store, walletID, db.KeyScopeBIP0084, "default", false, ) - tx := newRegularTx( + // The parent spends an external input and credits the wallet at output 0. + parentTx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 19000, PkScript: addr.ScriptPubKey}}, + []*wire.TxOut{{Value: 7000, PkScript: parentAddr.ScriptPubKey}}, ) - err := store.CreateTx(t.Context(), db.CreateTxParams{ - WalletID: walletID, - Tx: tx, - Received: time.Unix(1710001710, 0), - Status: db.TxStatusPending, - Credits: map[uint32]address.Address{0: nil}, + // The child spends the parent's wallet-owned output and credits the wallet + // at its own output 0. + childTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 6000, PkScript: childAddr.ScriptPubKey}}, + ) + + // Deliberately list the child before its in-batch parent. A caller-order + // apply would record the child first, drop its spend of the not-yet-stored + // parent output, and leave the parent credit unspent. + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: walletID, + Transactions: []db.CreateTxParams{ + { + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710000180, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }, + { + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710000181, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }, + }, }) require.NoError(t, err) - _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + // Both transactions are recorded. + _, err = store.GetTx(t.Context(), db.GetTxQuery{ WalletID: walletID, - OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, - ID: db.LockID{1}, - Duration: 30 * time.Minute, + Txid: parentTx.TxHash(), }) require.NoError(t, err) - - _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + _, err = store.GetTx(t.Context(), db.GetTxQuery{ WalletID: walletID, - OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, - ID: db.LockID{2}, - Duration: 30 * time.Minute, + Txid: childTx.TxHash(), }) - 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") + require.NoError(t, err) - _, err := store.LeaseOutput(t.Context(), db.LeaseOutputParams{ - WalletID: walletID, - OutPoint: randomOutPoint(), - ID: db.LockID{3}, - Duration: 0, - }) + // The child's own credit is recorded as a wallet UTXO. + require.True(t, walletUtxoExists(t, store, walletID, wire.OutPoint{ + Hash: childTx.TxHash(), Index: 0, + })) - require.ErrorIs(t, err, db.ErrInvalidParam) + // The parent credit must be marked spent: the in-batch child's spend edge + // was recorded even though the child was applied first. Without the + // parents-first ordering this edge is silently dropped and the assertion + // fails. + require.True(t, walletUtxoSpent(t, store, walletID, wire.OutPoint{ + Hash: parentTx.TxHash(), Index: 0, + })) } -// TestReleaseOutputUnlocksMatchingLease verifies that ReleaseOutput removes the -// active lease when the caller presents the matching lock ID. -func TestReleaseOutputUnlocksMatchingLease(t *testing.T) { +// 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) - walletID := newWallet(t, store, "wallet-release-output") + 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: 20000, PkScript: addr.ScriptPubKey}}, + []*wire.TxOut{{Value: 7000, PkScript: addr.ScriptPubKey}}, ) + syncedTo := NewBlockFixture(212) - err := store.CreateTx(t.Context(), db.CreateTxParams{ - WalletID: walletID, - Tx: tx, - Received: time.Unix(1710001900, 0), - Status: db.TxStatusPending, - Credits: map[uint32]address.Address{0: nil}, - }) - require.NoError(t, err) - - leaseID := RandomHash() - _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ WalletID: walletID, - ID: leaseID, - OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, - Duration: time.Minute, + Transactions: []db.CreateTxParams{{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000150, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }}, + SyncedTo: &syncedTo, }) require.NoError(t, err) - err = store.ReleaseOutput(t.Context(), db.ReleaseOutputParams{ + txInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ WalletID: walletID, - ID: leaseID, - OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + 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, + })) - otherID := RandomHash() - _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ - WalletID: walletID, - ID: otherID, - OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, - Duration: time.Minute, - }) + 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()) } -// TestReleaseOutputRejectsWrongLockID verifies that ReleaseOutput reports the -// public unlock error when another active lock still owns the output. -func TestReleaseOutputRejectsWrongLockID(t *testing.T) { +// 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) - walletID := newWallet(t, store, "wallet-release-conflict") + 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: 21000, PkScript: addr.ScriptPubKey}}, + []*wire.TxOut{{Value: 7000, 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]address.Address{0: nil}, - }) - require.NoError(t, err) + // 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) - leaseID := RandomHash() - _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ WalletID: walletID, - ID: leaseID, - OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, - Duration: time.Minute, + Transactions: []db.CreateTxParams{{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000160, 0), + Block: &block, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }}, + SyncedTo: &block, }) require.NoError(t, err) - wrongID := RandomHash() - err = store.ReleaseOutput(t.Context(), db.ReleaseOutputParams{ + // 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, - ID: wrongID, - OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + 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, + })) - require.ErrorIs(t, err, db.ErrOutputUnlockNotAllowed) + // 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) } -// TestListLeasedOutputsReturnsActiveLeases verifies that ListLeasedOutputs -// returns the currently active wallet lease set. -func TestListLeasedOutputsReturnsActiveLeases(t *testing.T) { +// TestApplyTxBatchConfirmsTxBeforeSyncTip verifies that a batch can record a +// transaction confirmed before the same batch's final synced block. The +// transaction's confirming block row does not exist before the batch, so the +// batch must create it independently of the synced-to block. +func TestApplyTxBatchConfirmsTxBeforeSyncTip(t *testing.T) { t.Parallel() store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-list-leases") + walletName := "wallet-apply-tx-batch-confirm-before-tip" + 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: 22000, PkScript: addr.ScriptPubKey}}, + []*wire.TxOut{{Value: 7000, PkScript: addr.ScriptPubKey}}, ) - err := store.CreateTx(t.Context(), db.CreateTxParams{ + block := NewBlockFixture(212) + syncedTo := NewBlockFixture(213) + + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ WalletID: walletID, - Tx: tx, - Received: time.Unix(1710002100, 0), - Status: db.TxStatusPending, - Credits: map[uint32]address.Address{0: nil}, + Transactions: []db.CreateTxParams{{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000160, 0), + Block: &block, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }}, + SyncedTo: &syncedTo, }) require.NoError(t, err) - leaseID := RandomHash() - _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + txInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ WalletID: walletID, - ID: leaseID, - OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, - Duration: time.Minute, + 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) - leases, err := store.ListLeasedOutputs(t.Context(), walletID) - + walletInfo, err := store.GetWallet(t.Context(), walletName) 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) + require.NotNil(t, walletInfo.SyncedTo) + require.Equal(t, syncedTo.Height, walletInfo.SyncedTo.Height) + require.Equal(t, syncedTo.Hash, walletInfo.SyncedTo.Hash) } -// TestListLeasedOutputsExcludesReleasedLease verifies that ListLeasedOutputs -// reflects a successful release immediately. -func TestListLeasedOutputsExcludesReleasedLease(t *testing.T) { +// TestApplyTxBatchConfirmsExistingTx verifies that ApplyTxBatch reconciles a +// pending transaction when the same transaction is later observed confirmed. +func TestApplyTxBatchConfirmsExistingTx(t *testing.T) { t.Parallel() store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-list-leases-after-release") + walletName := "wallet-apply-tx-batch-confirm-existing" + 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: 23000, PkScript: addr.ScriptPubKey}}, + []*wire.TxOut{{Value: 7000, 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]address.Address{0: nil}, - }) - require.NoError(t, err) - - leaseID := RandomHash() - _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ WalletID: walletID, - ID: leaseID, - OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, - Duration: time.Minute, + Transactions: []db.CreateTxParams{{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000161, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }}, }) require.NoError(t, err) - err = store.ReleaseOutput(t.Context(), db.ReleaseOutputParams{ + block := NewBlockFixture(216) + err = store.ApplyTxBatch(t.Context(), db.TxBatchParams{ WalletID: walletID, - ID: leaseID, - OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Transactions: []db.CreateTxParams{{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000162, 0), + Block: &block, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }}, + SyncedTo: &block, }) require.NoError(t, err) - leases, err := store.ListLeasedOutputs(t.Context(), walletID) + 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) + walletInfo, err := store.GetWallet(t.Context(), walletName) require.NoError(t, err) - require.Empty(t, leases) + require.NotNil(t, walletInfo.SyncedTo) + require.Equal(t, block.Height, walletInfo.SyncedTo.Height) + require.Equal(t, block.Hash, walletInfo.SyncedTo.Hash) } -// TestBalanceReturnsTotalAndLocked verifies that Balance returns the filtered -// total UTXO value together with the locked subset covered by active leases. -func TestBalanceReturnsTotalAndLocked(t *testing.T) { +// TestApplyTxBatchDuplicateUnminedKeepsConfirmed verifies that an unmined batch +// notification for an already-confirmed transaction is a no-op, matching the +// legacy kvdb behavior for rescan observations. +func TestApplyTxBatchDuplicateUnminedKeepsConfirmed(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, - ) + walletID := newWallet(t, store, "wallet-apply-tx-batch-unmined-dupe") - txOne := newRegularTx( - []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 24000, PkScript: addr.ScriptPubKey}}, - ) - txTwo := newRegularTx( + tx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 26000, PkScript: addr.ScriptPubKey}}, + []*wire.TxOut{{Value: 7000, PkScript: RandomBytes(22)}}, ) + block := NewBlockFixture(217) - err := store.CreateTx(t.Context(), db.CreateTxParams{ - WalletID: walletID, - Tx: txOne, - Received: time.Unix(1710002300, 0), - Status: db.TxStatusPending, - Credits: map[uint32]address.Address{0: nil}, - }) - require.NoError(t, err) - - err = store.CreateTx(t.Context(), db.CreateTxParams{ + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ WalletID: walletID, - Tx: txTwo, - Received: time.Unix(1710002310, 0), - Status: db.TxStatusPending, - Credits: map[uint32]address.Address{0: nil}, + Transactions: []db.CreateTxParams{{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000163, 0), + Block: &block, + Status: db.TxStatusPublished, + }}, + SyncedTo: &block, }) require.NoError(t, err) - leaseID := RandomHash() - _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + err = store.ApplyTxBatch(t.Context(), db.TxBatchParams{ WalletID: walletID, - ID: leaseID, - OutPoint: wire.OutPoint{Hash: txOne.TxHash(), Index: 0}, - Duration: time.Minute, + Transactions: []db.CreateTxParams{{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000164, 0), + Status: db.TxStatusPending, + }}, }) require.NoError(t, err) - balance, err := store.Balance(t.Context(), db.BalanceParams{ + txInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ WalletID: walletID, + Txid: tx.TxHash(), }) - require.NoError(t, err) - require.Equal(t, btcutil.Amount(50000), balance.Total) - require.Equal(t, btcutil.Amount(24000), balance.Locked) + 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) } -// TestBalanceNameFilterDisambiguatesImportedXpub verifies that the account-name -// balance filter isolates imported-xpub child rows even though the imported -// account does not expose a wallet-derived account number. -func TestBalanceNameFilterDisambiguatesImportedXpub(t *testing.T) { +// TestApplyTxBatchDuplicateConfirmedChecksTimestamp verifies that an otherwise +// idempotent confirmed duplicate still validates the caller's block timestamp. +func TestApplyTxBatchDuplicateConfirmedChecksTimestamp(t *testing.T) { t.Parallel() store := NewTestStore(t) - queries := store.Queries() - walletID := newWatchOnlyWallet( - t, store, "wallet-balance-name-filter", - ) - - const ( - derivedName = defaultAccountName - importedName = hardwareAccountName - ) + walletID := newWallet(t, store, "wallet-apply-tx-batch-ts-dupe") - createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, derivedName) - CreateImportedAccount( - t, store, walletID, db.KeyScopeBIP0084, importedName, true, + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 7000, PkScript: RandomBytes(22)}}, ) + block := NewBlockFixture(218) - derivedAddr := newDerivedAddress( - t, store, walletID, db.KeyScopeBIP0084, derivedName, false, - ) - importedScript := RandomBytes(22) - scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) - importedAccountID := GetAccountID(t, queries, scopeID, importedName) - err := createDerivedAddressRaw( - t, queries, walletID, importedAccountID, 0, 0, - importedScript, - ) + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: walletID, + Transactions: []db.CreateTxParams{{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000165, 0), + Block: &block, + Status: db.TxStatusPublished, + }}, + SyncedTo: &block, + }) require.NoError(t, err) - block := CreateBlockFixture(t, queries, 280) - derivedTx := newRegularTx( - []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 11000, PkScript: derivedAddr.ScriptPubKey}}, + mismatchedBlock := block + mismatchedBlock.Timestamp = block.Timestamp.Add(time.Second) + err = store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: walletID, + Transactions: []db.CreateTxParams{{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000166, 0), + Block: &mismatchedBlock, + Status: db.TxStatusPublished, + }}, + }) + require.ErrorIs(t, err, db.ErrBlockMismatch) +} + +// TestApplyTxBatchRejectsDuplicateStateMismatch verifies that a non-idempotent +// duplicate does not let the batch advance the sync tip. +func TestApplyTxBatchRejectsDuplicateStateMismatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletName := "wallet-apply-tx-batch-duplicate-mismatch" + walletID := newWallet(t, store, walletName) + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, ) - importedTx := newRegularTx( + tx := newRegularTx( []wire.OutPoint{randomOutPoint()}, - []*wire.TxOut{{Value: 22000, PkScript: importedScript}}, + []*wire.TxOut{{Value: 7000, PkScript: addr.ScriptPubKey}}, ) - err = store.CreateTx(t.Context(), db.CreateTxParams{ + firstBlock := NewBlockFixture(217) + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ WalletID: walletID, - Tx: derivedTx, - Received: time.Unix(1710002320, 0), - Block: &block, - Status: db.TxStatusPublished, - Credits: map[uint32]address.Address{0: nil}, + Transactions: []db.CreateTxParams{{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000163, 0), + Block: &firstBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }}, + SyncedTo: &firstBlock, }) require.NoError(t, err) - err = store.CreateTx(t.Context(), db.CreateTxParams{ + secondBlock := NewBlockFixture(218) + err = store.ApplyTxBatch(t.Context(), db.TxBatchParams{ WalletID: walletID, - Tx: importedTx, - Received: time.Unix(1710002330, 0), - Block: &block, - Status: db.TxStatusPublished, - Credits: map[uint32]address.Address{0: nil}, + Transactions: []db.CreateTxParams{{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000164, 0), + Block: &secondBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }}, + SyncedTo: &secondBlock, }) - require.NoError(t, err) + require.ErrorIs(t, err, db.ErrTxAlreadyExists) - scope := db.KeyScopeBIP0084 - importedBalanceName := importedName - balance, err := store.Balance(t.Context(), db.BalanceParams{ + txInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ WalletID: walletID, - Scope: &scope, - Name: &importedBalanceName, + Txid: tx.TxHash(), }) require.NoError(t, err) - require.Equal(t, btcutil.Amount(22000), balance.Total) + require.Equal(t, db.TxStatusPublished, txInfo.Status) + require.NotNil(t, txInfo.Block) + require.Equal(t, firstBlock.Height, txInfo.Block.Height) + require.Equal(t, firstBlock.Hash, txInfo.Block.Hash) - derivedBalanceName := derivedName - balance, err = store.Balance(t.Context(), db.BalanceParams{ - WalletID: walletID, - Scope: &scope, - Name: &derivedBalanceName, - }) + walletInfo, err := store.GetWallet(t.Context(), walletName) require.NoError(t, err) - require.Equal(t, btcutil.Amount(11000), balance.Total) + require.NotNil(t, walletInfo.SyncedTo) + require.Equal(t, firstBlock.Height, walletInfo.SyncedTo.Height) + require.Equal(t, firstBlock.Hash, walletInfo.SyncedTo.Hash) } -// 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 -} +// 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() -// 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) + store := NewTestStore(t) + walletName := "wallet-apply-tx-batch-mismatch" + walletID := newWallet(t, store, walletName) + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") - for _, prevOut := range inputs { - tx.AddTxIn(&wire.TxIn{PreviousOutPoint: prevOut}) - } + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 7000, PkScript: addr.ScriptPubKey}}, + ) + syncedTo := NewBlockFixture(214) - for _, txOut := range outputs { - tx.AddTxOut(txOut) - } + // 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]address.Address{0: nil}, + }}, + SyncedTo: &syncedTo, + }) + require.ErrorIs(t, err, db.ErrInvalidParam) - return tx -} + // 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) -// randomOutPoint returns one fixture outpoint backed by a random hash. -func randomOutPoint() wire.OutPoint { - return wire.OutPoint{Hash: RandomHash(), Index: 0} + // 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) } -// newMultisigScript builds a 1-of-2 bare-multisig output script and returns the -// first member's pubkey address, that member's own P2PK script, and the full -// multisig output script. The member script is what PayToAddrScript(memberAddr) -// yields and is what a wallet would register as an address; the multisig script -// is the on-chain output script, which is never a wallet address by itself. -func newMultisigScript(t *testing.T) (*address.AddressPubKey, []byte, []byte) { - t.Helper() - - firstKey, err := btcec.NewPrivateKey() - require.NoError(t, err) +// TestApplyTxBatchRejectsNilTx verifies that a multi-transaction batch with one +// nil Tx is rejected with ErrInvalidParam rather than panicking. ApplyTxBatch +// reorders the batch parents-first before applying it, and that sort +// dereferences each transaction's Tx, so a nil member must be caught up front; +// the rejection must also commit nothing. +func TestApplyTxBatchRejectsNilTx(t *testing.T) { + t.Parallel() - secondKey, err := btcec.NewPrivateKey() - require.NoError(t, err) + store := NewTestStore(t) + walletName := "wallet-apply-tx-batch-nil-tx" + walletID := newWallet(t, store, walletName) + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") - memberAddr, err := address.NewAddressPubKey( - firstKey.PubKey().SerializeCompressed(), - &chaincfg.RegressionNetParams, + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, ) - require.NoError(t, err) - - memberScript, err := txscript.PayToAddrScript(memberAddr) - require.NoError(t, err) - - builder := txscript.NewScriptBuilder() - builder.AddInt64(1) - builder.AddData(firstKey.PubKey().SerializeCompressed()) - builder.AddData(secondKey.PubKey().SerializeCompressed()) - builder.AddInt64(2) - builder.AddOp(txscript.OP_CHECKMULTISIG) + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 7000, PkScript: addr.ScriptPubKey}}, + ) + syncedTo := NewBlockFixture(215) + + // The batch carries a valid transaction and a second one with a nil Tx. + // The parents-first sort runs before per-tx request validation, so the nil + // member must be rejected by the up-front guard rather than panicking. + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: walletID, + Transactions: []db.CreateTxParams{ + { + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000210, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: nil}, + }, + { + WalletID: walletID, + Tx: nil, + Received: time.Unix(1710000211, 0), + Status: db.TxStatusPending, + }, + }, + SyncedTo: &syncedTo, + }) + require.ErrorIs(t, err, db.ErrInvalidParam) - multiSigScript, err := builder.Script() + // The sync tip was not advanced and the valid transaction was not written: + // the whole batch is rejected before any write. + walletInfo, err := store.GetWallet(t.Context(), walletName) require.NoError(t, err) + require.Nil(t, walletInfo.SyncedTo) - return memberAddr, memberScript, multiSigScript -} - -// txHashes returns transaction hashes in result order. -func txHashes(infos []db.TxInfo) []chainhash.Hash { - hashes := make([]chainhash.Hash, 0, len(infos)) - for _, info := range infos { - hashes = append(hashes, info.Hash) - } - - return hashes -} - -// txDetailHashes returns transaction-detail hashes in result order. -func txDetailHashes(infos []db.TxDetailInfo) []chainhash.Hash { - hashes := make([]chainhash.Hash, 0, len(infos)) - for _, info := range infos { - hashes = append(hashes, info.Hash) - } - - return hashes + _, err = store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }) + require.ErrorIs(t, err, db.ErrTxNotFound) } diff --git a/wallet/internal/db/kvdb/txstore.go b/wallet/internal/db/kvdb/txstore.go index a00e11cb72..2194819a7b 100644 --- a/wallet/internal/db/kvdb/txstore.go +++ b/wallet/internal/db/kvdb/txstore.go @@ -25,6 +25,18 @@ var _ db.TxStore = (*Store)(nil) // signed legacy wtxmgr height domain. var errLegacyHeightOverflow = errors.New("legacy height overflows int32") +// kvdbTxStatusBucketKey is a kvdb-owned side bucket under the legacy wtxmgr +// namespace that records transaction statuses wtxmgr cannot represent. +var kvdbTxStatusBucketKey = []byte("db-tx-status") + +// legacyTxStatusValueLen is the byte width of one encoded db.TxStatus value. +const legacyTxStatusValueLen = 1 + +// errLegacyTxStatusUnexpectedSize reports a corrupt status side-bucket value. +var errLegacyTxStatusUnexpectedSize = errors.New( + "legacy tx status bucket value has unexpected byte width", +) + // CreateTx records an unmined transaction through the legacy wtxmgr path. // // Unlike the SQL backends, kvdb does NOT route through the shared @@ -105,6 +117,11 @@ func (s *Store) createTxWithTx(tx walletdb.ReadWriteTx, return db.ErrTxAlreadyExists } + err = putLegacyTxStatus(txmgrNs, txRec.Hash, params.Status) + if err != nil { + return fmt.Errorf("put tx status: %w", err) + } + if len(params.Label) != 0 { err := s.txStore.PutTxLabel( txmgrNs, txRec.Hash, params.Label, @@ -289,12 +306,620 @@ func (s *Store) UpdateTx(_ context.Context, params db.UpdateTxParams) error { return nil } -// GetTx retrieves one wallet-scoped transaction snapshot through the legacy -// wtxmgr query path. -func (s *Store) GetTx(_ context.Context, query db.GetTxQuery) ( - *db.TxInfo, error) { +// ApplyTxBatch atomically records transactions and an optional sync-tip update +// through the legacy walletdb managers. +func (s *Store) ApplyTxBatch(_ context.Context, + params db.TxBatchParams) error { + + // A batch with no transactions and no sync-tip update has nothing to + // persist. Return before the addrStore guard and walletdb.Update so an + // empty batch neither requires an address manager nor opens a write + // transaction. + if len(params.Transactions) == 0 && params.SyncedTo == nil { + return nil + } + + err := db.ValidateBatchTransactionsWalletID( + params.WalletID, params.Transactions, + ) + if err != nil { + return fmt.Errorf("validate batch wallet ids: %w", err) + } + + // Reject a nil-Tx member before the parents-first sort below + // dereferences each transaction; the per-tx NewCreateTxRequest check in + // the apply loop runs only after the sort. + err = db.ValidateBatchTransactionsTx(params.Transactions) + if err != nil { + return fmt.Errorf("validate batch transactions: %w", err) + } + + // Record any in-batch parent before its children. The confirmed path + // records a debit only against an already-inserted parent credit, so a + // confirmed child applied before its in-batch parent would find no credit + // to spend and silently leave the parent output unspent. Sorting parents + // first makes the batch order-independent; an already parents-first or + // dependency-free batch is returned unchanged. + params.Transactions = db.SortTxBatchParentsFirst(params.Transactions) + + if batchNeedsAddrStore(params) && s.addrStore == nil { + return fmt.Errorf("kvdb.Store.ApplyTxBatch: %w", + errMissingAddrStore) + } + + var syncTipRestore batchSyncTipRestore + + // Hold the Store write lock through the commit-failure restore below. This + // keeps the cache restore ordered before a following Store write can commit + // the same tip and create an ABA match. + s.writeMu.Lock() + defer s.writeMu.Unlock() + + err = walletdb.Update(s.db, func(tx walletdb.ReadWriteTx) error { + return s.applyLegacyBatchUpdate(tx, params, &syncTipRestore) + }) + if err != nil { + if syncTipRestore.snapshotTaken { + s.addrStore.RestoreSyncedToIfCurrent( + syncTipRestore.previous, + syncTipRestore.attempted, + ) + } + + return fmt.Errorf("kvdb.Store.ApplyTxBatch: %w", err) + } + + return nil +} + +// batchNeedsAddrStore reports whether a transaction batch needs the address +// manager namespace or live address store. +func batchNeedsAddrStore(params db.TxBatchParams) bool { + if params.SyncedTo != nil { + return true + } + + for _, tx := range params.Transactions { + if len(tx.Credits) > 0 { + return true + } + } + + return false +} + +// batchSyncTipRestore tracks the live sync tip to restore when a batch update +// fails after the address manager has changed its in-memory synced-to block. +type batchSyncTipRestore struct { + previous waddrmgr.BlockStamp + attempted waddrmgr.BlockStamp + + snapshotTaken bool +} + +// applyLegacyBatchUpdate performs the namespace lookups and write operations of +// a transaction batch within a legacy walletdb write transaction, recording any +// transactions and applying the optional sync-tip update. +func (s *Store) applyLegacyBatchUpdate(tx walletdb.ReadWriteTx, + params db.TxBatchParams, syncTipRestore *batchSyncTipRestore) error { + + var addrmgrNs walletdb.ReadWriteBucket + if batchNeedsAddrStore(params) { + addrmgrNs = tx.ReadWriteBucket(waddrmgr.NamespaceKey) + if addrmgrNs == nil { + return errMissingAddrmgrNamespace + } + } + + if len(params.Transactions) > 0 { + txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) + if txmgrNs == nil { + return errMissingTxmgrNamespace + } + + err := s.applyLegacyTxBatch( + addrmgrNs, txmgrNs, params.Transactions, + ) + if err != nil { + return err + } + } + + return s.applyLegacyBatchSyncTip( + addrmgrNs, params, syncTipRestore, + ) +} + +// applyLegacyBatchSyncTip applies the optional sync-tip update for a legacy +// transaction batch. +func (s *Store) applyLegacyBatchSyncTip(ns walletdb.ReadWriteBucket, + params db.TxBatchParams, syncTipRestore *batchSyncTipRestore) error { + + if params.SyncedTo == nil { + return nil + } + + block, err := db.BlockStampFromBlock(params.SyncedTo) + if err != nil { + return err + } + + syncTipRestore.previous = s.addrStore.SyncedTo() + syncTipRestore.attempted = block + syncTipRestore.snapshotTaken = true + + err = s.addrStore.SetSyncedTo(ns, &block) + if err != nil { + return fmt.Errorf("set synced tip: %w", err) + } + + return nil +} + +// applyLegacyTxBatch records one batch of relevant transaction notifications. +func (s *Store) applyLegacyTxBatch(addrmgrNs walletdb.ReadWriteBucket, + txmgrNs walletdb.ReadWriteBucket, transactions []db.CreateTxParams) error { + + for i := range transactions { + req, err := db.NewCreateTxRequest(transactions[i]) + if err != nil { + return fmt.Errorf("validate tx %d: %w", i, err) + } + + err = s.applyLegacyTxNotification(addrmgrNs, txmgrNs, req) + if err != nil { + return fmt.Errorf("apply tx %d: %w", i, err) + } + } + + return nil +} + +// applyLegacyTxNotification records one relevant transaction notification using +// legacy wtxmgr semantics. +func (s *Store) applyLegacyTxNotification(addrmgrNs walletdb.ReadWriteBucket, + txmgrNs walletdb.ReadWriteBucket, req db.CreateTxRequest) error { + + txRec, err := wtxmgr.NewTxRecordFromMsgTx( + req.Params.Tx, req.Received, + ) + if err != nil { + return fmt.Errorf("build tx record: %w", err) + } + + if req.Params.Block == nil { + return s.applyUnconfirmedLegacyTx(addrmgrNs, txmgrNs, txRec, req) + } + + block, err := kvdbTxBlockMeta(req.Params.Block) + if err != nil { + return err + } + + handled, err := s.applyConfirmedDuplicate( + addrmgrNs, txmgrNs, txRec.Hash, block, req, + ) + if err != nil { + return err + } + + if handled { + return nil + } + + label, err := s.confirmedBatchLabel(txmgrNs, txRec.Hash, req) + if err != nil { + return err + } + + credits, err := s.legacyCreditEntries(addrmgrNs, req) + if err != nil { + return err + } + + err = s.txStore.InsertConfirmedTx(txmgrNs, txRec, block, credits) + if err != nil { + return fmt.Errorf("insert confirmed tx: %w", err) + } + + err = putLegacyTxStatus(txmgrNs, txRec.Hash, req.Params.Status) + if err != nil { + return fmt.Errorf("put tx status: %w", err) + } + + return s.putLegacyTxLabel(txmgrNs, txRec, label) +} + +// confirmedBatchLabel returns the label to keep after storing a confirmed batch +// notification. +func (s *Store) confirmedBatchLabel(txmgrNs walletdb.ReadBucket, + txHash chainhash.Hash, req db.CreateTxRequest) (string, error) { + + existing, err := s.txStore.TxDetails(txmgrNs, &txHash) + if err != nil { + return "", fmt.Errorf("lookup existing tx label: %w", err) + } + + if existing == nil || existing.Block.Height >= 0 { + return req.Params.Label, nil + } + + return existing.Label, nil +} + +// applyConfirmedDuplicate handles a confirmed batch notification for a +// transaction hash already present in the legacy store. +func (s *Store) applyConfirmedDuplicate( + addrmgrNs walletdb.ReadBucket, txmgrNs walletdb.ReadBucket, + txHash chainhash.Hash, block *wtxmgr.BlockMeta, + req db.CreateTxRequest) (bool, error) { + + existing, err := s.txStore.TxDetails(txmgrNs, &txHash) + if err != nil { + return false, fmt.Errorf("lookup existing tx: %w", err) + } + + if existing == nil || existing.Block.Height < 0 { + return false, nil + } + + matches, err := s.legacyConfirmedDuplicateMatches( + addrmgrNs, txmgrNs, existing, txHash, block, req, + ) + if err != nil { + return false, err + } + + if matches { + return true, nil + } + + return true, fmt.Errorf("tx %s: %w", txHash, db.ErrTxAlreadyExists) +} + +// legacyConfirmedDuplicateMatches reports whether a confirmed duplicate batch +// notification is already fully reflected by the legacy store. +func (s *Store) legacyConfirmedDuplicateMatches( + addrmgrNs walletdb.ReadBucket, txmgrNs walletdb.ReadBucket, + existing *wtxmgr.TxDetails, txHash chainhash.Hash, + block *wtxmgr.BlockMeta, req db.CreateTxRequest) (bool, error) { + + if existing.Block.Height != block.Height || + existing.Block.Hash != block.Hash || + !existing.Block.Time.Equal(block.Time) { + + return false, nil + } + + status, err := readLegacyTxStatus(txmgrNs, txHash) + if err != nil { + return false, fmt.Errorf("read duplicate tx status: %w", err) + } + + return s.legacyDuplicateMatches(addrmgrNs, existing, status, req) +} + +// applyUnconfirmedLegacyTx records an unmined transaction through the legacy +// wtxmgr path. A duplicate unmined notification for a transaction the store +// already has confirmed is a no-op: InsertUnconfirmedTx no-ops the record write +// for an existing transaction, but the status write would otherwise mark the +// confirmed transaction pending. +func (s *Store) applyUnconfirmedLegacyTx( + addrmgrNs walletdb.ReadWriteBucket, txmgrNs walletdb.ReadWriteBucket, + txRec *wtxmgr.TxRecord, req db.CreateTxRequest) error { + + existing, err := s.txStore.TxDetails(txmgrNs, &txRec.Hash) + if err != nil { + return fmt.Errorf("lookup existing tx: %w", err) + } + + if existing != nil { + return s.applyUnconfirmedDuplicate( + addrmgrNs, txmgrNs, existing, txRec.Hash, req, + ) + } + + credits, err := s.legacyCreditEntries(addrmgrNs, req) + if err != nil { + return err + } + + err = s.txStore.InsertUnconfirmedTx(txmgrNs, txRec, credits) + if err != nil { + return fmt.Errorf("insert unconfirmed tx: %w", err) + } + + err = putLegacyTxStatus(txmgrNs, txRec.Hash, req.Params.Status) + if err != nil { + return fmt.Errorf("put tx status: %w", err) + } + + return s.putLegacyTxLabel(txmgrNs, txRec, req.Params.Label) +} + +// applyUnconfirmedDuplicate handles an unmined batch notification for a +// transaction hash already present in the legacy store. +func (s *Store) applyUnconfirmedDuplicate( + addrmgrNs walletdb.ReadBucket, txmgrNs walletdb.ReadBucket, + existing *wtxmgr.TxDetails, txHash chainhash.Hash, + req db.CreateTxRequest) error { + + if existing.Block.Height >= 0 { + return nil + } + + status, err := readLegacyTxStatus(txmgrNs, txHash) + if err != nil { + return fmt.Errorf("read duplicate tx status: %w", err) + } + + matches, err := s.legacyDuplicateMatches( + addrmgrNs, existing, status, req, + ) + if err != nil { + return err + } + + if matches { + return nil + } + + return fmt.Errorf("tx %s: %w", txHash, db.ErrTxAlreadyExists) +} + +// legacyDuplicateMatches reports whether a duplicate batch notification's +// wallet metadata is already fully reflected by the legacy store. +func (s *Store) legacyDuplicateMatches( + addrmgrNs walletdb.ReadBucket, existing *wtxmgr.TxDetails, + status db.TxStatus, req db.CreateTxRequest) (bool, error) { + + if status != req.Params.Status { + return false, nil + } + + if existing.Label != req.Params.Label { + return false, nil + } + + return s.legacyCreditsMatch(addrmgrNs, existing.Credits, req) +} + +// legacyCreditsMatch reports whether requested credit ownership exactly matches +// the legacy credit records already stored for an unconfirmed transaction. +func (s *Store) legacyCreditsMatch(addrmgrNs walletdb.ReadBucket, + existing []wtxmgr.CreditRecord, req db.CreateTxRequest) (bool, error) { + + if len(existing) != len(req.Params.Credits) { + return false, nil + } + + existingCredits := make(map[uint32]bool, len(existing)) + for _, credit := range existing { + existingCredits[credit.Index] = credit.Change + } + + for index, addr := range req.Params.Credits { + existingChange, ok := existingCredits[index] + if !ok { + return false, nil + } + + expectedChange := false + if addr != nil { + chainParams := s.addrStore.ChainParams() + + err := validateCreditAddr(*req.Params.Tx, index, addr, chainParams) + if err != nil { + return false, err + } + + managedAddr, err := s.addrStore.Address(addrmgrNs, addr) + if waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) { + return false, nil + } + + if err != nil { + return false, fmt.Errorf("lookup credit address %d: %w", + index, err) + } + + expectedChange = managedAddr.Internal() + } + + if existingChange != expectedChange { + return false, nil + } + } + + return true, nil +} + +// putLegacyTxStatus writes or clears the kvdb status side-bucket entry for one +// legacy transaction. +func putLegacyTxStatus(txmgrNs walletdb.ReadWriteBucket, + txid chainhash.Hash, status db.TxStatus) error { + + if status == db.TxStatusPublished { + return deleteLegacyTxStatus(txmgrNs, txid) + } + + if status != db.TxStatusPending { + return fmt.Errorf("legacy tx status %s: %w", status, + db.ErrInvalidStatus) + } + + statusBucket, err := txmgrNs.CreateBucketIfNotExists( + kvdbTxStatusBucketKey, + ) + if err != nil { + return fmt.Errorf("create tx status bucket: %w", err) + } + + return statusBucket.Put(txid[:], []byte{byte(status)}) +} + +// deleteLegacyTxStatus clears the kvdb status side-bucket entry for one legacy +// transaction. +func deleteLegacyTxStatus(txmgrNs walletdb.ReadWriteBucket, + txid chainhash.Hash) error { + + statusBucket := txmgrNs.NestedReadWriteBucket(kvdbTxStatusBucketKey) + if statusBucket == nil { + return nil + } + + return statusBucket.Delete(txid[:]) +} + +// readLegacyTxStatus reads the kvdb status side-bucket entry for one legacy +// transaction. Missing entries mean wtxmgr's native published status. +func readLegacyTxStatus(txmgrNs walletdb.ReadBucket, + txid chainhash.Hash) (db.TxStatus, error) { + + statusBucket := txmgrNs.NestedReadBucket(kvdbTxStatusBucketKey) + if statusBucket == nil { + return db.TxStatusPublished, nil + } + + raw := statusBucket.Get(txid[:]) + if raw == nil { + return db.TxStatusPublished, nil + } + + if len(raw) != legacyTxStatusValueLen { + return db.TxStatus(0), fmt.Errorf( + "%w: txid=%s: expected %d bytes, got %d", + errLegacyTxStatusUnexpectedSize, txid, + legacyTxStatusValueLen, len(raw), + ) + } + + status, err := db.ParseTxStatus(int64(raw[0])) + if err != nil { + return db.TxStatus(0), err + } + + return status, nil +} - var info *db.TxInfo +// putLegacyTxLabel records one non-empty transaction label through wtxmgr. +func (s *Store) putLegacyTxLabel(txmgrNs walletdb.ReadWriteBucket, + txRec *wtxmgr.TxRecord, label string) error { + + if len(label) == 0 { + return nil + } + + err := s.txStore.PutTxLabel(txmgrNs, txRec.Hash, label) + if err != nil { + return fmt.Errorf("put transaction label: %w", err) + } + + return nil +} + +// legacyCreditEntries converts db-native credit addresses into legacy credit +// entries and marks resolved addresses as used. +// +// Each non-nil credit is validated against the output script it claims to +// credit using the same membership check the CreateTx path applies in +// addCreateTxCredit, so the batch path cannot record a UTXO owned by an +// address the output does not pay. A nil credit means the caller has no +// resolved owner, so ownership is keyed on the output's own script: the entry +// is recorded from the output index alone, with no address-manager lookup or +// used-marking, matching CreateTx and the SQL backends. +func (s *Store) legacyCreditEntries(addrmgrNs walletdb.ReadWriteBucket, + req db.CreateTxRequest) ([]wtxmgr.CreditEntry, error) { + + if len(req.Params.Credits) == 0 { + return nil, nil + } + + chainParams := s.addrStore.ChainParams() + + credits := make([]wtxmgr.CreditEntry, 0, len(req.Params.Credits)) + for index, addr := range req.Params.Credits { + // A nil credit address has no owner to resolve, so key the + // credit on the output's own script: record it from the index + // with change cleared, skipping the address-manager lookup and + // the membership check that has no caller address to validate. + if addr == nil { + if int(index) >= len(req.Params.Tx.TxOut) { + return nil, fmt.Errorf("credit output %d: %w: "+ + "index out of range", index, + db.ErrInvalidParam) + } + + credits = append(credits, wtxmgr.CreditEntry{ + Index: index, + Change: false, + }) + + continue + } + + // Validate the caller-supplied credit against the actual output + // script before trusting it, rejecting a mismatch with the same + // error CreateTx uses. + err := validateCreditAddr( + *req.Params.Tx, index, addr, chainParams, + ) + if err != nil { + return nil, err + } + + managedAddr, err := s.addrStore.Address(addrmgrNs, addr) + if waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) { + return nil, fmt.Errorf("credit output %d: %w", index, + db.ErrAddressNotFound) + } + + if err != nil { + return nil, fmt.Errorf("lookup credit address %d: %w", + index, err) + } + + credits = append(credits, wtxmgr.CreditEntry{ + Index: index, + Change: managedAddr.Internal(), + }) + + err = s.addrStore.MarkUsed(addrmgrNs, addr) + if err != nil { + return nil, fmt.Errorf("mark credit address used %d: %w", + index, err) + } + } + + return credits, nil +} + +// kvdbTxBlockMeta converts store block metadata into legacy transaction block +// metadata. +func kvdbTxBlockMeta(block *db.Block) (*wtxmgr.BlockMeta, error) { + height, err := db.Uint32ToInt32(block.Height) + if err != nil { + return nil, fmt.Errorf("convert block height: %w", err) + } + + return &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Hash: block.Hash, + Height: height, + }, + Time: block.Timestamp, + }, nil +} + +// fetchTxDetailsWithStatus loads the wtxmgr details and legacy status for a +// transaction within a single read transaction. +func (s *Store) fetchTxDetailsWithStatus(txid *chainhash.Hash) ( + *wtxmgr.TxDetails, db.TxStatus, error) { + + var ( + details *wtxmgr.TxDetails + status db.TxStatus + ) err := walletdb.View(s.db, func(tx walletdb.ReadTx) error { ns := tx.ReadBucket(wtxmgrNamespaceKey) @@ -304,24 +929,40 @@ func (s *Store) GetTx(_ context.Context, query db.GetTxQuery) ( ) } - details, err := s.txStore.TxDetails(ns, &query.Txid) + txDetails, err := s.txStore.TxDetails(ns, txid) if err != nil { return fmt.Errorf("lookup transaction details: %w", err) } - if details == nil { + if txDetails == nil { return db.ErrTxNotFound } - info = kvdbTxInfo(details) + txStatus, err := readLegacyTxStatus(ns, txDetails.Hash) + if err != nil { + return fmt.Errorf("read tx status: %w", err) + } + + details = txDetails + status = txStatus return nil }) + + return details, status, err +} + +// GetTx retrieves one wallet-scoped transaction snapshot through the legacy +// wtxmgr query path. +func (s *Store) GetTx(_ context.Context, query db.GetTxQuery) ( + *db.TxInfo, error) { + + details, status, err := s.fetchTxDetailsWithStatus(&query.Txid) if err != nil { return nil, fmt.Errorf("kvdb.Store.GetTx: %w", err) } - return info, nil + return kvdbTxInfo(details, status), nil } // ListTxns lists wallet-scoped transaction summaries through the legacy wtxmgr @@ -375,7 +1016,16 @@ func (s *Store) listTxnsRange(ns walletdb.ReadBucket, begin, end int32, ns, begin, end, func(txDetails []wtxmgr.TxDetails) (bool, error) { for i := range txDetails { - infos = append(infos, *kvdbTxInfo(&txDetails[i])) + status, err := readLegacyTxStatus( + ns, txDetails[i].Hash, + ) + if err != nil { + return false, fmt.Errorf("read tx status: %w", err) + } + + infos = append( + infos, *kvdbTxInfo(&txDetails[i], status), + ) } return false, nil @@ -393,34 +1043,12 @@ func (s *Store) listTxnsRange(ns walletdb.ReadBucket, begin, end int32, func (s *Store) GetTxDetail(_ context.Context, query db.GetTxDetailQuery) ( *db.TxDetailInfo, error) { - var detail *db.TxDetailInfo - - err := walletdb.View(s.db, func(tx walletdb.ReadTx) error { - ns := tx.ReadBucket(wtxmgrNamespaceKey) - if ns == nil { - return fmt.Errorf( - "wtxmgr namespace: %w", walletdb.ErrBucketNotFound, - ) - } - - txDetails, err := s.txStore.TxDetails(ns, &query.Txid) - if err != nil { - return fmt.Errorf("lookup transaction details: %w", err) - } - - if txDetails == nil { - return db.ErrTxNotFound - } - - detail = kvdbTxDetailInfo(txDetails) - - return nil - }) + details, status, err := s.fetchTxDetailsWithStatus(&query.Txid) if err != nil { return nil, fmt.Errorf("kvdb.Store.GetTxDetail: %w", err) } - return detail, nil + return kvdbTxDetailInfo(details, status), nil } // ListTxDetails lists detailed wallet-scoped transaction views through the @@ -442,8 +1070,18 @@ func (s *Store) ListTxDetails(_ context.Context, query db.ListTxDetailsQuery) ( ns, query.StartHeight, query.EndHeight, func(txDetails []wtxmgr.TxDetails) (bool, error) { for i := range txDetails { + status, err := readLegacyTxStatus( + ns, txDetails[i].Hash, + ) + if err != nil { + return false, fmt.Errorf( + "read tx status: %w", err, + ) + } + details = append( - details, *kvdbTxDetailInfo(&txDetails[i]), + details, + *kvdbTxDetailInfo(&txDetails[i], status), ) } @@ -491,7 +1129,12 @@ func (s *Store) InvalidateUnminedTx(_ context.Context, db.ErrInvalidateTx) } - return s.txStore.RemoveUnminedTx(ns, &details.TxRecord) + err = s.txStore.RemoveUnminedTx(ns, &details.TxRecord) + if err != nil { + return err + } + + return deleteLegacyTxStatus(ns, params.Txid) }) if err != nil { return fmt.Errorf("kvdb.Store.InvalidateUnminedTx: %w", err) @@ -1033,7 +1676,7 @@ func validateUpdateTxParams(params db.UpdateTxParams) (*string, error) { // kvdbTxInfo maps legacy wtxmgr detail data into the lightweight db-native // transaction summary model. -func kvdbTxInfo(details *wtxmgr.TxDetails) *db.TxInfo { +func kvdbTxInfo(details *wtxmgr.TxDetails, status db.TxStatus) *db.TxInfo { var block *db.Block if details.Block.Height >= 0 { block = &db.Block{ @@ -1049,16 +1692,16 @@ func kvdbTxInfo(details *wtxmgr.TxDetails) *db.TxInfo { Received: details.Received.UTC(), Block: block, - // Legacy wtxmgr only exposes transactions it still treats as valid, - // and it does not persist pending/replaced/failed/orphaned state. - Status: db.TxStatusPublished, + Status: status, Label: details.Label, } } // kvdbTxDetailInfo maps legacy wtxmgr detail data into the db-native // transaction detail model used by wallet tx-reader code. -func kvdbTxDetailInfo(details *wtxmgr.TxDetails) *db.TxDetailInfo { +func kvdbTxDetailInfo(details *wtxmgr.TxDetails, + status db.TxStatus) *db.TxDetailInfo { + var block *db.Block if details.Block.Height >= 0 { block = &db.Block{ @@ -1093,9 +1736,7 @@ func kvdbTxDetailInfo(details *wtxmgr.TxDetails) *db.TxDetailInfo { Received: details.Received.UTC(), Block: block, - // Legacy wtxmgr only exposes transactions it still treats as valid, - // and it does not persist pending/replaced/failed/orphaned state. - Status: db.TxStatusPublished, + Status: status, Label: details.Label, OwnedInputs: ownedInputs, OwnedOutputs: ownedOutputs, diff --git a/wallet/internal/db/kvdb/txstore_test.go b/wallet/internal/db/kvdb/txstore_test.go index 48f89ccf70..6f2a23b4a3 100644 --- a/wallet/internal/db/kvdb/txstore_test.go +++ b/wallet/internal/db/kvdb/txstore_test.go @@ -21,6 +21,58 @@ import ( "github.com/stretchr/testify/require" ) +// failingRollbackTxStore injects a Rollback failure while delegating all other +// transaction-store methods to the embedded implementation. +type failingRollbackTxStore struct { + wtxmgr.TxStore + + err error +} + +// Rollback injects the configured rollback failure. +func (f failingRollbackTxStore) Rollback(walletdb.ReadWriteBucket, + int32) error { + + return f.err +} + +// succeedThenFailSyncedAddrStore writes the synced-to block through the real +// manager, then reports an induced failure so the walletdb transaction rolls +// back after the manager's in-memory tip has already advanced. +type succeedThenFailSyncedAddrStore struct { + *waddrmgr.Manager + + beforeRestore func() +} + +// SetSyncedTo writes the synced-to block before injecting the configured +// post-write failure. +func (s *succeedThenFailSyncedAddrStore) SetSyncedTo( + ns walletdb.ReadWriteBucket, bs *waddrmgr.BlockStamp) error { + + err := s.Manager.SetSyncedTo(ns, bs) + if err != nil { + return err + } + + return errInducedFailure +} + +// RestoreSyncedToIfCurrent runs an optional hook before delegating the +// conditional restore to the wrapped manager. +func (s *succeedThenFailSyncedAddrStore) RestoreSyncedToIfCurrent( + previous, current waddrmgr.BlockStamp) bool { + + if s.beforeRestore != nil { + beforeRestore := s.beforeRestore + s.beforeRestore = nil + + beforeRestore() + } + + return s.Manager.RestoreSyncedToIfCurrent(previous, current) +} + // TestCreateTxUnminedWithCreditSuccess verifies that kvdb.Store records an // unmined transaction, label, credit, and address-used state through wtxmgr. func TestCreateTxUnminedWithCreditSuccess(t *testing.T) { @@ -117,6 +169,67 @@ func TestCreateTxDuplicateReturnsStoreError(t *testing.T) { require.ErrorIs(t, err, db.ErrTxAlreadyExists) } +// TestCreateTxPersistsPendingStatus verifies kvdb round-trips pending +// transactions through its status side bucket. +func TestCreateTxPersistsPendingStatus(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + newAddrmgrNamespace(t, dbConn) + txStore := newTxStore(t, dbConn) + store := NewStore(dbConn, txStore, nil) + + txMsg := &wire.MsgTx{Version: 1} + txMsg.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{77}, + }}) + txMsg.AddTxOut(&wire.TxOut{Value: 8_000, PkScript: []byte{0x51}}) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: 0, + Tx: txMsg, + Received: time.Unix(1710004300, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + txid := txMsg.TxHash() + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: 0, + Txid: txid, + }) + require.NoError(t, err) + require.Equal(t, db.TxStatusPending, info.Status) + + detail, err := store.GetTxDetail(t.Context(), db.GetTxDetailQuery{ + WalletID: 0, + Txid: txid, + }) + require.NoError(t, err) + require.Equal(t, db.TxStatusPending, detail.Status) + + infos, err := store.ListTxns(t.Context(), db.ListTxnsQuery{ + WalletID: 0, + UnminedOnly: true, + }) + require.NoError(t, err) + require.Len(t, infos, 1) + require.Equal(t, db.TxStatusPending, infos[0].Status) + + details, err := store.ListTxDetails( + t.Context(), db.ListTxDetailsQuery{ + WalletID: 0, + StartHeight: -1, + EndHeight: -1, + }, + ) + require.NoError(t, err) + require.Len(t, details, 1) + require.Equal(t, db.TxStatusPending, details[0].Status) +} + // TestCreateTxCreditAddrMismatch verifies that crediting an output with an // address that the output script does not pay to is rejected, so a caller // cannot corrupt UTXO ownership by mislabeling a credit. @@ -870,6 +983,52 @@ func TestRollbackToBlockRestoresResetSyncTipOnCommitFailure(t *testing.T) { require.ErrorIs(t, err, errInjectedCommit) } +// TestRollbackToBlockFailureKeepsSyncTip verifies a transaction rollback +// failure does not leave the live address-manager sync tip rewound. +func TestRollbackToBlockFailureKeepsSyncTip(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + addrStore := newAddrStore(t, dbConn) + t.Cleanup(addrStore.Close) + txStore := newTxStore(t, dbConn) + + forkTip := waddrmgr.BlockStamp{ + Hash: chainhash.Hash{9}, + Height: 9, + Timestamp: time.Unix(1710004100, 0), + } + currentTip := waddrmgr.BlockStamp{ + Hash: chainhash.Hash{10}, + Height: 10, + Timestamp: time.Unix(1710004700, 0), + } + err := walletdb.Update(dbConn, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgr.NamespaceKey) + require.NotNil(t, addrmgrNs) + + err := addrStore.SetSyncedTo(addrmgrNs, &forkTip) + if err != nil { + return err + } + + return addrStore.SetSyncedTo(addrmgrNs, ¤tTip) + }) + require.NoError(t, err) + + store := NewStore( + dbConn, failingRollbackTxStore{ + TxStore: txStore, + err: errInducedFailure, + }, addrStore, + ) + err = store.RollbackToBlock(t.Context(), 10) + require.ErrorIs(t, err, errInducedFailure) + require.Equal(t, currentTip, addrStore.SyncedTo()) +} + // TestUpdateTxLabelOnlySuccess verifies that kvdb.Store can apply a label-only // UpdateTx patch through the legacy label path. func TestUpdateTxLabelOnlySuccess(t *testing.T) { @@ -1371,6 +1530,29 @@ func mockNoBirthdayBlock(addrStore *bwmock.AddrStore) { ).Maybe() } +// beforeUpdateDB wraps a walletdb.DB and runs one hook immediately before the +// next Update starts its underlying write transaction. +type beforeUpdateDB struct { + walletdb.DB + + before func() +} + +// Update runs the configured pre-update hook before delegating to the embedded +// DB implementation. +func (db *beforeUpdateDB) Update(f func(walletdb.ReadWriteTx) error, + reset func()) error { + + if db.before != nil { + before := db.before + db.before = nil + + before() + } + + return db.DB.Update(f, reset) +} + // writeSyncedTo persists a sync tip through the real address manager. func writeSyncedTo(t *testing.T, dbConn walletdb.DB, mgr *waddrmgr.Manager, tip waddrmgr.BlockStamp) { @@ -1588,3 +1770,948 @@ func matchAddress(addr address.Address) interface{} { return got.EncodeAddress() == addr.EncodeAddress() }) } + +// TestApplyTxBatchDuplicateUnminedKeepsConfirmed verifies that a duplicate +// unmined notification for an already-confirmed transaction is a no-op and does +// not downgrade the recorded confirmed status to pending. +func TestApplyTxBatchDuplicateUnminedKeepsConfirmed(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + newAddrmgrNamespace(t, dbConn) + txStore := newTxStore(t, dbConn) + + addr, script := newTestAddressScript(t) + managedAddr := &bwmock.ManagedAddress{} + managedAddr.On("Internal").Return(true).Maybe() + managedAddr.On("Address").Return(addr).Maybe() + managedAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Maybe() + managedAddr.On("Imported").Return(false).Maybe() + managedAddr.On("InternalAccount").Return(uint32(0)).Maybe() + managedAddr.On("Compressed").Return(true).Maybe() + managedAddr.On("AddrHash").Return([]byte(nil)).Maybe() + managedAddr.On("Used", mock.Anything).Return(false).Maybe() + + addrStore := &bwmock.AddrStore{} + addrStore.On("ChainParams").Return(&chaincfg.RegressionNetParams) + addrStore.On("Address", mock.Anything, mock.Anything). + Return(managedAddr, nil) + addrStore.On("MarkUsed", mock.Anything, mock.Anything).Return(nil) + addrStore.On("SetSyncedTo", mock.Anything, mock.Anything).Return(nil) + addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{}).Maybe() + store := NewStore(dbConn, txStore, addrStore) + + txMsg := &wire.MsgTx{Version: 1} + txMsg.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{63}, + }}) + txMsg.AddTxOut(&wire.TxOut{Value: 10_000, PkScript: script}) + + block := &db.Block{ + Hash: chainhash.Hash{64}, + Height: 144, + Timestamp: time.Unix(1710003200, 0), + } + + // Record the transaction as confirmed. + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{{ + WalletID: 0, + Tx: txMsg, + Received: time.Unix(1710003100, 0), + Block: block, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: addr}, + }}, + SyncedTo: block, + }) + require.NoError(t, err) + + // A later duplicate unmined notification for the same transaction must + // not downgrade its confirmed status to pending. + err = store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{{ + WalletID: 0, + Tx: txMsg, + Received: time.Unix(1710003300, 0), + Status: db.TxStatusPending, + Credits: map[uint32]address.Address{0: addr}, + }}, + }) + require.NoError(t, err) + + txInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: 0, + Txid: txMsg.TxHash(), + }) + require.NoError(t, err) + require.NotNil(t, txInfo.Block) + require.Equal(t, db.TxStatusPublished, txInfo.Status) +} + +// TestApplyTxBatchConfirmedDuplicatePreservesUnminedLabel verifies that a +// confirmed notification for an already-stored unmined transaction preserves +// the existing user label instead of replacing it with the batch label. +func TestApplyTxBatchConfirmedDuplicatePreservesUnminedLabel(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + newAddrmgrNamespace(t, dbConn) + txStore := newTxStore(t, dbConn) + addrStore := &bwmock.AddrStore{} + addrStore.On("ChainParams").Return(&chaincfg.RegressionNetParams).Maybe() + store := NewStore(dbConn, txStore, addrStore) + + txMsg := &wire.MsgTx{Version: 1} + txMsg.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{69}, + }}) + txMsg.AddTxOut(&wire.TxOut{Value: 10_000, PkScript: []byte{0x51}}) + + const originalLabel = "original" + + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{{ + WalletID: 0, + Tx: txMsg, + Received: time.Unix(1710003800, 0), + Status: db.TxStatusPending, + Label: originalLabel, + }}, + }) + require.NoError(t, err) + + block := &db.Block{ + Hash: chainhash.Hash{70}, + Height: 147, + Timestamp: time.Unix(1710003900, 0), + } + err = store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{{ + WalletID: 0, + Tx: txMsg, + Received: time.Unix(1710004000, 0), + Block: block, + Status: db.TxStatusPublished, + Label: "ignored", + }}, + }) + require.NoError(t, err) + + txInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: 0, + Txid: txMsg.TxHash(), + }) + require.NoError(t, err) + require.NotNil(t, txInfo.Block) + require.Equal(t, block.Height, txInfo.Block.Height) + require.Equal(t, db.TxStatusPublished, txInfo.Status) + require.Equal(t, originalLabel, txInfo.Label) +} + +// TestApplyTxBatchDuplicateConfirmedRejectsBlockMismatch verifies that a +// confirmed duplicate for the same transaction hash cannot be recorded in a +// second block. +func TestApplyTxBatchDuplicateConfirmedRejectsBlockMismatch(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + newAddrmgrNamespace(t, dbConn) + txStore := newTxStore(t, dbConn) + addrStore := &bwmock.AddrStore{} + addrStore.On("ChainParams").Return(&chaincfg.RegressionNetParams).Maybe() + store := NewStore(dbConn, txStore, addrStore) + + txMsg := &wire.MsgTx{Version: 1} + txMsg.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{65}, + }}) + txMsg.AddTxOut(&wire.TxOut{Value: 10_000, PkScript: []byte{0x51}}) + + firstBlock := &db.Block{ + Hash: chainhash.Hash{66}, + Height: 145, + Timestamp: time.Unix(1710003400, 0), + } + secondBlock := &db.Block{ + Hash: chainhash.Hash{67}, + Height: 146, + Timestamp: time.Unix(1710003500, 0), + } + + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{{ + WalletID: 0, + Tx: txMsg, + Received: time.Unix(1710003300, 0), + Block: firstBlock, + Status: db.TxStatusPublished, + }}, + }) + require.NoError(t, err) + + err = store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{{ + WalletID: 0, + Tx: txMsg, + Received: time.Unix(1710003600, 0), + Block: secondBlock, + Status: db.TxStatusPublished, + }}, + }) + require.ErrorIs(t, err, db.ErrTxAlreadyExists) + + txInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: 0, + Txid: txMsg.TxHash(), + }) + require.NoError(t, err) + require.NotNil(t, txInfo.Block) + require.Equal(t, firstBlock.Height, txInfo.Block.Height) + require.Equal(t, firstBlock.Hash, txInfo.Block.Hash) +} + +// TestApplyTxBatchDuplicateUnminedRejectsMutation verifies that a duplicate +// unconfirmed batch member cannot mutate status, label, or credit metadata for +// a transaction already stored as unconfirmed. +func TestApplyTxBatchDuplicateUnminedRejectsMutation(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + newAddrmgrNamespace(t, dbConn) + txStore := newTxStore(t, dbConn) + addrStore := &bwmock.AddrStore{} + addrStore.On("ChainParams").Return(&chaincfg.RegressionNetParams).Maybe() + store := NewStore(dbConn, txStore, addrStore) + + txMsg := &wire.MsgTx{Version: 1} + txMsg.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{65}, + }}) + txMsg.AddTxOut(&wire.TxOut{Value: 10_000, PkScript: []byte{0x51}}) + + const originalLabel = "original" + + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{{ + WalletID: 0, + Tx: txMsg, + Received: time.Unix(1710003400, 0), + Status: db.TxStatusPending, + Label: originalLabel, + }}, + }) + require.NoError(t, err) + + err = store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{{ + WalletID: 0, + Tx: txMsg, + Received: time.Unix(1710003500, 0), + Status: db.TxStatusPublished, + Label: "mutated", + }}, + }) + require.ErrorIs(t, err, db.ErrTxAlreadyExists) + + err = store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{{ + WalletID: 0, + Tx: txMsg, + Received: time.Unix(1710003600, 0), + Status: db.TxStatusPending, + Label: originalLabel, + Credits: map[uint32]address.Address{0: nil}, + }}, + }) + require.ErrorIs(t, err, db.ErrTxAlreadyExists) + + txInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: 0, + Txid: txMsg.TxHash(), + }) + require.NoError(t, err) + require.Nil(t, txInfo.Block) + require.Equal(t, db.TxStatusPending, txInfo.Status) + require.Equal(t, originalLabel, txInfo.Label) +} + +// TestApplyTxBatchRecordsTxAndSyncedTo verifies that kvdb.Store applies +// transaction notifications and sync-tip updates atomically. +func TestApplyTxBatchRecordsTxAndSyncedTo(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + newAddrmgrNamespace(t, dbConn) + txStore := newTxStore(t, dbConn) + + addr, script := newTestAddressScript(t) + managedAddr := &bwmock.ManagedAddress{} + managedAddr.On("Internal").Return(true).Maybe() + managedAddr.On("Address").Return(addr).Maybe() + managedAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Maybe() + managedAddr.On("Imported").Return(false).Maybe() + managedAddr.On("InternalAccount").Return(uint32(0)).Maybe() + managedAddr.On("Compressed").Return(true).Maybe() + managedAddr.On("AddrHash").Return([]byte(nil)).Maybe() + managedAddr.On("Used", mock.Anything).Return(false).Maybe() + + addrStore := &bwmock.AddrStore{} + addrStore.On("ChainParams").Return(&chaincfg.RegressionNetParams) + addrStore.On("Address", mock.Anything, mock.Anything). + Return(managedAddr, nil) + addrStore.On("MarkUsed", mock.Anything, mock.Anything).Return(nil) + addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{}).Maybe() + addrStore.On("SetSyncedTo", mock.Anything, mock.Anything).Return(nil) + store := NewStore(dbConn, txStore, addrStore) + + txMsg := &wire.MsgTx{Version: 1} + txMsg.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{63}, + }}) + txMsg.AddTxOut(&wire.TxOut{Value: 10_000, PkScript: script}) + + syncedTo := &db.Block{ + Hash: chainhash.Hash{64}, + Height: 144, + Timestamp: time.Unix(1710003200, 0), + } + label := "batch label" + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{{ + WalletID: 0, + Tx: txMsg, + Received: time.Unix(1710003100, 0), + Status: db.TxStatusPublished, + Label: label, + Credits: map[uint32]address.Address{0: addr}, + }}, + SyncedTo: syncedTo, + }) + require.NoError(t, err) + + txid := txMsg.TxHash() + err = walletdb.View(dbConn, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(wtxmgrNamespaceKey) + require.NotNil(t, ns) + + details, err := txStore.TxDetails(ns, &txid) + require.NoError(t, err) + require.NotNil(t, details) + require.Equal(t, label, details.Label) + require.Len(t, details.Credits, 1) + require.True(t, details.Credits[0].Change) + + return nil + }) + require.NoError(t, err) + addrStore.AssertCalled(t, "MarkUsed", mock.Anything, mock.Anything) + addrStore.AssertCalled( + t, "SetSyncedTo", mock.Anything, + mock.MatchedBy(func(bs *waddrmgr.BlockStamp) bool { + return bs.Height == int32(syncedTo.Height) + }), + ) +} + +// TestApplyTxBatchRestoresSyncedToOnFailure verifies that a batch failure after +// SetSyncedTo restores the address manager's in-memory synced tip to match the +// rolled-back walletdb state. +func TestApplyTxBatchRestoresSyncedToOnFailure(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + mgr := newSpendableAddrMgr(t, dbConn) + t.Cleanup(mgr.Close) + + preBatchTip := waddrmgr.BlockStamp{ + Height: 100, + Hash: chainhash.Hash{71}, + Timestamp: time.Unix(1710004100, 0), + } + err := walletdb.Update(dbConn, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgr.NamespaceKey) + require.NotNil(t, addrmgrNs) + + return mgr.SetSyncedTo(addrmgrNs, &preBatchTip) + }) + require.NoError(t, err) + require.Equal(t, preBatchTip, mgr.SyncedTo()) + + addrStore := &succeedThenFailSyncedAddrStore{Manager: mgr} + store := NewStore(dbConn, nil, addrStore) + syncedTo := &db.Block{ + Hash: chainhash.Hash{72}, + Height: 101, + Timestamp: time.Unix(1710004200, 0), + } + + err = store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + SyncedTo: syncedTo, + }) + require.ErrorIs(t, err, errInducedFailure) + require.Equal(t, preBatchTip, mgr.SyncedTo()) + + var reopenedTip waddrmgr.BlockStamp + + err = walletdb.View(dbConn, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgr.NamespaceKey) + require.NotNil(t, addrmgrNs) + + reopened, err := waddrmgr.Open( + addrmgrNs, []byte("pub"), &chaincfg.SimNetParams, + ) + if err != nil { + return err + } + defer reopened.Close() + + reopenedTip = reopened.SyncedTo() + + return nil + }) + require.NoError(t, err) + require.Equal(t, preBatchTip.Height, reopenedTip.Height) + require.Equal(t, preBatchTip.Hash, reopenedTip.Hash) +} + +// TestApplyTxBatchRestoresWriteLockedSyncedToOnFailure verifies that a failed +// batch restores the sync tip snapshot captured inside the walletdb write +// transaction, not a stale tip observed before another writer commits. +func TestApplyTxBatchRestoresWriteLockedSyncedToOnFailure(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + mgr := newSpendableAddrMgr(t, dbConn) + t.Cleanup(mgr.Close) + + preBatchTip := waddrmgr.BlockStamp{ + Height: 100, + Hash: chainhash.Hash{81}, + Timestamp: time.Unix(1710004300, 0), + } + writeSyncedTo(t, dbConn, mgr, preBatchTip) + require.Equal(t, preBatchTip, mgr.SyncedTo()) + + writeLockedTip := waddrmgr.BlockStamp{ + Height: 101, + Hash: chainhash.Hash{82}, + Timestamp: time.Unix(1710004400, 0), + } + failDB := &beforeUpdateDB{ + DB: dbConn, + before: func() { + writeSyncedTo(t, dbConn, mgr, writeLockedTip) + }, + } + + addrStore := &succeedThenFailSyncedAddrStore{Manager: mgr} + store := NewStore(failDB, nil, addrStore) + syncedTo := &db.Block{ + Hash: chainhash.Hash{83}, + Height: 102, + Timestamp: time.Unix(1710004500, 0), + } + + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + SyncedTo: syncedTo, + }) + require.ErrorIs(t, err, errInducedFailure) + require.Equal(t, writeLockedTip, mgr.SyncedTo()) +} + +// TestApplyTxBatchSkipsStaleSyncedToRestore verifies that the failed-batch +// restore path does not overwrite a newer live tip committed after the failed +// update has returned. +func TestApplyTxBatchSkipsStaleSyncedToRestore(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + mgr := newSpendableAddrMgr(t, dbConn) + t.Cleanup(mgr.Close) + + preBatchTip := waddrmgr.BlockStamp{ + Height: 100, + Hash: chainhash.Hash{84}, + Timestamp: time.Unix(1710004600, 0), + } + writeSyncedTo(t, dbConn, mgr, preBatchTip) + require.Equal(t, preBatchTip, mgr.SyncedTo()) + + newerTip := waddrmgr.BlockStamp{ + Height: 101, + Hash: chainhash.Hash{85}, + Timestamp: time.Unix(1710004700, 0), + } + addrStore := &succeedThenFailSyncedAddrStore{ + Manager: mgr, + beforeRestore: func() { + writeSyncedTo(t, dbConn, mgr, newerTip) + }, + } + store := NewStore(dbConn, nil, addrStore) + syncedTo := &db.Block{ + Hash: chainhash.Hash{86}, + Height: 102, + Timestamp: time.Unix(1710004800, 0), + } + + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + SyncedTo: syncedTo, + }) + require.ErrorIs(t, err, errInducedFailure) + require.Equal(t, newerTip, mgr.SyncedTo()) +} + +// TestApplyTxBatchEmptyNoAddrStore verifies that a batch with no transactions +// and no sync-tip update returns nil without an address manager and without +// opening a write transaction. The store is built with a nil addrStore and the +// db has no namespaces, so any attempt to open the addrmgr bucket would fail; +// a nil error therefore proves the empty batch short-circuits before +// walletdb.Update. +func TestApplyTxBatchEmptyNoAddrStore(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + // Deliberately create no namespaces and pass a nil addrStore: an empty + // batch must touch neither. + store := NewStore(dbConn, nil, nil) + + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{WalletID: 0}) + require.NoError(t, err) +} + +// TestApplyTxBatchCreditlessNoAddrStore verifies that a transaction-only batch +// with no credits records through wtxmgr without an address manager or addrmgr +// namespace. +func TestApplyTxBatchCreditlessNoAddrStore(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + txStore := newTxStore(t, dbConn) + store := NewStore(dbConn, txStore, nil) + + txMsg := &wire.MsgTx{Version: 1} + txMsg.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{87}, + }}) + txMsg.AddTxOut(&wire.TxOut{Value: 1_000, PkScript: []byte{0x51}}) + txHash := txMsg.TxHash() + + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{{ + WalletID: 0, + Tx: txMsg, + Received: time.Unix(1710004900, 0), + Status: db.TxStatusPublished, + }}, + }) + require.NoError(t, err) + + err = walletdb.View(dbConn, func(tx walletdb.ReadTx) error { + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + require.NotNil(t, txmgrNs) + + details, err := txStore.TxDetails(txmgrNs, &txHash) + require.NoError(t, err) + require.NotNil(t, details) + require.Empty(t, details.Credits) + + return nil + }) + require.NoError(t, err) +} + +// TestApplyTxBatchSyncTipOnlyDoesNotRequireTxStore verifies a sync-tip-only +// batch does not require the legacy wtxmgr namespace. +func TestApplyTxBatchSyncTipOnlyDoesNotRequireTxStore(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + newAddrmgrNamespace(t, dbConn) + + syncedTo := &db.Block{ + Hash: chainhash.Hash{78}, + Height: 144, + Timestamp: time.Unix(1710004400, 0), + } + addrStore := &bwmock.AddrStore{} + addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{}).Maybe() + addrStore.On("SetSyncedTo", mock.Anything, mock.MatchedBy( + func(bs *waddrmgr.BlockStamp) bool { + return bs.Height == int32(syncedTo.Height) && + bs.Hash == syncedTo.Hash + }, + )).Return(nil) + store := NewStore(dbConn, nil, addrStore) + + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + SyncedTo: syncedTo, + }) + require.NoError(t, err) + addrStore.AssertExpectations(t) +} + +// TestApplyTxBatchRejectsMismatchedWalletID verifies kvdb enforces the shared +// batch wallet-id invariant before opening a write transaction. +func TestApplyTxBatchRejectsMismatchedWalletID(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + store := NewStore(dbConn, nil, nil) + txMsg := &wire.MsgTx{Version: 1} + txMsg.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{75}, + }}) + txMsg.AddTxOut(&wire.TxOut{Value: 1_000, PkScript: []byte{0x51}}) + + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{{ + WalletID: 1, + Tx: txMsg, + Received: time.Unix(1710004000, 0), + Status: db.TxStatusPublished, + }}, + }) + require.ErrorIs(t, err, db.ErrInvalidParam) +} + +// TestApplyTxBatchRejectsNilTx verifies that a multi-transaction batch +// containing a nil Tx is rejected with ErrInvalidParam instead of panicking. +// ApplyTxBatch reorders the batch parents-first before applying it, and that +// sort dereferences each transaction's Tx; without an up-front nil check the +// sort would panic on the nil member rather than returning a validation error. +func TestApplyTxBatchRejectsNilTx(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + store := NewStore(dbConn, nil, nil) + + validTx := &wire.MsgTx{Version: 1} + validTx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{77}, + }}) + validTx.AddTxOut(&wire.TxOut{Value: 1_000, PkScript: []byte{0x51}}) + + // The batch carries two transactions but the second has a nil Tx. The + // parents-first sort runs before the per-tx request validation, so this + // must be caught by the up-front guard rather than panicking. + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{ + { + WalletID: 0, + Tx: validTx, + Received: time.Unix(1710004100, 0), + Status: db.TxStatusPublished, + }, + { + WalletID: 0, + Tx: nil, + Received: time.Unix(1710004101, 0), + Status: db.TxStatusPublished, + }, + }, + }) + require.ErrorIs(t, err, db.ErrInvalidParam) +} + +// TestApplyTxBatchConfirmedChildBeforeParentSpendsParent verifies that a batch +// listing a confirmed child before its in-batch confirmed parent still records +// the child's spend of the parent's wallet-owned output. The confirmed write +// path records a debit only against an already-inserted parent credit, so +// applying the child first would find no credit to spend and silently leave the +// parent output unspent. ApplyTxBatch sorts the batch parents-first to close +// that gap. +func TestApplyTxBatchConfirmedChildBeforeParentSpendsParent(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + newAddrmgrNamespace(t, dbConn) + txStore := newTxStore(t, dbConn) + + addr, script := newTestAddressScript(t) + managedAddr := &bwmock.ManagedAddress{} + managedAddr.On("Internal").Return(true).Maybe() + managedAddr.On("Address").Return(addr).Maybe() + managedAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Maybe() + managedAddr.On("Imported").Return(false).Maybe() + managedAddr.On("InternalAccount").Return(uint32(0)).Maybe() + managedAddr.On("Compressed").Return(true).Maybe() + managedAddr.On("AddrHash").Return([]byte(nil)).Maybe() + managedAddr.On("Used", mock.Anything).Return(false).Maybe() + + addrStore := &bwmock.AddrStore{} + addrStore.On("ChainParams").Return(&chaincfg.RegressionNetParams) + addrStore.On("Address", mock.Anything, mock.Anything). + Return(managedAddr, nil) + addrStore.On("MarkUsed", mock.Anything, mock.Anything).Return(nil) + addrStore.On("SetSyncedTo", mock.Anything, mock.Anything).Return(nil) + addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{}).Maybe() + store := NewStore(dbConn, txStore, addrStore) + + // The parent spends an external input and credits the wallet at output 0. + parentTx := &wire.MsgTx{Version: 1} + parentTx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{91}, + }}) + parentTx.AddTxOut(&wire.TxOut{Value: 10_000, PkScript: script}) + parentOutPoint := wire.OutPoint{Hash: parentTx.TxHash(), Index: 0} + + // The child spends the parent's wallet-owned output and credits the wallet + // at its own output 0. + childTx := &wire.MsgTx{Version: 1} + childTx.AddTxIn(&wire.TxIn{PreviousOutPoint: parentOutPoint}) + childTx.AddTxOut(&wire.TxOut{Value: 9_000, PkScript: script}) + childOutPoint := wire.OutPoint{Hash: childTx.TxHash(), Index: 0} + + block := &db.Block{ + Hash: chainhash.Hash{92}, + Height: 200, + Timestamp: time.Unix(1710005000, 0), + } + + // Deliberately list the confirmed child before its in-batch confirmed + // parent. A caller-order apply would record the child first and drop its + // spend of the not-yet-stored parent output. + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{ + { + WalletID: 0, + Tx: childTx, + Received: time.Unix(1710005100, 0), + Block: block, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: addr}, + }, + { + WalletID: 0, + Tx: parentTx, + Received: time.Unix(1710005101, 0), + Block: block, + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: addr}, + }, + }, + SyncedTo: block, + }) + require.NoError(t, err) + + // The parent output must be spent and the child output unspent: only the + // child's own credit should remain in the unspent set. Without the + // parents-first ordering the parent output would be left unspent and both + // outputs would appear in the unspent set. + err = walletdb.View(dbConn, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(wtxmgrNamespaceKey) + require.NotNil(t, ns) + + unspent, err := txStore.UnspentOutputs(ns) + require.NoError(t, err) + + unspentSet := make(map[wire.OutPoint]struct{}, len(unspent)) + for _, credit := range unspent { + unspentSet[credit.OutPoint] = struct{}{} + } + + require.NotContains(t, unspentSet, parentOutPoint) + require.Contains(t, unspentSet, childOutPoint) + + return nil + }) + require.NoError(t, err) +} + +// TestApplyTxBatchPersistsPendingStatus verifies kvdb batch writes round-trip +// pending transactions through the status side bucket. +func TestApplyTxBatchPersistsPendingStatus(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + newAddrmgrNamespace(t, dbConn) + txStore := newTxStore(t, dbConn) + addrStore := &bwmock.AddrStore{} + addrStore.On("ChainParams").Return(&chaincfg.RegressionNetParams).Maybe() + store := NewStore(dbConn, txStore, addrStore) + + txMsg := &wire.MsgTx{Version: 1} + txMsg.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{76}, + }}) + txMsg.AddTxOut(&wire.TxOut{Value: 1_000, PkScript: []byte{0x51}}) + + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{{ + WalletID: 0, + Tx: txMsg, + Received: time.Unix(1710004200, 0), + Status: db.TxStatusPending, + }}, + }) + require.NoError(t, err) + + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: 0, + Txid: txMsg.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, db.TxStatusPending, info.Status) +} + +// TestApplyTxBatchCreditAddrMismatch verifies that the batch credit path +// rejects a credit whose address the output script does not pay, with the same +// ErrInvalidParam the CreateTx path returns. This guards against recording a +// UTXO owned by an unrelated address. +func TestApplyTxBatchCreditAddrMismatch(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + newAddrmgrNamespace(t, dbConn) + txStore := newTxStore(t, dbConn) + + // The output pays to one address, but the credit claims otherAddr, + // which the output script does not contain. + _, script := newTestAddressScript(t) + otherAddr, _ := newTestAddressScript(t) + + // Register no Address/MarkUsed expectations: validation must reject the + // mismatch before the address manager is consulted. + addrStore := &bwmock.AddrStore{} + addrStore.On("ChainParams").Return(&chaincfg.RegressionNetParams) + store := NewStore(dbConn, txStore, addrStore) + + txMsg := &wire.MsgTx{Version: 1} + txMsg.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{67}, + }}) + txMsg.AddTxOut(&wire.TxOut{Value: 7_000, PkScript: script}) + + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{{ + WalletID: 0, + Tx: txMsg, + Received: time.Unix(1710003600, 0), + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: otherAddr}, + }}, + }) + require.ErrorIs(t, err, db.ErrInvalidParam) + + // The mismatch must be caught before any address-manager access. + addrStore.AssertNotCalled(t, "Address", mock.Anything, mock.Anything) + addrStore.AssertNotCalled(t, "MarkUsed", mock.Anything, mock.Anything) +} + +// TestApplyTxBatchCreditNilFallback verifies that a nil credit address in the +// batch path records the credit via the output's own script, matching the +// CreateTx fallback and the SQL backends. The nil path must not consult the +// address manager (no Address/MarkUsed lookup) and must not panic. +func TestApplyTxBatchCreditNilFallback(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + newAddrmgrNamespace(t, dbConn) + txStore := newTxStore(t, dbConn) + + _, script := newTestAddressScript(t) + + // A non-nil addrStore is required because the batch is non-empty, but + // the nil-credit path must only read ChainParams and never resolve or + // mark an address. Register no Address/MarkUsed expectations so a + // regression that consults the address manager surfaces here. + addrStore := &bwmock.AddrStore{} + addrStore.On("ChainParams").Return(&chaincfg.RegressionNetParams) + store := NewStore(dbConn, txStore, addrStore) + + txMsg := &wire.MsgTx{Version: 1} + txMsg.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{68}, + }}) + txMsg.AddTxOut(&wire.TxOut{Value: 4_000, PkScript: script}) + + err := store.ApplyTxBatch(t.Context(), db.TxBatchParams{ + WalletID: 0, + Transactions: []db.CreateTxParams{{ + WalletID: 0, + Tx: txMsg, + Received: time.Unix(1710003700, 0), + Status: db.TxStatusPublished, + Credits: map[uint32]address.Address{0: nil}, + }}, + }) + require.NoError(t, err) + + // The credit is recorded from the output index alone, with change + // cleared because there is no resolved derivation branch. + txid := txMsg.TxHash() + err = walletdb.View(dbConn, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(wtxmgrNamespaceKey) + require.NotNil(t, ns) + + details, err := txStore.TxDetails(ns, &txid) + require.NoError(t, err) + require.NotNil(t, details) + require.Len(t, details.Credits, 1) + require.Equal(t, uint32(0), details.Credits[0].Index) + require.False(t, details.Credits[0].Change) + + return nil + }) + require.NoError(t, err) + + // The address manager must not have been consulted for a nil credit. + addrStore.AssertNotCalled(t, "Address", mock.Anything, mock.Anything) + addrStore.AssertNotCalled(t, "MarkUsed", mock.Anything, mock.Anything) +} diff --git a/wallet/internal/db/kvdb/utxostore.go b/wallet/internal/db/kvdb/utxostore.go index 06e28ab287..d4815c5b69 100644 --- a/wallet/internal/db/kvdb/utxostore.go +++ b/wallet/internal/db/kvdb/utxostore.go @@ -186,6 +186,42 @@ func (s *Store) ListUTXOs(_ context.Context, return utxos, nil } +// ListOutputsToWatch lists outputs that recovery scans should monitor through +// the legacy wtxmgr query path. +func (s *Store) ListOutputsToWatch(_ context.Context, + _ uint32) ([]db.UtxoInfo, error) { + + var utxos []db.UtxoInfo + + err := walletdb.View(s.db, func(tx walletdb.ReadTx) error { + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + if txmgrNs == nil { + return errMissingTxmgrNamespace + } + + credits, err := s.txStore.OutputsToWatch(txmgrNs) + if err != nil { + return fmt.Errorf("outputs to watch: %w", err) + } + + utxos = make([]db.UtxoInfo, len(credits)) + for i := range credits { + utxos[i] = *utxoInfoBase(&credits[i]) + } + + return nil + }) + if err != nil { + return nil, fmt.Errorf("kvdb.Store.ListOutputsToWatch: %w", err) + } + + if len(utxos) == 0 { + return []db.UtxoInfo{}, nil + } + + return utxos, nil +} + // LeaseOutput locks one known wallet UTXO through the legacy wtxmgr lease // path. func (s *Store) LeaseOutput(_ context.Context, diff --git a/wallet/internal/db/kvdb/utxostore_test.go b/wallet/internal/db/kvdb/utxostore_test.go index e1e13283ab..5efb64e6fe 100644 --- a/wallet/internal/db/kvdb/utxostore_test.go +++ b/wallet/internal/db/kvdb/utxostore_test.go @@ -313,6 +313,37 @@ func TestDeleteExpiredLeases(t *testing.T) { require.NoError(t, err) } +// TestListOutputsToWatchReturnsLockedCredit verifies that recovery watch lists +// include credits even when they are currently leased. +func TestListOutputsToWatchReturnsLockedCredit(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + txStore := newTxStore(t, dbConn) + store := NewStore(dbConn, txStore, nil) + + pkScript := []byte{0x51, 0x21} + outPoint := insertKnownCredit(t, dbConn, txStore, pkScript, 3000, 4) + + err := walletdb.Update(dbConn, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) + _, err := txStore.LockOutput( + ns, wtxmgr.LockID{3}, outPoint, time.Hour, + ) + + return err + }) + require.NoError(t, err) + + utxos, err := store.ListOutputsToWatch(t.Context(), 0) + require.NoError(t, err) + require.Len(t, utxos, 1) + require.Equal(t, outPoint, utxos[0].OutPoint) + require.Equal(t, pkScript, utxos[0].PkScript) +} + // TestGetUtxoSuccess verifies that kvdb.Store adapts one wallet-owned legacy // credit into the db-native UTXO shape. func TestGetUtxoSuccess(t *testing.T) { diff --git a/wallet/internal/db/pg/backend_error_test.go b/wallet/internal/db/pg/backend_error_test.go index e6dbba9a1b..885dc0bec9 100644 --- a/wallet/internal/db/pg/backend_error_test.go +++ b/wallet/internal/db/pg/backend_error_test.go @@ -180,7 +180,7 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { ) require.ErrorContains(t, err, "convert input outpoint index 0") - _, err = creditExists(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 = markInputsSpent(t.Context(), nil, db.CreateTxParams{ diff --git a/wallet/internal/db/pg/backend_rows_test.go b/wallet/internal/db/pg/backend_rows_test.go index 7e6c371309..3ebfe1ddee 100644 --- a/wallet/internal/db/pg/backend_rows_test.go +++ b/wallet/internal/db/pg/backend_rows_test.go @@ -154,7 +154,7 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { conflictOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlc.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT ?", int64(5)), + row: newSQLiteRow(t, "SELECT ?, ?", int64(5), []byte{1}), queryErr: errDummy, }), }, diff --git a/wallet/internal/db/pg/txstore_batch.go b/wallet/internal/db/pg/txstore_batch.go new file mode 100644 index 0000000000..44ed07a860 --- /dev/null +++ b/wallet/internal/db/pg/txstore_batch.go @@ -0,0 +1,126 @@ +package pg + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" +) + +// ApplyTxBatch atomically records transactions and an optional sync-tip update. +func (s *Store) ApplyTxBatch(ctx context.Context, + params db.TxBatchParams) error { + + // Reject a batch that mixes wallets before opening the write transaction: + // the sync tip is updated for params.WalletID, so a transaction owned by a + // different wallet must not ride along in the same atomic batch. + err := db.ValidateBatchTransactionsWalletID( + params.WalletID, params.Transactions, + ) + if err != nil { + return err + } + + // Reject a nil-Tx member before SortTxBatchParentsFirst dereferences each + // transaction below; the per-tx NewCreateTxRequest check in + // applyBatchTransaction runs only after the sort. + err = db.ValidateBatchTransactionsTx(params.Transactions) + if err != nil { + return err + } + + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { + // CreateTxWithOps needs each confirming block row during + // PrepareBlock, so materialize all batch transaction blocks before + // any transaction is created. + err := ensureBatchTxBlocks(ctx, qtx, params.Transactions) + if err != nil { + return err + } + + err = applyBatchSyncTip(ctx, qtx, params) + if err != nil { + return err + } + + // Record any in-batch parent before its children. Each tx claims + // its spent parent inputs by updating the parent credit's UTXO row, + // so a child applied before its in-batch parent would update no row + // and silently drop the spend edge. Sorting parents first makes the + // batch order-independent; an already parents-first or + // dependency-free batch is returned unchanged. + txs := db.SortTxBatchParentsFirst(params.Transactions) + + for i := range txs { + req, err := db.NewCreateTxRequest(txs[i]) + if err != nil { + return fmt.Errorf("validate tx %d: %w", i, err) + } + + err = db.CreateTxWithOps(ctx, req, &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ + qtx: qtx, + }, + }) + if err != nil { + return fmt.Errorf("create tx %d: %w", i, err) + } + } + + return nil + }) +} + +// ensureBatchTxBlocks materializes every confirming block referenced by a batch +// transaction before the transaction rows are created. +func ensureBatchTxBlocks(ctx context.Context, qtx *sqlc.Queries, + txs []db.CreateTxParams) error { + + for i := range txs { + if txs[i].Block == nil { + continue + } + + err := ensureBlockExists(ctx, qtx, txs[i].Block) + if err != nil { + return fmt.Errorf("tx %d block: %w", i, err) + } + } + + return nil +} + +// applyBatchSyncTip applies the optional sync-tip update within a batch. +func applyBatchSyncTip(ctx context.Context, qtx *sqlc.Queries, + params db.TxBatchParams) error { + + if params.SyncedTo == nil { + return nil + } + + err := ensureBlockExists(ctx, qtx, params.SyncedTo) + if err != nil { + return fmt.Errorf("ensure synced block: %w", err) + } + + syncParams, err := buildUpdateSyncParams(db.UpdateWalletParams{ + WalletID: params.WalletID, + SyncedTo: params.SyncedTo, + }) + if err != nil { + return err + } + + 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, db.ErrWalletNotFound) + } + + return nil +} diff --git a/wallet/internal/db/pg/txstore_createtx.go b/wallet/internal/db/pg/txstore_createtx.go index 5c2f8cfc8a..9b791afded 100644 --- a/wallet/internal/db/pg/txstore_createtx.go +++ b/wallet/internal/db/pg/txstore_createtx.go @@ -1,14 +1,17 @@ package pg import ( + "bytes" "context" "database/sql" "errors" "fmt" + "sort" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) @@ -52,9 +55,9 @@ var _ db.CreateTxOps = (*createTxOps)(nil) func (o *createTxOps) LoadExisting(ctx context.Context, req db.CreateTxRequest) (*db.CreateTxExistingTarget, error) { - meta, err := o.qtx.GetTransactionMetaByHash( + row, err := o.qtx.GetTransactionByHash( ctx, - sqlc.GetTransactionMetaByHashParams{ + sqlc.GetTransactionByHashParams{ WalletID: int64(req.Params.WalletID), TxHash: req.TxHash[:], }, @@ -67,16 +70,38 @@ func (o *createTxOps) LoadExisting(ctx context.Context, return nil, fmt.Errorf("get tx metadata: %w", err) } - status, err := db.ParseTxStatus(int64(meta.TxStatus)) + status, err := db.ParseTxStatus(int64(row.TxStatus)) if err != nil { return nil, err } + var ( + blockHeight *uint32 + blockHash *chainhash.Hash + ) + if row.BlockHeight.Valid { + block, err := buildBlock( + row.BlockHeight, row.BlockHash, row.BlockTimestamp, + ) + if err != nil { + return nil, err + } + + height := block.Height + blockHeight = &height + + hash := block.Hash + blockHash = &hash + } + return &db.CreateTxExistingTarget{ - ID: meta.ID, - Status: status, - HasBlock: meta.BlockHeight.Valid, - IsCoinbase: meta.IsCoinbase, + ID: row.ID, + Status: status, + HasBlock: row.BlockHeight.Valid, + BlockHeight: blockHeight, + BlockHash: blockHash, + IsCoinbase: row.IsCoinbase, + Label: row.TxLabel, }, nil } @@ -169,7 +194,7 @@ func collectConflictRootIDs(ctx context.Context, qtx *sqlc.Queries, inputIndex, err) } - spentByTxID, err := qtx.GetUtxoSpendByOutpoint( + spend, err := qtx.GetUtxoSpendByOutpoint( ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(req.Params.WalletID), TxHash: txIn.PreviousOutPoint.Hash[:], @@ -185,11 +210,11 @@ func collectConflictRootIDs(ctx context.Context, qtx *sqlc.Queries, err) } - if !spentByTxID.Valid { + if !spend.SpentByTxID.Valid { continue } - rootIDs[spentByTxID.Int64] = struct{}{} + rootIDs[spend.SpentByTxID.Int64] = struct{}{} } return rootIDs, nil @@ -246,7 +271,7 @@ func (o *createTxOps) Insert(ctx context.Context, func (o *createTxOps) InsertCredits(ctx context.Context, req db.CreateTxRequest, txID int64) error { - return insertCredits(ctx, o.qtx, req.Params, txID) + return insertCredits(ctx, o, req.Params, txID) } // MarkInputsSpent records wallet-owned inputs spent by the transaction. @@ -346,75 +371,491 @@ func creditLookupScript(params db.CreateTxParams, index uint32) ([]byte, return lookupScript, nil } +// existingChildSpend describes one already-stored active transaction input that +// spends an output whose wallet ownership was discovered later. +type existingChildSpend struct { + // id is the stored child transaction row ID. + id int64 + + // hash is the child transaction hash used for descendant discovery. + hash chainhash.Hash + + // confirmed reports whether the child row has confirming block metadata. + confirmed bool + + // inputIndex is the input index that spends prevOut. + inputIndex int + + // prevOut is the parent output spent by the child input. + prevOut wire.OutPoint +} + // insertCredits inserts one wallet-owned UTXO row for each credited output of // the transaction being stored. -func insertCredits(ctx context.Context, qtx *sqlc.Queries, +func insertCredits(ctx context.Context, ops *createTxOps, params db.CreateTxParams, txID int64) error { for index := range params.Credits { - creditExists, err := creditExists( - ctx, qtx, params.WalletID, params.Tx.TxHash(), index, + err := insertCredit(ctx, ops.qtx, params, txID, index) + if err != nil { + return err + } + } + + err := markExistingChildSpends(ctx, ops, params, txID) + if err != nil { + return err + } + + return nil +} + +// insertCredit inserts or validates one wallet-owned UTXO row for a credited +// output. +func insertCredit(ctx context.Context, qtx *sqlc.Queries, + params db.CreateTxParams, txID int64, index uint32) error { + + lookupScript, err := creditLookupScript(params, index) + if err != nil { + return err + } + + creditExists, existingScript, err := creditExists( + ctx, qtx, params.WalletID, params.Tx.TxHash(), index, + ) + if err != nil { + return err + } + + if creditExists { + if !bytes.Equal(existingScript, lookupScript) { + return fmt.Errorf("credit output %d owner mismatch: %w", + index, db.ErrTxAlreadyExists) + } + + return nil + } + + addrRow, err := qtx.GetAddressByScriptPubKey( + ctx, sqlc.GetAddressByScriptPubKeyParams{ + ScriptPubKey: lookupScript, + WalletID: int64(params.WalletID), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("credit output %d: %w", index, + db.ErrAddressNotFound) + } + + return fmt.Errorf("resolve credit address %d: %w", index, err) + } + + outputIndex, err := db.Uint32ToInt32(index) + if err != nil { + return fmt.Errorf("convert credit index %d: %w", index, err) + } + + _, err = qtx.InsertUtxo(ctx, sqlc.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 +} + +// markExistingChildSpends attaches already-stored active child transaction +// inputs to any credited outputs created by params.Tx. +func markExistingChildSpends(ctx context.Context, ops *createTxOps, + params db.CreateTxParams, txID int64) error { + + if len(params.Credits) == 0 { + return nil + } + + qtx := ops.qtx + + rows, err := qtx.ListActiveTransactionRaws(ctx, int64(params.WalletID)) + if err != nil { + return fmt.Errorf("list active txns: %w", err) + } + + parentHash := params.Tx.TxHash() + + childSpends, err := collectExistingChildSpends( + rows, parentHash, params, txID, + ) + if err != nil { + return err + } + + outPoints := make([]wire.OutPoint, 0, len(childSpends)) + for outPoint := range childSpends { + outPoints = append(outPoints, outPoint) + } + + sort.Slice(outPoints, func(i, j int) bool { + return outPoints[i].Index < outPoints[j].Index + }) + + activeSpends := make(map[wire.OutPoint][]existingChildSpend, len(outPoints)) + for _, outPoint := range outPoints { + spends, err := activeExistingChildSpends( + ctx, qtx, params.WalletID, childSpends[outPoint], ) if err != nil { return err } - if creditExists { + activeSpends[outPoint] = spends + } + + scheduledReplacements, err := validateExistingChildSpendGroups( + activeSpends, + ) + if err != nil { + return err + } + + appliedReplacements := make(map[int64]struct{}, len(scheduledReplacements)) + for _, outPoint := range outPoints { + spends := activeSpends[outPoint] + + err = reconcileExistingChildSpends( + ctx, ops, params, spends, scheduledReplacements, + appliedReplacements, + ) + if err != nil { + return err + } + } + + return nil +} + +// validateExistingChildSpendGroups validates every credited-output spend group +// before reconciliation mutates any spend edges. +func validateExistingChildSpendGroups( + groups map[wire.OutPoint][]existingChildSpend) (map[int64]struct{}, error) { + + scheduledReplacements := make(map[int64]struct{}) + for _, spends := range groups { + confirmed, unmined := splitExistingChildSpends(spends) + if len(confirmed) > 1 { + return nil, db.ErrTxInputConflict + } + + if len(confirmed) == 0 { + continue + } + + for _, spend := range unmined { + scheduledReplacements[spend.id] = struct{}{} + } + } + + for _, spends := range groups { + confirmed, unmined := splitExistingChildSpends(spends) + if len(confirmed) != 0 { + continue + } + + unmined = filterExistingChildSpendsByID( + unmined, scheduledReplacements, + ) + if len(unmined) > 1 { + return nil, db.ErrTxInputConflict + } + } + + return scheduledReplacements, nil +} + +// filterExistingChildSpendsByID removes spends whose child transaction ID is in +// the skip set. +func filterExistingChildSpendsByID(spends []existingChildSpend, + skip map[int64]struct{}) []existingChildSpend { + + filtered := spends[:0] + for _, spend := range spends { + if _, ok := skip[spend.id]; ok { continue } - lookupScript, err := creditLookupScript(params, index) + filtered = append(filtered, spend) + } + + return filtered +} + +// collectExistingChildSpends groups active children by the credited parent +// outpoint they spend. +func collectExistingChildSpends(rows []sqlc.ListActiveTransactionRawsRow, + parentHash chainhash.Hash, params db.CreateTxParams, txID int64) ( + map[wire.OutPoint][]existingChildSpend, error) { + + spends := make(map[wire.OutPoint][]existingChildSpend) + for _, row := range rows { + if row.ID == txID { + continue + } + + txHash, err := chainhash.NewHash(row.TxHash) if err != nil { - return err + return nil, fmt.Errorf("active child tx hash %d: %w", row.ID, + err) + } + + childTx, err := deserializeActiveTx(row.ID, row.RawTx) + if err != nil { + return nil, err } - addrRow, err := qtx.GetAddressByScriptPubKey( - ctx, sqlc.GetAddressByScriptPubKeyParams{ - ScriptPubKey: lookupScript, - WalletID: int64(params.WalletID), + addChildInputSpends( + spends, childTx, parentHash, params, row.ID, *txHash, + row.BlockHeight.Valid, + ) + } + + return spends, nil +} + +// activeExistingChildSpends filters one snapshot group to children that still +// belong to the active spend set. +func activeExistingChildSpends(ctx context.Context, qtx *sqlc.Queries, + walletID uint32, spends []existingChildSpend) ([]existingChildSpend, + error) { + + active := make([]existingChildSpend, 0, len(spends)) + for _, spend := range spends { + row, err := qtx.GetTransactionByHash( + ctx, sqlc.GetTransactionByHashParams{ + WalletID: int64(walletID), + TxHash: spend.hash[:], }, ) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("credit output %d: %w", index, - db.ErrAddressNotFound) - } - - return fmt.Errorf("resolve credit address %d: %w", index, err) + return nil, fmt.Errorf("refresh existing child %d: %w", + spend.id, err) } - outputIndex, err := db.Uint32ToInt32(index) + status, err := db.ParseTxStatus(int64(row.TxStatus)) if err != nil { - return fmt.Errorf("convert credit index %d: %w", index, err) + return nil, fmt.Errorf("refresh existing child %d: %w", + spend.id, err) } - _, err = qtx.InsertUtxo(ctx, sqlc.InsertUtxoParams{ - WalletID: int64(params.WalletID), - TxID: txID, - OutputIndex: outputIndex, - Amount: params.Tx.TxOut[index].Value, - AddressID: addrRow.ID, + if !db.IsUnminedStatus(status) { + continue + } + + spend.confirmed = row.BlockHeight.Valid + active = append(active, spend) + } + + return active, nil +} + +// addChildInputSpends appends child inputs that spend credited parent outputs. +func addChildInputSpends(spends map[wire.OutPoint][]existingChildSpend, + childTx *wire.MsgTx, parentHash chainhash.Hash, params db.CreateTxParams, + childTxID int64, childHash chainhash.Hash, confirmed bool) { + + for inputIndex, txIn := range childTx.TxIn { + prevOut := txIn.PreviousOutPoint + if prevOut.Hash != parentHash { + continue + } + + if _, ok := params.Credits[prevOut.Index]; !ok { + continue + } + + spends[prevOut] = append(spends[prevOut], existingChildSpend{ + id: childTxID, + hash: childHash, + confirmed: confirmed, + inputIndex: inputIndex, + prevOut: prevOut, }) + } +} + +// deserializeActiveTx deserializes one active transaction row. +func deserializeActiveTx(txID int64, rawTx []byte) (*wire.MsgTx, error) { + var msgTx wire.MsgTx + + err := msgTx.Deserialize(bytes.NewReader(rawTx)) + if err != nil { + return nil, fmt.Errorf("deserialize active tx %d: %w", txID, err) + } + + return &msgTx, nil +} + +// reconcileExistingChildSpends reconciles all active children that spend one +// newly discovered parent credit before any spend edge is mutated. +func reconcileExistingChildSpends(ctx context.Context, ops *createTxOps, + params db.CreateTxParams, spends []existingChildSpend, + scheduledReplacements, appliedReplacements map[int64]struct{}) error { + + confirmed, unmined := splitExistingChildSpends(spends) + if len(confirmed) > 1 { + return db.ErrTxInputConflict + } + + if len(confirmed) == 0 { + unmined = filterExistingChildSpendsByID( + unmined, scheduledReplacements, + ) + if len(unmined) == 0 { + return nil + } + + if len(unmined) != 1 { + return db.ErrTxInputConflict + } + + return markChildInputSpent(ctx, ops.qtx, params, unmined[0]) + } + + confirmedSpend := confirmed[0] + + unmined = filterExistingChildSpendsByID(unmined, appliedReplacements) + if len(unmined) > 0 { + err := replaceUnminedChildSpends( + ctx, ops, params, confirmedSpend.id, unmined, + ) if err != nil { - return fmt.Errorf("insert credit output %d: %w", index, err) + return err + } + + for _, spend := range unmined { + appliedReplacements[spend.id] = struct{}{} + } + } + + return markChildInputSpent(ctx, ops.qtx, params, confirmedSpend) +} + +// splitExistingChildSpends separates confirmed child spends from unmined child +// spends. +func splitExistingChildSpends(spends []existingChildSpend) ( + []existingChildSpend, []existingChildSpend) { + + confirmed := make([]existingChildSpend, 0, len(spends)) + unmined := make([]existingChildSpend, 0, len(spends)) + + for _, spend := range spends { + if spend.confirmed { + confirmed = append(confirmed, spend) + + continue } + + unmined = append(unmined, spend) + } + + return confirmed, unmined +} + +// replaceUnminedChildSpends marks unmined child spends replaced by a confirmed +// child spend. +func replaceUnminedChildSpends(ctx context.Context, ops *createTxOps, + params db.CreateTxParams, confirmedTxID int64, + unmined []existingChildSpend) error { + + rootIDs := make([]int64, 0, len(unmined)) + rootHashes := make([]chainhash.Hash, 0, len(unmined)) + + for _, spend := range unmined { + rootIDs = append(rootIDs, spend.id) + rootHashes = append(rootHashes, spend.hash) + } + + err := db.ReplaceUnminedTxConflicts( + ctx, int64(params.WalletID), rootIDs, rootHashes, confirmedTxID, + ops, + ) + if err != nil { + return fmt.Errorf("replace existing child spends: %w", err) + } + + return nil +} + +// markChildInputSpent attaches one child input to its credited parent output. +func markChildInputSpent(ctx context.Context, qtx *sqlc.Queries, + params db.CreateTxParams, spend existingChildSpend) error { + + outputIndex, err := db.Uint32ToInt32(spend.prevOut.Index) + if err != nil { + return fmt.Errorf("convert child outpoint index %d: %w", + spend.inputIndex, err) + } + + spentInputIndex, err := db.Int64ToInt32(int64(spend.inputIndex)) + if err != nil { + return fmt.Errorf("convert child input index %d: %w", + spend.inputIndex, err) + } + + rowsAffected, err := qtx.MarkUtxoSpent( + ctx, sqlc.MarkUtxoSpentParams{ + WalletID: int64(params.WalletID), + TxHash: spend.prevOut.Hash[:], + OutputIndex: outputIndex, + SpentByTxID: sql.NullInt64{ + Int64: spend.id, + Valid: true, + }, + SpentInputIndex: sql.NullInt32{ + Int32: spentInputIndex, + Valid: true, + }, + }, + ) + if err != nil { + return fmt.Errorf("mark existing child %d input %d: %w", + spend.id, spend.inputIndex, err) + } + + if rowsAffected != 0 { + return nil + } + + err = ensureSpendConflict( + ctx, qtx, params.WalletID, spend.prevOut.Hash, outputIndex, + spend.id, + ) + if err != nil { + return fmt.Errorf("mark existing child %d input %d: %w", + spend.id, spend.inputIndex, err) } return nil } -// 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. +// 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. When the row +// exists, it also returns the script used to resolve the owner address. func creditExists(ctx context.Context, qtx *sqlc.Queries, - walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { + walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, []byte, + error) { convertedIndex, err := db.Uint32ToInt32(outputIndex) if err != nil { - return false, fmt.Errorf("convert credit index %d: %w", outputIndex, - err) + return false, nil, fmt.Errorf("convert credit index %d: %w", + outputIndex, err) } - _, err = qtx.GetUtxoSpendByOutpoint( + row, err := qtx.GetUtxoSpendByOutpoint( ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], @@ -423,14 +864,14 @@ func creditExists(ctx context.Context, qtx *sqlc.Queries, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return false, nil + return false, nil, nil } - return false, fmt.Errorf("lookup credit output %d: %w", outputIndex, - err) + return false, nil, fmt.Errorf("lookup credit output %d: %w", + outputIndex, err) } - return true, nil + return true, row.ScriptPubKey, nil } // markInputsSpent attaches wallet-owned outpoints spent by the stored @@ -493,7 +934,7 @@ func ensureSpendConflict(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int32, txID int64) error { - spendByTxID, err := qtx.GetUtxoSpendByOutpoint( + spend, err := qtx.GetUtxoSpendByOutpoint( ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], @@ -510,7 +951,7 @@ func ensureSpendConflict(ctx context.Context, qtx *sqlc.Queries, return fmt.Errorf("check spend conflict: %w", err) } - if spendByTxID.Valid && spendByTxID.Int64 != txID { + if spend.SpentByTxID.Valid && spend.SpentByTxID.Int64 != txID { return db.ErrTxInputConflict } diff --git a/wallet/internal/db/pg/utxostore_listoutputstowatch.go b/wallet/internal/db/pg/utxostore_listoutputstowatch.go new file mode 100644 index 0000000000..336fdd17e7 --- /dev/null +++ b/wallet/internal/db/pg/utxostore_listoutputstowatch.go @@ -0,0 +1,51 @@ +package pg + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" +) + +// ListOutputsToWatch returns UTXOs that recovery scans should watch. +// +// The result mirrors the legacy wtxmgr OutputsToWatch contract: it returns +// every known output (unspent, locked, or spent only by an unmined +// transaction) but populates just the OutPoint and PkScript, since those are +// the only fields a rescan consumes. +func (s *Store) ListOutputsToWatch(ctx context.Context, + walletID uint32) ([]db.UtxoInfo, error) { + + var utxos []db.UtxoInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + rows, err := q.ListOutputsToWatch(ctx, int64(walletID)) + if err != nil { + return fmt.Errorf("list outputs to watch: %w", err) + } + + utxos = make([]db.UtxoInfo, len(rows)) + for i, row := range rows { + utxo, err := db.WatchOutputFromRow( + row.TxHash, int64(row.OutputIndex), row.RawTx, + ) + if err != nil { + return err + } + + utxos[i] = *utxo + } + + return nil + }) + if err != nil { + return nil, err + } + + if len(utxos) == 0 { + return []db.UtxoInfo{}, nil + } + + return utxos, nil +} diff --git a/wallet/internal/db/sqlite/backend_rows_test.go b/wallet/internal/db/sqlite/backend_rows_test.go index d53d05ae94..a98956e045 100644 --- a/wallet/internal/db/sqlite/backend_rows_test.go +++ b/wallet/internal/db/sqlite/backend_rows_test.go @@ -66,7 +66,7 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { conflictOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlc.New(rowDBTX{ - row: newRow(t, "SELECT ?", int64(5)), + row: newRow(t, "SELECT ?, ?", int64(5), []byte{1}), queryErr: errDummy, }), }, diff --git a/wallet/internal/db/sqlite/txstore_batch.go b/wallet/internal/db/sqlite/txstore_batch.go new file mode 100644 index 0000000000..cbe3405704 --- /dev/null +++ b/wallet/internal/db/sqlite/txstore_batch.go @@ -0,0 +1,123 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" +) + +// ApplyTxBatch atomically records transactions and an optional sync-tip update. +func (s *Store) ApplyTxBatch(ctx context.Context, + params db.TxBatchParams) error { + + // Reject a batch that mixes wallets before opening the write transaction: + // the sync tip is updated for params.WalletID, so a transaction owned by a + // different wallet must not ride along in the same atomic batch. + err := db.ValidateBatchTransactionsWalletID( + params.WalletID, params.Transactions, + ) + if err != nil { + return err + } + + // Reject a nil-Tx member before SortTxBatchParentsFirst dereferences each + // transaction below; the per-tx NewCreateTxRequest check in + // applyBatchTransaction runs only after the sort. + err = db.ValidateBatchTransactionsTx(params.Transactions) + if err != nil { + return err + } + + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { + // CreateTxWithOps needs each confirming block row during + // PrepareBlock, so materialize all batch transaction blocks before + // any transaction is created. + err := ensureBatchTxBlocks(ctx, qtx, params.Transactions) + if err != nil { + return err + } + + err = applyBatchSyncTip(ctx, qtx, params) + if err != nil { + return err + } + + // Record any in-batch parent before its children. Each tx claims + // its spent parent inputs by updating the parent credit's UTXO row, + // so a child applied before its in-batch parent would update no row + // and silently drop the spend edge. Sorting parents first makes the + // batch order-independent; an already parents-first or + // dependency-free batch is returned unchanged. + txs := db.SortTxBatchParentsFirst(params.Transactions) + + for i := range txs { + req, err := db.NewCreateTxRequest(txs[i]) + if err != nil { + return fmt.Errorf("validate tx %d: %w", i, err) + } + + err = db.CreateTxWithOps(ctx, req, &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ + qtx: qtx, + }, + }) + if err != nil { + return fmt.Errorf("create tx %d: %w", i, err) + } + } + + return nil + }) +} + +// ensureBatchTxBlocks materializes every confirming block referenced by a batch +// transaction before the transaction rows are created. +func ensureBatchTxBlocks(ctx context.Context, qtx *sqlc.Queries, + txs []db.CreateTxParams) error { + + for i := range txs { + if txs[i].Block == nil { + continue + } + + err := ensureBlockExists(ctx, qtx, txs[i].Block) + if err != nil { + return fmt.Errorf("tx %d block: %w", i, err) + } + } + + return nil +} + +// applyBatchSyncTip applies the optional sync-tip update within a batch. +func applyBatchSyncTip(ctx context.Context, qtx *sqlc.Queries, + params db.TxBatchParams) error { + + if params.SyncedTo == nil { + return nil + } + + err := ensureBlockExists(ctx, qtx, params.SyncedTo) + if err != nil { + return fmt.Errorf("ensure synced block: %w", err) + } + + syncParams := buildUpdateSyncParams(db.UpdateWalletParams{ + WalletID: params.WalletID, + SyncedTo: params.SyncedTo, + }) + + 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, db.ErrWalletNotFound) + } + + return nil +} diff --git a/wallet/internal/db/sqlite/txstore_createtx.go b/wallet/internal/db/sqlite/txstore_createtx.go index 2ae288913b..d5994f5ba4 100644 --- a/wallet/internal/db/sqlite/txstore_createtx.go +++ b/wallet/internal/db/sqlite/txstore_createtx.go @@ -1,14 +1,17 @@ package sqlite import ( + "bytes" "context" "database/sql" "errors" "fmt" + "sort" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) @@ -52,9 +55,9 @@ var _ db.CreateTxOps = (*createTxOps)(nil) func (o *createTxOps) LoadExisting(ctx context.Context, req db.CreateTxRequest) (*db.CreateTxExistingTarget, error) { - meta, err := o.qtx.GetTransactionMetaByHash( + row, err := o.qtx.GetTransactionByHash( ctx, - sqlc.GetTransactionMetaByHashParams{ + sqlc.GetTransactionByHashParams{ WalletID: int64(req.Params.WalletID), TxHash: req.TxHash[:], }, @@ -67,16 +70,38 @@ func (o *createTxOps) LoadExisting(ctx context.Context, return nil, fmt.Errorf("get tx metadata: %w", err) } - status, err := db.ParseTxStatus(meta.TxStatus) + status, err := db.ParseTxStatus(row.TxStatus) if err != nil { return nil, err } + var ( + blockHeight *uint32 + blockHash *chainhash.Hash + ) + if row.BlockHeight.Valid { + block, err := buildBlock( + row.BlockHeight, row.BlockHash, row.BlockTimestamp, + ) + if err != nil { + return nil, err + } + + height := block.Height + blockHeight = &height + + hash := block.Hash + blockHash = &hash + } + return &db.CreateTxExistingTarget{ - ID: meta.ID, - Status: status, - HasBlock: meta.BlockHeight.Valid, - IsCoinbase: meta.IsCoinbase, + ID: row.ID, + Status: status, + HasBlock: row.BlockHeight.Valid, + BlockHeight: blockHeight, + BlockHash: blockHash, + IsCoinbase: row.IsCoinbase, + Label: row.TxLabel, }, nil } @@ -164,7 +189,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( + spend, err := qtx.GetUtxoSpendByOutpoint( ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(req.Params.WalletID), TxHash: txIn.PreviousOutPoint.Hash[:], @@ -180,11 +205,11 @@ func collectConflictRootIDs(ctx context.Context, err) } - if !spentByTxID.Valid { + if !spend.SpentByTxID.Valid { continue } - rootIDs[spentByTxID.Int64] = struct{}{} + rootIDs[spend.SpentByTxID.Int64] = struct{}{} } return rootIDs, nil @@ -244,7 +269,7 @@ func (o *createTxOps) Insert(ctx context.Context, func (o *createTxOps) InsertCredits(ctx context.Context, req db.CreateTxRequest, txID int64) error { - return insertCredits(ctx, o.qtx, req.Params, txID) + return insertCredits(ctx, o, req.Params, txID) } // MarkInputsSpent records wallet-owned inputs spent by the transaction. @@ -344,64 +369,470 @@ func creditLookupScript(params db.CreateTxParams, index uint32) ([]byte, return lookupScript, nil } +// existingChildSpend describes one already-stored active transaction input that +// spends an output whose wallet ownership was discovered later. +type existingChildSpend struct { + // id is the stored child transaction row ID. + id int64 + + // hash is the child transaction hash used for descendant discovery. + hash chainhash.Hash + + // confirmed reports whether the child row has confirming block metadata. + confirmed bool + + // inputIndex is the input index that spends prevOut. + inputIndex int + + // prevOut is the parent output spent by the child input. + prevOut wire.OutPoint +} + // insertCredits inserts one wallet-owned UTXO row for each credited // output of the transaction being stored. -func insertCredits(ctx context.Context, qtx *sqlc.Queries, +func insertCredits(ctx context.Context, ops *createTxOps, params db.CreateTxParams, txID int64) error { for index := range params.Credits { - creditExists, err := creditExists( - ctx, qtx, params.WalletID, params.Tx.TxHash(), index, + err := insertCredit(ctx, ops.qtx, params, txID, index) + if err != nil { + return err + } + } + + err := markExistingChildSpends(ctx, ops, params, txID) + if err != nil { + return err + } + + return nil +} + +// insertCredit inserts or validates one wallet-owned UTXO row for a credited +// output. +func insertCredit(ctx context.Context, qtx *sqlc.Queries, + params db.CreateTxParams, txID int64, index uint32) error { + + lookupScript, err := creditLookupScript(params, index) + if err != nil { + return err + } + + creditExists, existingScript, err := creditExists( + ctx, qtx, params.WalletID, params.Tx.TxHash(), index, + ) + if err != nil { + return err + } + + if creditExists { + if !bytes.Equal(existingScript, lookupScript) { + return fmt.Errorf("credit output %d owner mismatch: %w", + index, db.ErrTxAlreadyExists) + } + + return nil + } + + addrRow, err := qtx.GetAddressByScriptPubKey( + ctx, sqlc.GetAddressByScriptPubKeyParams{ + ScriptPubKey: lookupScript, + WalletID: int64(params.WalletID), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("credit output %d: %w", index, + db.ErrAddressNotFound) + } + + return fmt.Errorf("resolve credit address %d: %w", index, err) + } + + _, err = qtx.InsertUtxo(ctx, sqlc.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 +} + +// markExistingChildSpends attaches already-stored active child transaction +// inputs to any credited outputs created by params.Tx. +func markExistingChildSpends(ctx context.Context, ops *createTxOps, + params db.CreateTxParams, txID int64) error { + + if len(params.Credits) == 0 { + return nil + } + + qtx := ops.qtx + + rows, err := qtx.ListActiveTransactionRaws(ctx, int64(params.WalletID)) + if err != nil { + return fmt.Errorf("list active txns: %w", err) + } + + parentHash := params.Tx.TxHash() + + childSpends, err := collectExistingChildSpends( + rows, parentHash, params, txID, + ) + if err != nil { + return err + } + + outPoints := make([]wire.OutPoint, 0, len(childSpends)) + for outPoint := range childSpends { + outPoints = append(outPoints, outPoint) + } + + sort.Slice(outPoints, func(i, j int) bool { + return outPoints[i].Index < outPoints[j].Index + }) + + activeSpends := make(map[wire.OutPoint][]existingChildSpend, len(outPoints)) + for _, outPoint := range outPoints { + spends, err := activeExistingChildSpends( + ctx, qtx, params.WalletID, childSpends[outPoint], + ) + if err != nil { + return err + } + + activeSpends[outPoint] = spends + } + + scheduledReplacements, err := validateExistingChildSpendGroups( + activeSpends, + ) + if err != nil { + return err + } + + appliedReplacements := make(map[int64]struct{}, len(scheduledReplacements)) + for _, outPoint := range outPoints { + spends := activeSpends[outPoint] + + err = reconcileExistingChildSpends( + ctx, ops, params, spends, scheduledReplacements, + appliedReplacements, ) if err != nil { return err } + } + + return nil +} + +// validateExistingChildSpendGroups validates every credited-output spend group +// before reconciliation mutates any spend edges. +func validateExistingChildSpendGroups( + groups map[wire.OutPoint][]existingChildSpend) (map[int64]struct{}, error) { + + scheduledReplacements := make(map[int64]struct{}) + for _, spends := range groups { + confirmed, unmined := splitExistingChildSpends(spends) + if len(confirmed) > 1 { + return nil, db.ErrTxInputConflict + } + + if len(confirmed) == 0 { + continue + } + + for _, spend := range unmined { + scheduledReplacements[spend.id] = struct{}{} + } + } + + for _, spends := range groups { + confirmed, unmined := splitExistingChildSpends(spends) + if len(confirmed) != 0 { + continue + } + + unmined = filterExistingChildSpendsByID( + unmined, scheduledReplacements, + ) + if len(unmined) > 1 { + return nil, db.ErrTxInputConflict + } + } + + return scheduledReplacements, nil +} + +// filterExistingChildSpendsByID removes spends whose child transaction ID is in +// the skip set. +func filterExistingChildSpendsByID(spends []existingChildSpend, + skip map[int64]struct{}) []existingChildSpend { + + filtered := spends[:0] + for _, spend := range spends { + if _, ok := skip[spend.id]; ok { + continue + } + + filtered = append(filtered, spend) + } - if creditExists { + return filtered +} + +// collectExistingChildSpends groups active children by the credited parent +// outpoint they spend. +func collectExistingChildSpends(rows []sqlc.ListActiveTransactionRawsRow, + parentHash chainhash.Hash, params db.CreateTxParams, txID int64) ( + map[wire.OutPoint][]existingChildSpend, error) { + + spends := make(map[wire.OutPoint][]existingChildSpend) + for _, row := range rows { + if row.ID == txID { continue } - lookupScript, err := creditLookupScript(params, index) + txHash, err := chainhash.NewHash(row.TxHash) if err != nil { - return err + return nil, fmt.Errorf("active child tx hash %d: %w", row.ID, + err) } - addrRow, err := qtx.GetAddressByScriptPubKey( - ctx, sqlc.GetAddressByScriptPubKeyParams{ - ScriptPubKey: lookupScript, - WalletID: int64(params.WalletID), + childTx, err := deserializeActiveTx(row.ID, row.RawTx) + if err != nil { + return nil, err + } + + addChildInputSpends( + spends, childTx, parentHash, params, row.ID, *txHash, + row.BlockHeight.Valid, + ) + } + + return spends, nil +} + +// activeExistingChildSpends filters one snapshot group to children that still +// belong to the active spend set. +func activeExistingChildSpends(ctx context.Context, qtx *sqlc.Queries, + walletID uint32, spends []existingChildSpend) ([]existingChildSpend, + error) { + + active := make([]existingChildSpend, 0, len(spends)) + for _, spend := range spends { + row, err := qtx.GetTransactionByHash( + ctx, sqlc.GetTransactionByHashParams{ + WalletID: int64(walletID), + TxHash: spend.hash[:], }, ) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("credit output %d: %w", index, - db.ErrAddressNotFound) - } + return nil, fmt.Errorf("refresh existing child %d: %w", + spend.id, err) + } - return fmt.Errorf("resolve credit address %d: %w", index, err) + status, err := db.ParseTxStatus(row.TxStatus) + if err != nil { + return nil, fmt.Errorf("refresh existing child %d: %w", + spend.id, err) } - _, err = qtx.InsertUtxo(ctx, sqlc.InsertUtxoParams{ - WalletID: int64(params.WalletID), - TxID: txID, - OutputIndex: int64(index), - Amount: params.Tx.TxOut[index].Value, - AddressID: addrRow.ID, + if !db.IsUnminedStatus(status) { + continue + } + + spend.confirmed = row.BlockHeight.Valid + active = append(active, spend) + } + + return active, nil +} + +// addChildInputSpends appends child inputs that spend credited parent outputs. +func addChildInputSpends(spends map[wire.OutPoint][]existingChildSpend, + childTx *wire.MsgTx, parentHash chainhash.Hash, params db.CreateTxParams, + childTxID int64, childHash chainhash.Hash, confirmed bool) { + + for inputIndex, txIn := range childTx.TxIn { + prevOut := txIn.PreviousOutPoint + if prevOut.Hash != parentHash { + continue + } + + if _, ok := params.Credits[prevOut.Index]; !ok { + continue + } + + spends[prevOut] = append(spends[prevOut], existingChildSpend{ + id: childTxID, + hash: childHash, + confirmed: confirmed, + inputIndex: inputIndex, + prevOut: prevOut, }) + } +} + +// deserializeActiveTx deserializes one active transaction row. +func deserializeActiveTx(txID int64, rawTx []byte) (*wire.MsgTx, error) { + var msgTx wire.MsgTx + + err := msgTx.Deserialize(bytes.NewReader(rawTx)) + if err != nil { + return nil, fmt.Errorf("deserialize active tx %d: %w", txID, err) + } + + return &msgTx, nil +} + +// reconcileExistingChildSpends reconciles all active children that spend one +// newly discovered parent credit before any spend edge is mutated. +func reconcileExistingChildSpends(ctx context.Context, ops *createTxOps, + params db.CreateTxParams, spends []existingChildSpend, + scheduledReplacements, appliedReplacements map[int64]struct{}) error { + + confirmed, unmined := splitExistingChildSpends(spends) + if len(confirmed) > 1 { + return db.ErrTxInputConflict + } + + if len(confirmed) == 0 { + unmined = filterExistingChildSpendsByID( + unmined, scheduledReplacements, + ) + if len(unmined) == 0 { + return nil + } + + if len(unmined) != 1 { + return db.ErrTxInputConflict + } + + return markChildInputSpent(ctx, ops.qtx, params, unmined[0]) + } + + confirmedSpend := confirmed[0] + + unmined = filterExistingChildSpendsByID(unmined, appliedReplacements) + if len(unmined) > 0 { + err := replaceUnminedChildSpends( + ctx, ops, params, confirmedSpend.id, unmined, + ) if err != nil { - return fmt.Errorf("insert credit output %d: %w", index, err) + return err } + + for _, spend := range unmined { + appliedReplacements[spend.id] = struct{}{} + } + } + + return markChildInputSpent(ctx, ops.qtx, params, confirmedSpend) +} + +// splitExistingChildSpends separates confirmed child spends from unmined child +// spends. +func splitExistingChildSpends(spends []existingChildSpend) ( + []existingChildSpend, []existingChildSpend) { + + confirmed := make([]existingChildSpend, 0, len(spends)) + unmined := make([]existingChildSpend, 0, len(spends)) + + for _, spend := range spends { + if spend.confirmed { + confirmed = append(confirmed, spend) + + continue + } + + unmined = append(unmined, spend) + } + + return confirmed, unmined +} + +// replaceUnminedChildSpends marks unmined child spends replaced by a confirmed +// child spend. +func replaceUnminedChildSpends(ctx context.Context, ops *createTxOps, + params db.CreateTxParams, confirmedTxID int64, + unmined []existingChildSpend) error { + + rootIDs := make([]int64, 0, len(unmined)) + rootHashes := make([]chainhash.Hash, 0, len(unmined)) + + for _, spend := range unmined { + rootIDs = append(rootIDs, spend.id) + rootHashes = append(rootHashes, spend.hash) + } + + err := db.ReplaceUnminedTxConflicts( + ctx, int64(params.WalletID), rootIDs, rootHashes, confirmedTxID, + ops, + ) + if err != nil { + return fmt.Errorf("replace existing child spends: %w", err) } return nil } -// 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. +// markChildInputSpent attaches one child input to its credited parent output. +func markChildInputSpent(ctx context.Context, qtx *sqlc.Queries, + params db.CreateTxParams, spend existingChildSpend) error { + + spentInputIndex := sql.NullInt64{ + Int64: int64(spend.inputIndex), + Valid: true, + } + + rowsAffected, err := qtx.MarkUtxoSpent( + ctx, sqlc.MarkUtxoSpentParams{ + WalletID: int64(params.WalletID), + TxHash: spend.prevOut.Hash[:], + OutputIndex: int64(spend.prevOut.Index), + SpentByTxID: sql.NullInt64{ + Int64: spend.id, + Valid: true, + }, + SpentInputIndex: spentInputIndex, + }, + ) + if err != nil { + return fmt.Errorf("mark existing child %d input %d: %w", + spend.id, spend.inputIndex, err) + } + + if rowsAffected != 0 { + return nil + } + + err = ensureSpendConflict( + ctx, qtx, params.WalletID, spend.prevOut.Hash, + int64(spend.prevOut.Index), spend.id, + ) + if err != nil { + return fmt.Errorf("mark existing child %d input %d: %w", + spend.id, spend.inputIndex, err) + } + + return nil +} + +// 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. When the row +// exists, it also returns the script used to resolve the owner address. func creditExists(ctx context.Context, qtx *sqlc.Queries, - walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { + walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, []byte, + error) { - _, err := qtx.GetUtxoSpendByOutpoint( + row, err := qtx.GetUtxoSpendByOutpoint( ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], @@ -410,14 +841,14 @@ func creditExists(ctx context.Context, qtx *sqlc.Queries, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return false, nil + return false, nil, nil } - return false, fmt.Errorf("lookup credit output %d: %w", outputIndex, - err) + return false, nil, fmt.Errorf("lookup credit output %d: %w", + outputIndex, err) } - return true, nil + return true, row.ScriptPubKey, nil } // markInputsSpent attaches wallet-owned outpoints spent by the stored @@ -472,7 +903,7 @@ func ensureSpendConflict(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int64, txID int64) error { - spendByTxID, err := qtx.GetUtxoSpendByOutpoint( + spend, err := qtx.GetUtxoSpendByOutpoint( ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], @@ -489,7 +920,7 @@ func ensureSpendConflict(ctx context.Context, return fmt.Errorf("check spend conflict: %w", err) } - if spendByTxID.Valid && spendByTxID.Int64 != txID { + if spend.SpentByTxID.Valid && spend.SpentByTxID.Int64 != txID { return db.ErrTxInputConflict } diff --git a/wallet/internal/db/sqlite/utxostore_listoutputstowatch.go b/wallet/internal/db/sqlite/utxostore_listoutputstowatch.go new file mode 100644 index 0000000000..2342ef93c8 --- /dev/null +++ b/wallet/internal/db/sqlite/utxostore_listoutputstowatch.go @@ -0,0 +1,51 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" +) + +// ListOutputsToWatch returns UTXOs that recovery scans should watch. +// +// The result mirrors the legacy wtxmgr OutputsToWatch contract: it returns +// every known output (unspent, locked, or spent only by an unmined +// transaction) but populates just the OutPoint and PkScript, since those are +// the only fields a rescan consumes. +func (s *Store) ListOutputsToWatch(ctx context.Context, + walletID uint32) ([]db.UtxoInfo, error) { + + var utxos []db.UtxoInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + rows, err := q.ListOutputsToWatch(ctx, int64(walletID)) + if err != nil { + return fmt.Errorf("list outputs to watch: %w", err) + } + + utxos = make([]db.UtxoInfo, len(rows)) + for i, row := range rows { + utxo, err := db.WatchOutputFromRow( + row.TxHash, row.OutputIndex, row.RawTx, + ) + if err != nil { + return err + } + + utxos[i] = *utxo + } + + return nil + }) + if err != nil { + return nil, err + } + + if len(utxos) == 0 { + return []db.UtxoInfo{}, nil + } + + return utxos, nil +} diff --git a/wallet/internal/db/txstore_common.go b/wallet/internal/db/txstore_common.go index 50b99c5a9f..1e453cdae1 100644 --- a/wallet/internal/db/txstore_common.go +++ b/wallet/internal/db/txstore_common.go @@ -780,8 +780,17 @@ type CreateTxExistingTarget struct { // HasBlock reports whether the row already has confirming block metadata. HasBlock bool + // BlockHeight is the confirmed block height when HasBlock is true. + BlockHeight *uint32 + + // BlockHash is the confirmed block hash when HasBlock is true. + BlockHash *chainhash.Hash + // IsCoinbase reports whether the row records coinbase history. IsCoinbase bool + + // Label is the stored user-visible transaction label. + Label string } // ErrCreateTxExistingNotFound reports that CreateTx found no existing row. @@ -898,6 +907,72 @@ func checkReuseCreateTx(req CreateTxRequest, return true } +// createTxBlockMatches reports whether a stored row carries the same block +// assignment as the incoming CreateTx observation. +func createTxBlockMatches(req CreateTxRequest, + existing CreateTxExistingTarget) bool { + + if req.Params.Block == nil { + return !existing.HasBlock + } + + if !existing.HasBlock || existing.BlockHeight == nil || + existing.BlockHash == nil { + + return false + } + + return *existing.BlockHeight == req.Params.Block.Height && + *existing.BlockHash == req.Params.Block.Hash +} + +// checkIdempotentCreateTx reports whether the incoming CreateTx observation is +// already fully reflected by the stored row. +func checkIdempotentCreateTx(req CreateTxRequest, + existing CreateTxExistingTarget) bool { + + if req.Params.Status != existing.Status { + return false + } + + if req.IsCoinbase != existing.IsCoinbase { + return false + } + + if req.Params.Label != existing.Label { + return false + } + + return createTxBlockMatches(req, existing) +} + +// validateCreateTxCreditRequests validates every caller-supplied credit before +// an existing tx row is treated as an idempotent duplicate. +func validateCreateTxCreditRequests(req CreateTxRequest) error { + for index, addr := range req.Params.Credits { + if addr == nil { + continue + } + + err := ValidateCreditAddrMembership( + addr, req.Params.Tx.TxOut[index].PkScript, + ) + if err != nil { + return fmt.Errorf("credit output %d: %w", index, err) + } + } + + return nil +} + +// canIgnoreUnminedConfirmedDuplicate reports whether an unmined observation may +// be ignored because the same transaction is already recorded as confirmed. +func canIgnoreUnminedConfirmedDuplicate(req CreateTxRequest, + existing CreateTxExistingTarget) bool { + + return req.Params.Block == nil && existing.HasBlock +} + // 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, @@ -1047,6 +1122,48 @@ func handleTxConflicts(ctx context.Context, req CreateTxRequest, return nil } +// ReplaceUnminedTxConflicts rewrites direct unmined conflict roots displaced by +// the provided replacement transaction. +// +// Callers use this when the direct conflict roots were discovered outside the +// normal spend-edge lookup path, such as when a parent credit is learned after +// its children have already been stored as external-input spends. The function +// preserves the standard replacement ordering: discover descendants from a +// stable graph snapshot, mark direct roots replaced, then mark dependent +// descendants failed. +func ReplaceUnminedTxConflicts(ctx context.Context, walletID int64, + rootIDs []int64, rootHashes []chainhash.Hash, replacementTxID int64, + ops CreateTxOps) error { + + if len(rootIDs) == 0 { + return nil + } + + if len(rootIDs) != len(rootHashes) { + return fmt.Errorf("%w: conflict roots and hashes differ", + ErrInvalidParam) + } + + descendantIDs, err := collectConflictDescendants( + ctx, walletID, rootHashes, rootIDs, ops, + ) + if err != nil { + return err + } + + err = handleRootTxns(ctx, walletID, rootIDs, replacementTxID, ops) + if err != nil { + return err + } + + err = handleTxDescendants(ctx, walletID, descendantIDs, ops) + if err != nil { + return err + } + + return nil +} + // insertCreateTx completes the fresh-Insert CreateTx path. // // The order is important: @@ -1075,18 +1192,30 @@ func insertCreateTx(ctx context.Context, req CreateTxRequest, 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) + err = recordCreateTxEdges(ctx, req, txID, ops) + if err != nil { + return err + } + + return nil +} + +// recordCreateTxEdges records wallet-owned credits and spent-input edges for a +// transaction row that already exists in the backend. +func recordCreateTxEdges(ctx context.Context, req CreateTxRequest, txID int64, + ops CreateTxOps) error { + + // Credits only describe outputs created by the 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. + // Claim wallet-owned parent inputs last. This is the write that makes this + // tx the recorded spender of the shared parents, so doing it earlier would + // hide a displaced unmined branch from the replacement walk. err = ops.MarkInputsSpent(ctx, req, txID) if err != nil { return fmt.Errorf("create tx spends: %w", err) @@ -1095,6 +1224,66 @@ func insertCreateTx(ctx context.Context, req CreateTxRequest, return nil } +// handleExistingCreateTx handles a CreateTx request for a transaction hash that +// already has a wallet-scoped row. +func handleExistingCreateTx(ctx context.Context, req CreateTxRequest, + existing CreateTxExistingTarget, ops CreateTxOps) error { + + if canIgnoreUnminedConfirmedDuplicate(req, existing) { + return nil + } + + if checkIdempotentCreateTx(req, existing) { + return replayIdempotentCreateTx(ctx, req, existing, ops) + } + + 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) + } + + err = handleTxConflicts(ctx, req, existing.ID, ops) + if err != nil { + return err + } + + err = recordCreateTxEdges(ctx, req, existing.ID, ops) + if err != nil { + return fmt.Errorf("replay confirmed tx edges: %w", err) + } + + return nil +} + +// replayIdempotentCreateTx validates and records any edge writes that an +// idempotent duplicate transaction notification may still need. +func replayIdempotentCreateTx(ctx context.Context, req CreateTxRequest, + existing CreateTxExistingTarget, ops CreateTxOps) error { + + err := validateCreateTxCreditRequests(req) + if err != nil { + return err + } + + if req.Params.Block != nil { + err = ops.PrepareBlock(ctx, req) + if err != nil { + return fmt.Errorf("prepare duplicate block assignment: %w", err) + } + } + + err = recordCreateTxEdges(ctx, req, existing.ID, ops) + if err != nil { + return fmt.Errorf("replay duplicate tx edges: %w", err) + } + + return nil +} + // CreateTxWithOps runs the backend-independent CreateTx orchestration once the // caller has opened a backend-specific SQL transaction. // @@ -1110,25 +1299,16 @@ func CreateTxWithOps(ctx context.Context, req CreateTxRequest, return err } - if foundExisting { - if !checkReuseCreateTx(req, *existing) { - return fmt.Errorf("tx %s: %w", req.TxHash, ErrTxAlreadyExists) - } - - err = ops.ConfirmExisting(ctx, req, *existing) + if !foundExisting { + err = ops.PrepareBlock(ctx, req) if err != nil { - return fmt.Errorf("confirm existing tx: %w", err) + return fmt.Errorf("prepare create block assignment: %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) } - return insertCreateTx(ctx, req, ops) + return handleExistingCreateTx(ctx, req, *existing, ops) } // validateUpdateTxParams checks that UpdateTx received at least one mutable @@ -1635,3 +1815,200 @@ func IsUnminedStatus(status TxStatus) bool { return false } } + +// ValidateBatchTransactionsWalletID rejects a batch whose transactions are not +// all owned by batchWalletID. A batch applies sync state (sync tip or scan +// horizons) to batchWalletID but records each transaction under that +// transaction's own WalletID, so a mismatched member would let one wallet's +// sync state advance atomically with another wallet's transaction write. The +// check runs before any backend write so the batch invariant is enforced +// up front, before horizon derivation or synced-block work is wasted. +func ValidateBatchTransactionsWalletID(batchWalletID uint32, + txs []CreateTxParams) error { + + for i := range txs { + if txs[i].WalletID == batchWalletID { + continue + } + + return fmt.Errorf("%w: transaction %d wallet id %d does not "+ + "match batch wallet id %d", ErrInvalidParam, i, + txs[i].WalletID, batchWalletID) + } + + return nil +} + +// ValidateBatchTransactionsTx rejects a batch in which any transaction has a +// nil Tx. A batch is reordered parents-first by SortTxBatchParentsFirst before +// it is applied, and that sort dereferences each transaction's Tx to read its +// hash and inputs. Per-transaction NewCreateTxRequest validation would catch a +// nil Tx, but only inside the apply loop that runs after the sort, so a nil Tx +// would panic during reordering. Running this check up front, before the sort, +// turns that into the same ErrInvalidParam every backend returns uniformly. +func ValidateBatchTransactionsTx(txs []CreateTxParams) error { + for i := range txs { + if txs[i].Tx != nil { + continue + } + + return fmt.Errorf("%w: transaction %d tx is required", + ErrInvalidParam, i) + } + + return nil +} + +// SortTxBatchParentsFirst returns the batch transactions reordered so that any +// transaction creating an output another batch member spends is positioned +// before that spending member, regardless of the caller's original order. +// +// The SQL backends record a transaction's wallet-owned credits and then claim +// its spent parent inputs in the same per-transaction step. Claiming a spent +// input is an UPDATE on the parent credit's UTXO row, so the parent credit must +// already exist when the child is recorded; otherwise the UPDATE matches no row +// and, finding no conflicting spend either, silently drops the spend edge and +// leaves the parent credit unspent. Applying a batch in caller order is +// therefore unsafe whenever a child is listed before its in-batch parent. +// +// This makes ApplyTxBatch order-independent: it stably topologically sorts the +// batch so every in-batch parent precedes its children while preserving the +// caller's relative order among transactions that have no in-batch dependency. +// A batch that is already parents-first, has a single transaction, or has no +// in-batch parent/child edges is returned unchanged. Edges to outpoints created +// outside the batch impose no ordering, since those parent rows either already +// exist or never will. +// +// The returned slice is a fresh reordering of the same CreateTxParams values; +// the input slice is not mutated. +func SortTxBatchParentsFirst(txs []CreateTxParams) []CreateTxParams { + // Nothing to reorder: a single transaction (or none) cannot list a + // child ahead of an in-batch parent. + if len(txs) <= 1 { + return txs + } + + children, inDegree := buildTxBatchDependencyGraph(txs) + order := topoSortTxBatchOrder(children, inDegree) + + ordered := make([]CreateTxParams, 0, len(txs)) + for _, idx := range order { + ordered = append(ordered, txs[idx]) + } + + return ordered +} + +// buildTxBatchDependencyGraph builds the parent->child dependency edges of a +// transaction batch. children[p] lists the positions of the batch transactions +// spending an output of txs[p], and inDegree[c] counts the distinct in-batch +// parents of txs[c]. Outpoints created outside the batch contribute no edge. +func buildTxBatchDependencyGraph(txs []CreateTxParams) ([][]int, []int) { + // Map every in-batch transaction hash to its position so input lookups + // can tell an in-batch parent from an external outpoint. A hash that + // repeats in the batch keeps its first position; a later duplicate is + // rejected by CreateTxWithOps regardless of order, so the dependency + // edge only needs to point at one occurrence. + indexByHash := make(map[chainhash.Hash]int, len(txs)) + for i := range txs { + hash := txs[i].Tx.TxHash() + if _, ok := indexByHash[hash]; !ok { + indexByHash[hash] = i + } + } + + children := make([][]int, len(txs)) + + inDegree := make([]int, len(txs)) + for child := range txs { + // Deduplicate parents within one child so a child spending two + // outputs of the same in-batch parent only adds one edge, which + // keeps the in-degree bookkeeping exact. + seen := make(map[int]struct{}) + for _, txIn := range txs[child].Tx.TxIn { + parent, ok := indexByHash[txIn.PreviousOutPoint.Hash] + if !ok || parent == child { + continue + } + + if _, dup := seen[parent]; dup { + continue + } + + seen[parent] = struct{}{} + children[parent] = append(children[parent], child) + inDegree[child]++ + } + } + + return children, inDegree +} + +// topoSortTxBatchOrder returns batch transaction positions in a stable +// parents-first topological order using Kahn's algorithm with an +// ascending-index ready set: among transactions whose in-batch parents are all +// already emitted, the one with the lowest original position goes next. That +// keeps the caller's relative order for independent transactions and leaves an +// already parents-first batch unchanged. +// +// A dependency cycle is impossible for real transactions, since an output +// cannot be spent before the transaction creating it exists. If a malformed +// batch still encodes one, the cyclic members never reach the ready set; they +// are appended in their original order so no transaction is dropped and +// CreateTxWithOps can reject the bad input downstream. +func topoSortTxBatchOrder(children [][]int, inDegree []int) []int { + total := len(inDegree) + + ready := make([]int, 0, total) + for i := range inDegree { + if inDegree[i] == 0 { + ready = append(ready, i) + } + } + + order := make([]int, 0, total) + + emitted := make([]bool, total) + for len(ready) > 0 { + node := popLowestIndex(&ready) + order = append(order, node) + emitted[node] = true + + for _, child := range children[node] { + inDegree[child]-- + if inDegree[child] == 0 { + ready = append(ready, child) + } + } + } + + // Append any cyclic leftover in original order. + if len(order) != total { + for i := range total { + if !emitted[i] { + order = append(order, i) + } + } + } + + return order +} + +// popLowestIndex removes and returns the smallest value from ready, preserving +// the order of the remaining elements. +func popLowestIndex(ready *[]int) int { + r := *ready + + lowest := 0 + for i := 1; i < len(r); i++ { + if r[i] < r[lowest] { + lowest = i + } + } + + node := r[lowest] + r = append(r[:lowest], r[lowest+1:]...) + *ready = r + + return node +} diff --git a/wallet/internal/db/txstore_common_test.go b/wallet/internal/db/txstore_common_test.go index 3ee4266b45..863f0c6cc6 100644 --- a/wallet/internal/db/txstore_common_test.go +++ b/wallet/internal/db/txstore_common_test.go @@ -762,12 +762,161 @@ func TestCreateTxWithOpsDuplicate(t *testing.T) { }) ops.On("LoadExisting", mock.Anything, req).Return( - &CreateTxExistingTarget{ID: 4}, nil).Once() + &CreateTxExistingTarget{ + ID: 4, + Status: TxStatusPublished, + }, nil, + ).Once() err := CreateTxWithOps(context.Background(), req, ops) require.ErrorIs(t, err, ErrTxAlreadyExists) } +// TestCreateTxWithOpsIdempotentDuplicate verifies that the shared CreateTx +// helper replays edge writes for an exact duplicate observation. +func TestCreateTxWithOpsIdempotentDuplicate(t *testing.T) { + t.Parallel() + + req := testCreateTxRequest(t) + existing := CreateTxExistingTarget{ + ID: 4, + Status: req.Params.Status, + IsCoinbase: req.IsCoinbase, + Label: req.Params.Label, + } + + ops := &mockCreateTxOps{} + t.Cleanup(func() { + ops.AssertExpectations(t) + }) + + ops.On("LoadExisting", mock.Anything, req).Return(&existing, nil).Once() + ops.On("InsertCredits", mock.Anything, req, int64(4)).Return(nil).Once() + ops.On("MarkInputsSpent", mock.Anything, req, int64(4)).Return(nil).Once() + + err := CreateTxWithOps(context.Background(), req, ops) + require.NoError(t, err) +} + +// TestCreateTxWithOpsValidatesIdempotentDuplicateCredits verifies that the +// duplicate replay path validates requested credit addresses before it treats a +// matching tx row as idempotent. +func TestCreateTxWithOpsValidatesIdempotentDuplicateCredits(t *testing.T) { + t.Parallel() + + params := &chaincfg.RegressionNetParams + paidKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + paidAddr, err := address.NewAddressPubKey( + paidKey.PubKey().SerializeCompressed(), params, + ) + require.NoError(t, err) + + wrongKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + wrongAddr, err := address.NewAddressPubKey( + wrongKey.PubKey().SerializeCompressed(), params, + ) + require.NoError(t, err) + + paidScript, err := txscript.PayToAddrScript(paidAddr) + require.NoError(t, err) + + tx := wire.NewMsgTx(wire.TxVersion) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{31}}, + }) + tx.AddTxOut(&wire.TxOut{Value: 1_000, PkScript: paidScript}) + + req, err := NewCreateTxRequest(CreateTxParams{ + WalletID: 5, + Tx: tx, + Received: time.Unix(456, 0), + Status: TxStatusPending, + Credits: map[uint32]address.Address{0: wrongAddr}, + }) + require.NoError(t, err) + + existing := CreateTxExistingTarget{ + ID: 4, + Status: req.Params.Status, + IsCoinbase: req.IsCoinbase, + Label: req.Params.Label, + } + + ops := &mockCreateTxOps{} + t.Cleanup(func() { + ops.AssertExpectations(t) + }) + ops.On("LoadExisting", mock.Anything, req).Return(&existing, nil).Once() + + err = CreateTxWithOps(context.Background(), req, ops) + require.ErrorIs(t, err, ErrInvalidParam) +} + +// TestCreateTxWithOpsValidatesDuplicateBlock verifies that an exact confirmed +// duplicate still checks the caller's block metadata before replaying edge +// writes. +func TestCreateTxWithOpsValidatesDuplicateBlock(t *testing.T) { + t.Parallel() + + req, err := NewCreateTxRequest(CreateTxParams{ + WalletID: 5, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + Label: "mined", + }) + require.NoError(t, err) + + height := req.Params.Block.Height + hash := req.Params.Block.Hash + existing := CreateTxExistingTarget{ + ID: 4, + Status: req.Params.Status, + HasBlock: true, + BlockHeight: &height, + BlockHash: &hash, + IsCoinbase: req.IsCoinbase, + Label: req.Params.Label, + } + + ops := &mockCreateTxOps{} + t.Cleanup(func() { + ops.AssertExpectations(t) + }) + + ops.On("LoadExisting", mock.Anything, req).Return(&existing, nil).Once() + ops.On("PrepareBlock", mock.Anything, req).Return(ErrBlockMismatch).Once() + + err = CreateTxWithOps(context.Background(), req, ops) + require.ErrorIs(t, err, ErrBlockMismatch) +} + +// TestCreateTxWithOpsIgnoresUnminedConfirmedDuplicate verifies that an unmined +// re-observation of an already confirmed transaction is a no-op. +func TestCreateTxWithOpsIgnoresUnminedConfirmedDuplicate(t *testing.T) { + t.Parallel() + + req := testCreateTxRequest(t) + existing := CreateTxExistingTarget{ + ID: 4, + Status: TxStatusPublished, + HasBlock: true, + } + + ops := &mockCreateTxOps{} + t.Cleanup(func() { + ops.AssertExpectations(t) + }) + + ops.On("LoadExisting", mock.Anything, req).Return(&existing, nil).Once() + + err := CreateTxWithOps(context.Background(), req, ops) + require.NoError(t, err) +} + // TestCreateTxWithOpsConfirmExisting verifies that the shared CreateTx flow can // promote one existing unmined row to confirmed state instead of inserting a // duplicate row. @@ -797,6 +946,11 @@ func TestCreateTxWithOpsConfirmExisting(t *testing.T) { ops.On("LoadExisting", mock.Anything, req).Return(&existing, nil).Once() ops.On("ConfirmExisting", mock.Anything, req, existing).Return(nil).Once() + ops.On("ListConflictTxns", mock.Anything, req).Return( + []int64(nil), []chainhash.Hash(nil), nil, + ).Once() + ops.On("InsertCredits", mock.Anything, req, int64(7)).Return(nil).Once() + ops.On("MarkInputsSpent", mock.Anything, req, int64(7)).Return(nil).Once() err = CreateTxWithOps(context.Background(), req, ops) require.NoError(t, err) @@ -1796,6 +1950,104 @@ func TestCheckReuseCreateTx(t *testing.T) { } } +// TestCheckIdempotentCreateTx verifies that duplicate observations are only +// skipped when the persisted tx status, block assignment, coinbase flag, and +// label already match the incoming request. +func TestCheckIdempotentCreateTx(t *testing.T) { + t.Parallel() + + confirmedReq, err := NewCreateTxRequest(CreateTxParams{ + WalletID: 9, + Tx: testRegularMsgTx(), + Received: time.Unix(556, 0), + Block: testBlock(23), + Status: TxStatusPublished, + Label: "mined", + Credits: map[uint32]address.Address{0: nil}, + }) + require.NoError(t, err) + + height := uint32(23) + wrongHeight := uint32(24) + blockHash := confirmedReq.Params.Block.Hash + wrongHash := chainhash.Hash{99} + tests := []struct { + name string + req CreateTxRequest + existing CreateTxExistingTarget + want bool + }{ + { + name: "unmined duplicate", + req: testCreateTxRequest(t), + existing: CreateTxExistingTarget{ + Status: TxStatusPending, + }, + want: true, + }, + { + name: "confirmed duplicate", + req: confirmedReq, + existing: CreateTxExistingTarget{ + Status: TxStatusPublished, + HasBlock: true, + BlockHeight: &height, + BlockHash: &blockHash, + Label: "mined", + }, + want: true, + }, + { + name: "different status", + req: testCreateTxRequest(t), + existing: CreateTxExistingTarget{ + Status: TxStatusPublished, + }, + }, + { + name: "different block", + req: confirmedReq, + existing: CreateTxExistingTarget{ + Status: TxStatusPublished, + HasBlock: true, + BlockHeight: &wrongHeight, + BlockHash: &blockHash, + Label: "mined", + }, + }, + { + name: "different block hash", + req: confirmedReq, + existing: CreateTxExistingTarget{ + Status: TxStatusPublished, + HasBlock: true, + BlockHeight: &height, + BlockHash: &wrongHash, + Label: "mined", + }, + }, + { + name: "different label", + req: confirmedReq, + existing: CreateTxExistingTarget{ + Status: TxStatusPublished, + HasBlock: true, + BlockHeight: &height, + BlockHash: &blockHash, + Label: "other", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.want, + checkIdempotentCreateTx(test.req, test.existing)) + }) + } +} + // TestLoadCreateTxExisting verifies not-found and wrapped-error handling for // the shared existing-row lookup. func TestLoadCreateTxExisting(t *testing.T) { @@ -2118,3 +2370,79 @@ func TestValidateCreditAddrMembership(t *testing.T) { }) } } + +// TestValidateBatchTransactionsWalletID verifies that the shared batch +// wallet-ID validator accepts a batch whose transactions all match the batch +// wallet and rejects any transaction owned by a different wallet with +// ErrInvalidParam. +func TestValidateBatchTransactionsWalletID(t *testing.T) { + t.Parallel() + + const batchWalletID = 7 + + t.Run("all match", func(t *testing.T) { + t.Parallel() + + err := ValidateBatchTransactionsWalletID(batchWalletID, + []CreateTxParams{ + {WalletID: batchWalletID}, + {WalletID: batchWalletID}, + }) + require.NoError(t, err) + }) + + t.Run("empty batch", func(t *testing.T) { + t.Parallel() + + err := ValidateBatchTransactionsWalletID(batchWalletID, nil) + require.NoError(t, err) + }) + + t.Run("mismatch rejected", func(t *testing.T) { + t.Parallel() + + err := ValidateBatchTransactionsWalletID(batchWalletID, + []CreateTxParams{ + {WalletID: batchWalletID}, + {WalletID: 99}, + }) + require.ErrorIs(t, err, ErrInvalidParam) + }) +} + +// TestValidateBatchTransactionsTx verifies that the shared batch nil-Tx +// validator accepts a batch whose transactions all carry a Tx and rejects a +// batch with any nil Tx with ErrInvalidParam, so the parents-first sort never +// dereferences a nil transaction. +func TestValidateBatchTransactionsTx(t *testing.T) { + t.Parallel() + + tx := testRegularMsgTx() + + t.Run("all present", func(t *testing.T) { + t.Parallel() + + err := ValidateBatchTransactionsTx([]CreateTxParams{ + {Tx: tx}, + {Tx: tx}, + }) + require.NoError(t, err) + }) + + t.Run("empty batch", func(t *testing.T) { + t.Parallel() + + err := ValidateBatchTransactionsTx(nil) + require.NoError(t, err) + }) + + t.Run("nil tx rejected", func(t *testing.T) { + t.Parallel() + + err := ValidateBatchTransactionsTx([]CreateTxParams{ + {Tx: tx}, + {Tx: nil}, + }) + require.ErrorIs(t, err, ErrInvalidParam) + }) +} diff --git a/wallet/internal/db/utxostore_common.go b/wallet/internal/db/utxostore_common.go index c1826e1e39..1d043fbc01 100644 --- a/wallet/internal/db/utxostore_common.go +++ b/wallet/internal/db/utxostore_common.go @@ -198,6 +198,52 @@ func BuildUtxoInfo(hash []byte, outputIndex uint32, amount int64, }, nil } +// WatchOutputFromRow converts a ListOutputsToWatch result row into the public +// UtxoInfo shape. Only OutPoint and PkScript are populated, mirroring the +// legacy wtxmgr OutputsToWatch contract where those are the only fields a +// rescan reads; the remaining fields keep their zero values so SQL backends +// stay byte-for-byte identical to the kvdb watch-output view. +// +// The watch script is read from the funding transaction's own output +// (TxOut[outputIndex].PkScript) rather than from the credited address row. +// A bare-multisig output the wallet partly owns is recorded against a member +// address whose own script differs from the multisig output script, so the +// address script would watch the wrong thing; the on-chain output script is +// what a rescan must match, matching the kvdb credit walk. +func WatchOutputFromRow(hash []byte, outputIndex int64, + rawTx []byte) (*UtxoInfo, error) { + + index, err := Int64ToUint32(outputIndex) + if err != nil { + return nil, fmt.Errorf("watch output index: %w", err) + } + + outPoint, err := buildOutPoint(hash, index) + if err != nil { + return nil, err + } + + tx, err := deserializeMsgTx(rawTx) + if err != nil { + return nil, fmt.Errorf("watch output %v: %w", outPoint, err) + } + + // Compare in uint64 so the bounds check stays correct for output + // indexes above math.MaxInt32: an int(index) conversion can overflow to + // a negative value on 32-bit platforms and wrongly pass the check. len + // is never negative, so widening it to uint64 is lossless. + if uint64(index) >= uint64(len(tx.TxOut)) { + return nil, fmt.Errorf("%w: watch output index %d out of range "+ + "for tx %s with %d outputs", ErrInvalidParam, index, + outPoint.Hash, len(tx.TxOut)) + } + + return &UtxoInfo{ + OutPoint: outPoint, + PkScript: tx.TxOut[index].PkScript, + }, nil +} + // BuildLeasedOutput converts SQL lease-row fields into the public LeasedOutput // type. func BuildLeasedOutput(hash []byte, outputIndex uint32, lockID []byte, diff --git a/wallet/internal/db/utxostore_common_test.go b/wallet/internal/db/utxostore_common_test.go index 2dba8083fb..84970efef3 100644 --- a/wallet/internal/db/utxostore_common_test.go +++ b/wallet/internal/db/utxostore_common_test.go @@ -1,7 +1,9 @@ package db import ( + "bytes" "context" + "math" "testing" "time" @@ -533,3 +535,74 @@ func TestBuildLeasedOutputInvalidHash(t *testing.T) { require.Error(t, err) } + +// TestWatchOutputFromRow verifies that WatchOutputFromRow reads the watch +// script from the funding transaction's own output for a valid index. +// +// Scenario: +// - One outputs-to-watch row carries a serialized funding transaction. +// Setup: +// - Build a single-output transaction and serialize it. +// Action: +// - Convert the row into the public watch-output view. +// Assertions: +// - Only the outpoint and the on-chain output script are populated. +func TestWatchOutputFromRow(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{4, 5, 6} + pkScript := []byte{0x00, 0x14, 0x01, 0x02, 0x03} + + tx := wire.NewMsgTx(2) + // A non-empty input set keeps the serialized form unambiguous: a tx with + // zero inputs serializes a leading zero that Deserialize would misread as + // the SegWit marker. + tx.AddTxIn(wire.NewTxIn(&wire.OutPoint{Index: 0}, nil, nil)) + tx.AddTxOut(wire.NewTxOut(1000, pkScript)) + + var buf bytes.Buffer + require.NoError(t, tx.Serialize(&buf)) + + info, err := WatchOutputFromRow(hash[:], 0, buf.Bytes()) + require.NoError(t, err) + require.Equal(t, hash, info.OutPoint.Hash) + require.Equal(t, uint32(0), info.OutPoint.Index) + require.Equal(t, pkScript, info.PkScript) +} + +// TestWatchOutputFromRowIndexAboveMaxInt32 verifies that an output index above +// math.MaxInt32 is rejected with db.ErrInvalidParam rather than overflowing the +// bounds check on 32-bit platforms. +// +// Scenario: +// - An outputs-to-watch row claims an output index above math.MaxInt32. +// Setup: +// - Build a single-output transaction so any large index is out of range. +// Action: +// - Convert the row, supplying an index that still fits in uint32 (so the +// int64->uint32 narrowing succeeds) but exceeds math.MaxInt32. +// +// Assertions: +// - The bounds check rejects the index with ErrInvalidParam, without +// panicking or wrapping to a negative slice index. +func TestWatchOutputFromRowIndexAboveMaxInt32(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{7, 8, 9} + + tx := wire.NewMsgTx(2) + // A non-empty input set keeps the serialized form unambiguous: a tx with + // zero inputs serializes a leading zero that Deserialize would misread as + // the SegWit marker. + tx.AddTxIn(wire.NewTxIn(&wire.OutPoint{Index: 0}, nil, nil)) + tx.AddTxOut(wire.NewTxOut(1000, []byte{0x00, 0x14})) + + var buf bytes.Buffer + require.NoError(t, tx.Serialize(&buf)) + + // math.MaxInt32+1 narrows cleanly to a uint32 but, converted to int on a + // 32-bit platform, would overflow to a negative value and wrongly pass an + // int(index) < len bounds check. + _, err := WatchOutputFromRow(hash[:], math.MaxInt32+1, buf.Bytes()) + require.ErrorIs(t, err, ErrInvalidParam) +} diff --git a/wallet/internal/sql/pg/queries/transactions.sql b/wallet/internal/sql/pg/queries/transactions.sql index c473ea1603..43f4a6102e 100644 --- a/wallet/internal/sql/pg/queries/transactions.sql +++ b/wallet/internal/sql/pg/queries/transactions.sql @@ -140,6 +140,28 @@ WHERE AND t.tx_status IN (0, 1) ORDER BY t.received_time DESC, t.id DESC; +-- name: ListActiveTransactionRaws :many +-- Lists active wallet transaction rows and their raw transaction bytes. +-- +-- How: +-- - Reads from transactions only and filters to rows that may currently spend +-- wallet-owned outputs (`pending` and `published`). +-- - Returns the primary key, transaction hash, block assignment, and raw +-- transaction bytes so callers can rebuild input outpoints not normalized in +-- the SQL schema. +-- Performance: +-- - Matches the wallet/status index used by active wallet history paths. +SELECT + t.id, + t.tx_hash, + t.block_height, + t.raw_tx +FROM transactions AS t +WHERE + t.wallet_id = $1 + AND t.tx_status IN (0, 1) +ORDER BY t.id; + -- name: ListTransactionsByHeightRange :many -- Lists all confirmed transactions for a wallet in the provided height range. -- diff --git a/wallet/internal/sql/pg/queries/utxos.sql b/wallet/internal/sql/pg/queries/utxos.sql index 18853c77c7..8e3197bdec 100644 --- a/wallet/internal/sql/pg/queries/utxos.sql +++ b/wallet/internal/sql/pg/queries/utxos.sql @@ -374,14 +374,19 @@ ORDER BY u.spent_by_tx_id; -- considers outputs whose parent status is `pending` or `published`. -- - Returns the nullable `spent_by_tx_id` column so callers can distinguish -- between an external/unknown parent and a wallet-owned conflict. +-- - Returns the owner address script so duplicate credit replay can verify it +-- matches the already-recorded UTXO rather than only checking outpoint +-- existence. -- 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 +SELECT u.spent_by_tx_id, a.script_pub_key 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 WHERE t.wallet_id = $1 + AND a.wallet_id = $1 AND t.tx_hash = $2 AND u.output_index = $3 AND t.tx_status IN (0, 1); @@ -481,3 +486,43 @@ WHERE FROM transactions AS t WHERE t.id = u.tx_id AND t.wallet_id = $1 ); + +-- name: ListOutputsToWatch :many +-- Lists every output a recovery rescan must keep watching for one wallet. +-- +-- How: +-- - Starts from the wallet's UTXO rows joined to their funding transaction; +-- the rescan needs each outpoint plus the on-chain output script. The script +-- is taken from the funding transaction's raw_tx (TxOut[output_index]) in the +-- Store Go layer rather than from the credited address: a bare-multisig +-- output the wallet partly owns is recorded against a member address whose +-- own script differs from the multisig output script, so the address script +-- would be the wrong thing to watch. Reading the actual output script matches +-- the kvdb OutputsToWatch contract. +-- - Includes outputs whose funding transaction is still active (pending or +-- published); invalidated parents (replaced/failed/orphaned) are excluded so +-- the watch set matches the legacy wtxmgr credit walk. +-- - Keeps an output when it is either still unspent OR spent only by an +-- unmined, still-active transaction. This mirrors the legacy behaviour of +-- returning unmined credits and credits spent by other unmined txs, while +-- dropping outputs already spent by a confirmed transaction. +-- - Locked (leased) outputs are intentionally retained because leasing is +-- modelled separately from existence and the rescan must still watch them. +SELECT + t.tx_hash, + u.output_index, + t.raw_tx +FROM utxos AS u +INNER JOIN transactions AS t ON u.tx_id = t.id +LEFT JOIN transactions AS spend ON u.spent_by_tx_id = spend.id +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND t.tx_status IN (0, 1) + AND ( + u.spent_by_tx_id IS NULL + OR ( + spend.block_height IS NULL + AND spend.tx_status IN (0, 1) + ) + ) +ORDER BY t.tx_hash, u.output_index; diff --git a/wallet/internal/sql/pg/sqlc/db.go b/wallet/internal/sql/pg/sqlc/db.go index 6ecaddfad1..4f1ebea521 100644 --- a/wallet/internal/sql/pg/sqlc/db.go +++ b/wallet/internal/sql/pg/sqlc/db.go @@ -201,6 +201,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.listActiveTransactionRawsStmt, err = db.PrepareContext(ctx, ListActiveTransactionRaws); err != nil { + return nil, fmt.Errorf("error preparing query ListActiveTransactionRaws: %w", err) + } if q.listActiveUtxoLeasesStmt, err = db.PrepareContext(ctx, ListActiveUtxoLeases); err != nil { return nil, fmt.Errorf("error preparing query ListActiveUtxoLeases: %w", err) } @@ -216,6 +219,9 @@ 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.listOutputsToWatchStmt, err = db.PrepareContext(ctx, ListOutputsToWatch); err != nil { + return nil, fmt.Errorf("error preparing query ListOutputsToWatch: %w", err) + } if q.listOwnedInputPrevOutputsByTxHashesStmt, err = db.PrepareContext(ctx, ListOwnedInputPrevOutputsByTxHashes); err != nil { return nil, fmt.Errorf("error preparing query ListOwnedInputPrevOutputsByTxHashes: %w", err) } @@ -588,6 +594,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listAccountsByWalletScopeStmt: %w", cerr) } } + if q.listActiveTransactionRawsStmt != nil { + if cerr := q.listActiveTransactionRawsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listActiveTransactionRawsStmt: %w", cerr) + } + } if q.listActiveUtxoLeasesStmt != nil { if cerr := q.listActiveUtxoLeasesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listActiveUtxoLeasesStmt: %w", cerr) @@ -613,6 +624,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listKeyScopesByWalletStmt: %w", cerr) } } + if q.listOutputsToWatchStmt != nil { + if cerr := q.listOutputsToWatchStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listOutputsToWatchStmt: %w", cerr) + } + } if q.listOwnedInputPrevOutputsByTxHashesStmt != nil { if cerr := q.listOwnedInputPrevOutputsByTxHashesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listOwnedInputPrevOutputsByTxHashesStmt: %w", cerr) @@ -831,11 +847,13 @@ type Queries struct { listAccountsByWalletStmt *sql.Stmt listAccountsByWalletAndNameStmt *sql.Stmt listAccountsByWalletScopeStmt *sql.Stmt + listActiveTransactionRawsStmt *sql.Stmt listActiveUtxoLeasesStmt *sql.Stmt listAddressTypesStmt *sql.Stmt listAddressesByAccountStmt *sql.Stmt listAddressesByScriptPubKeysStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt + listOutputsToWatchStmt *sql.Stmt listOwnedInputPrevOutputsByTxHashesStmt *sql.Stmt listOwnedOutputsByTxIDsStmt *sql.Stmt listRawImportedAddressesStmt *sql.Stmt @@ -925,11 +943,13 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listAccountsByWalletStmt: q.listAccountsByWalletStmt, listAccountsByWalletAndNameStmt: q.listAccountsByWalletAndNameStmt, listAccountsByWalletScopeStmt: q.listAccountsByWalletScopeStmt, + listActiveTransactionRawsStmt: q.listActiveTransactionRawsStmt, listActiveUtxoLeasesStmt: q.listActiveUtxoLeasesStmt, listAddressTypesStmt: q.listAddressTypesStmt, listAddressesByAccountStmt: q.listAddressesByAccountStmt, listAddressesByScriptPubKeysStmt: q.listAddressesByScriptPubKeysStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, + listOutputsToWatchStmt: q.listOutputsToWatchStmt, listOwnedInputPrevOutputsByTxHashesStmt: q.listOwnedInputPrevOutputsByTxHashesStmt, listOwnedOutputsByTxIDsStmt: q.listOwnedOutputsByTxIDsStmt, listRawImportedAddressesStmt: q.listRawImportedAddressesStmt, diff --git a/wallet/internal/sql/pg/sqlc/querier.go b/wallet/internal/sql/pg/sqlc/querier.go index 75b77d12fa..77d89da9fc 100644 --- a/wallet/internal/sql/pg/sqlc/querier.go +++ b/wallet/internal/sql/pg/sqlc/querier.go @@ -234,10 +234,13 @@ type Querier interface { // considers outputs whose parent status is `pending` or `published`. // - Returns the nullable `spent_by_tx_id` column so callers can distinguish // between an external/unknown parent and a wallet-owned conflict. + // - Returns the owner address script so duplicate credit replay can verify it + // matches the already-recorded UTXO rather than only checking outpoint + // existence. // 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) + GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendByOutpointParams) (GetUtxoSpendByOutpointRow, 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) @@ -317,6 +320,17 @@ type Querier interface { ListAccountsByWalletAndName(ctx context.Context, arg ListAccountsByWalletAndNameParams) ([]ListAccountsByWalletAndNameRow, error) // Lists all accounts for a wallet and scope tuple. ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) + // Lists active wallet transaction rows and their raw transaction bytes. + // + // How: + // - Reads from transactions only and filters to rows that may currently spend + // wallet-owned outputs (`pending` and `published`). + // - Returns the primary key, transaction hash, block assignment, and raw + // transaction bytes so callers can rebuild input outpoints not normalized in + // the SQL schema. + // Performance: + // - Matches the wallet/status index used by active wallet history paths. + ListActiveTransactionRaws(ctx context.Context, walletID int64) ([]ListActiveTransactionRawsRow, error) // Lists all currently active leases for a wallet. // // How: @@ -341,6 +355,27 @@ type Querier interface { ListAddressesByScriptPubKeys(ctx context.Context, arg ListAddressesByScriptPubKeysParams) ([]ListAddressesByScriptPubKeysRow, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) + // Lists every output a recovery rescan must keep watching for one wallet. + // + // How: + // - Starts from the wallet's UTXO rows joined to their funding transaction; + // the rescan needs each outpoint plus the on-chain output script. The script + // is taken from the funding transaction's raw_tx (TxOut[output_index]) in the + // Store Go layer rather than from the credited address: a bare-multisig + // output the wallet partly owns is recorded against a member address whose + // own script differs from the multisig output script, so the address script + // would be the wrong thing to watch. Reading the actual output script matches + // the kvdb OutputsToWatch contract. + // - Includes outputs whose funding transaction is still active (pending or + // published); invalidated parents (replaced/failed/orphaned) are excluded so + // the watch set matches the legacy wtxmgr credit walk. + // - Keeps an output when it is either still unspent OR spent only by an + // unmined, still-active transaction. This mirrors the legacy behaviour of + // returning unmined credits and credits spent by other unmined txs, while + // dropping outputs already spent by a confirmed transaction. + // - Locked (leased) outputs are intentionally retained because leasing is + // modelled separately from existence and the rescan must still watch them. + ListOutputsToWatch(ctx context.Context, walletID int64) ([]ListOutputsToWatchRow, error) // ListOwnedInputPrevOutputsByTxHashes lists wallet-owned previous outputs that // may be spent by selected transaction inputs. // diff --git a/wallet/internal/sql/pg/sqlc/transactions.sql.go b/wallet/internal/sql/pg/sqlc/transactions.sql.go index 22bcb0a927..2ae87b284b 100644 --- a/wallet/internal/sql/pg/sqlc/transactions.sql.go +++ b/wallet/internal/sql/pg/sqlc/transactions.sql.go @@ -243,6 +243,65 @@ func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionPa return id, err } +const ListActiveTransactionRaws = `-- name: ListActiveTransactionRaws :many +SELECT + t.id, + t.tx_hash, + t.block_height, + t.raw_tx +FROM transactions AS t +WHERE + t.wallet_id = $1 + AND t.tx_status IN (0, 1) +ORDER BY t.id +` + +type ListActiveTransactionRawsRow struct { + ID int64 + TxHash []byte + BlockHeight sql.NullInt32 + RawTx []byte +} + +// Lists active wallet transaction rows and their raw transaction bytes. +// +// How: +// - Reads from transactions only and filters to rows that may currently spend +// wallet-owned outputs (`pending` and `published`). +// - Returns the primary key, transaction hash, block assignment, and raw +// transaction bytes so callers can rebuild input outpoints not normalized in +// the SQL schema. +// +// Performance: +// - Matches the wallet/status index used by active wallet history paths. +func (q *Queries) ListActiveTransactionRaws(ctx context.Context, walletID int64) ([]ListActiveTransactionRawsRow, error) { + rows, err := q.query(ctx, q.listActiveTransactionRawsStmt, ListActiveTransactionRaws, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListActiveTransactionRawsRow + for rows.Next() { + var i ListActiveTransactionRawsRow + if err := rows.Scan( + &i.ID, + &i.TxHash, + &i.BlockHeight, + &i.RawTx, + ); 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 ListOwnedInputPrevOutputsByTxHashes = `-- name: ListOwnedInputPrevOutputsByTxHashes :many SELECT t.tx_hash, diff --git a/wallet/internal/sql/pg/sqlc/utxos.sql.go b/wallet/internal/sql/pg/sqlc/utxos.sql.go index cdbcd185d1..153eae9e3b 100644 --- a/wallet/internal/sql/pg/sqlc/utxos.sql.go +++ b/wallet/internal/sql/pg/sqlc/utxos.sql.go @@ -398,11 +398,13 @@ func (q *Queries) GetUtxoIDByOutpoint(ctx context.Context, arg GetUtxoIDByOutpoi } const GetUtxoSpendByOutpoint = `-- name: GetUtxoSpendByOutpoint :one -SELECT u.spent_by_tx_id +SELECT u.spent_by_tx_id, a.script_pub_key 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 WHERE t.wallet_id = $1 + AND a.wallet_id = $1 AND t.tx_hash = $2 AND u.output_index = $3 AND t.tx_status IN (0, 1) @@ -414,6 +416,11 @@ type GetUtxoSpendByOutpointParams struct { OutputIndex int32 } +type GetUtxoSpendByOutpointRow struct { + SpentByTxID sql.NullInt64 + ScriptPubKey []byte +} + // Returns the current spend edge for one wallet-owned outpoint. // // How: @@ -421,15 +428,18 @@ type GetUtxoSpendByOutpointParams struct { // considers outputs whose parent status is `pending` or `published`. // - Returns the nullable `spent_by_tx_id` column so callers can distinguish // between an external/unknown parent and a wallet-owned conflict. +// - Returns the owner address script so duplicate credit replay can verify it +// matches the already-recorded UTXO rather than only checking outpoint +// existence. // // 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) { +func (q *Queries) GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendByOutpointParams) (GetUtxoSpendByOutpointRow, 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 + var i GetUtxoSpendByOutpointRow + err := row.Scan(&i.SpentByTxID, &i.ScriptPubKey) + return i, err } const HasInvalidWalletUtxoByOutpoint = `-- name: HasInvalidWalletUtxoByOutpoint :one @@ -526,6 +536,76 @@ func (q *Queries) InsertUtxo(ctx context.Context, arg InsertUtxoParams) (int64, return id, err } +const ListOutputsToWatch = `-- name: ListOutputsToWatch :many +SELECT + t.tx_hash, + u.output_index, + t.raw_tx +FROM utxos AS u +INNER JOIN transactions AS t ON u.tx_id = t.id +LEFT JOIN transactions AS spend ON u.spent_by_tx_id = spend.id +WHERE + t.wallet_id = $1 + AND t.tx_status IN (0, 1) + AND ( + u.spent_by_tx_id IS NULL + OR ( + spend.block_height IS NULL + AND spend.tx_status IN (0, 1) + ) + ) +ORDER BY t.tx_hash, u.output_index +` + +type ListOutputsToWatchRow struct { + TxHash []byte + OutputIndex int32 + RawTx []byte +} + +// Lists every output a recovery rescan must keep watching for one wallet. +// +// How: +// - Starts from the wallet's UTXO rows joined to their funding transaction; +// the rescan needs each outpoint plus the on-chain output script. The script +// is taken from the funding transaction's raw_tx (TxOut[output_index]) in the +// Store Go layer rather than from the credited address: a bare-multisig +// output the wallet partly owns is recorded against a member address whose +// own script differs from the multisig output script, so the address script +// would be the wrong thing to watch. Reading the actual output script matches +// the kvdb OutputsToWatch contract. +// - Includes outputs whose funding transaction is still active (pending or +// published); invalidated parents (replaced/failed/orphaned) are excluded so +// the watch set matches the legacy wtxmgr credit walk. +// - Keeps an output when it is either still unspent OR spent only by an +// unmined, still-active transaction. This mirrors the legacy behaviour of +// returning unmined credits and credits spent by other unmined txs, while +// dropping outputs already spent by a confirmed transaction. +// - Locked (leased) outputs are intentionally retained because leasing is +// modelled separately from existence and the rescan must still watch them. +func (q *Queries) ListOutputsToWatch(ctx context.Context, walletID int64) ([]ListOutputsToWatchRow, error) { + rows, err := q.query(ctx, q.listOutputsToWatchStmt, ListOutputsToWatch, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListOutputsToWatchRow + for rows.Next() { + var i ListOutputsToWatchRow + if err := rows.Scan(&i.TxHash, &i.OutputIndex, &i.RawTx); 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 ListSpendingTxIDsByParentTxID = `-- name: ListSpendingTxIDsByParentTxID :many SELECT DISTINCT u.spent_by_tx_id FROM utxos AS u diff --git a/wallet/internal/sql/sqlite/queries/transactions.sql b/wallet/internal/sql/sqlite/queries/transactions.sql index 06a72a6ee7..950d218e86 100644 --- a/wallet/internal/sql/sqlite/queries/transactions.sql +++ b/wallet/internal/sql/sqlite/queries/transactions.sql @@ -145,6 +145,28 @@ WHERE AND t.tx_status IN (0, 1) ORDER BY t.received_time DESC, t.id DESC; +-- name: ListActiveTransactionRaws :many +-- Lists active wallet transaction rows and their raw transaction bytes. +-- +-- How: +-- - Reads from transactions only and filters to rows that may currently spend +-- wallet-owned outputs (`pending` and `published`). +-- - Returns the primary key, transaction hash, block assignment, and raw +-- transaction bytes so callers can rebuild input outpoints not normalized in +-- the SQL schema. +-- Performance: +-- - Matches the wallet/status index used by active wallet history paths. +SELECT + t.id, + t.tx_hash, + t.block_height, + t.raw_tx +FROM transactions AS t +WHERE + t.wallet_id = ? + AND t.tx_status IN (0, 1) +ORDER BY t.id; + -- name: ListTransactionsByHeightRange :many -- Lists all confirmed transactions for a wallet in the provided height range. -- diff --git a/wallet/internal/sql/sqlite/queries/utxos.sql b/wallet/internal/sql/sqlite/queries/utxos.sql index e8547dd0ea..b8aceee992 100644 --- a/wallet/internal/sql/sqlite/queries/utxos.sql +++ b/wallet/internal/sql/sqlite/queries/utxos.sql @@ -385,14 +385,19 @@ ORDER BY u.spent_by_tx_id; -- considers outputs whose parent status is `pending` or `published`. -- - Returns the nullable `spent_by_tx_id` column so callers can distinguish -- between an external/unknown parent and a wallet-owned conflict. +-- - Returns the owner address script so duplicate credit replay can verify it +-- matches the already-recorded UTXO rather than only checking outpoint +-- existence. -- 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 +SELECT utxos.spent_by_tx_id, a.script_pub_key FROM transactions AS t INNER JOIN utxos ON t.id = utxos.tx_id +INNER JOIN addresses AS a ON utxos.address_id = a.id WHERE t.wallet_id = ?1 + AND a.wallet_id = ?1 AND t.tx_hash = ?2 AND utxos.output_index = ?3 AND t.tx_status IN (0, 1); @@ -492,3 +497,43 @@ WHERE FROM transactions AS t WHERE t.id = utxos.tx_id AND t.wallet_id = ?1 ); + +-- name: ListOutputsToWatch :many +-- Lists every output a recovery rescan must keep watching for one wallet. +-- +-- How: +-- - Starts from the wallet's UTXO rows joined to their funding transaction; +-- the rescan needs each outpoint plus the on-chain output script. The script +-- is taken from the funding transaction's raw_tx (TxOut[output_index]) in the +-- Store Go layer rather than from the credited address: a bare-multisig +-- output the wallet partly owns is recorded against a member address whose +-- own script differs from the multisig output script, so the address script +-- would be the wrong thing to watch. Reading the actual output script matches +-- the kvdb OutputsToWatch contract. +-- - Includes outputs whose funding transaction is still active (pending or +-- published); invalidated parents (replaced/failed/orphaned) are excluded so +-- the watch set matches the legacy wtxmgr credit walk. +-- - Keeps an output when it is either still unspent OR spent only by an +-- unmined, still-active transaction. This mirrors the legacy behaviour of +-- returning unmined credits and credits spent by other unmined txs, while +-- dropping outputs already spent by a confirmed transaction. +-- - Locked (leased) outputs are intentionally retained because leasing is +-- modelled separately from existence and the rescan must still watch them. +SELECT + t.tx_hash, + u.output_index, + t.raw_tx +FROM utxos AS u +INNER JOIN transactions AS t ON u.tx_id = t.id +LEFT JOIN transactions AS spend ON u.spent_by_tx_id = spend.id +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND t.tx_status IN (0, 1) + AND ( + u.spent_by_tx_id IS NULL + OR ( + spend.block_height IS NULL + AND spend.tx_status IN (0, 1) + ) + ) +ORDER BY t.tx_hash, u.output_index; diff --git a/wallet/internal/sql/sqlite/sqlc/db.go b/wallet/internal/sql/sqlite/sqlc/db.go index 6ecaddfad1..4f1ebea521 100644 --- a/wallet/internal/sql/sqlite/sqlc/db.go +++ b/wallet/internal/sql/sqlite/sqlc/db.go @@ -201,6 +201,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.listActiveTransactionRawsStmt, err = db.PrepareContext(ctx, ListActiveTransactionRaws); err != nil { + return nil, fmt.Errorf("error preparing query ListActiveTransactionRaws: %w", err) + } if q.listActiveUtxoLeasesStmt, err = db.PrepareContext(ctx, ListActiveUtxoLeases); err != nil { return nil, fmt.Errorf("error preparing query ListActiveUtxoLeases: %w", err) } @@ -216,6 +219,9 @@ 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.listOutputsToWatchStmt, err = db.PrepareContext(ctx, ListOutputsToWatch); err != nil { + return nil, fmt.Errorf("error preparing query ListOutputsToWatch: %w", err) + } if q.listOwnedInputPrevOutputsByTxHashesStmt, err = db.PrepareContext(ctx, ListOwnedInputPrevOutputsByTxHashes); err != nil { return nil, fmt.Errorf("error preparing query ListOwnedInputPrevOutputsByTxHashes: %w", err) } @@ -588,6 +594,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listAccountsByWalletScopeStmt: %w", cerr) } } + if q.listActiveTransactionRawsStmt != nil { + if cerr := q.listActiveTransactionRawsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listActiveTransactionRawsStmt: %w", cerr) + } + } if q.listActiveUtxoLeasesStmt != nil { if cerr := q.listActiveUtxoLeasesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listActiveUtxoLeasesStmt: %w", cerr) @@ -613,6 +624,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listKeyScopesByWalletStmt: %w", cerr) } } + if q.listOutputsToWatchStmt != nil { + if cerr := q.listOutputsToWatchStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listOutputsToWatchStmt: %w", cerr) + } + } if q.listOwnedInputPrevOutputsByTxHashesStmt != nil { if cerr := q.listOwnedInputPrevOutputsByTxHashesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listOwnedInputPrevOutputsByTxHashesStmt: %w", cerr) @@ -831,11 +847,13 @@ type Queries struct { listAccountsByWalletStmt *sql.Stmt listAccountsByWalletAndNameStmt *sql.Stmt listAccountsByWalletScopeStmt *sql.Stmt + listActiveTransactionRawsStmt *sql.Stmt listActiveUtxoLeasesStmt *sql.Stmt listAddressTypesStmt *sql.Stmt listAddressesByAccountStmt *sql.Stmt listAddressesByScriptPubKeysStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt + listOutputsToWatchStmt *sql.Stmt listOwnedInputPrevOutputsByTxHashesStmt *sql.Stmt listOwnedOutputsByTxIDsStmt *sql.Stmt listRawImportedAddressesStmt *sql.Stmt @@ -925,11 +943,13 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listAccountsByWalletStmt: q.listAccountsByWalletStmt, listAccountsByWalletAndNameStmt: q.listAccountsByWalletAndNameStmt, listAccountsByWalletScopeStmt: q.listAccountsByWalletScopeStmt, + listActiveTransactionRawsStmt: q.listActiveTransactionRawsStmt, listActiveUtxoLeasesStmt: q.listActiveUtxoLeasesStmt, listAddressTypesStmt: q.listAddressTypesStmt, listAddressesByAccountStmt: q.listAddressesByAccountStmt, listAddressesByScriptPubKeysStmt: q.listAddressesByScriptPubKeysStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, + listOutputsToWatchStmt: q.listOutputsToWatchStmt, listOwnedInputPrevOutputsByTxHashesStmt: q.listOwnedInputPrevOutputsByTxHashesStmt, listOwnedOutputsByTxIDsStmt: q.listOwnedOutputsByTxIDsStmt, listRawImportedAddressesStmt: q.listRawImportedAddressesStmt, diff --git a/wallet/internal/sql/sqlite/sqlc/querier.go b/wallet/internal/sql/sqlite/sqlc/querier.go index 7b93c15148..8249201d26 100644 --- a/wallet/internal/sql/sqlite/sqlc/querier.go +++ b/wallet/internal/sql/sqlite/sqlc/querier.go @@ -232,10 +232,13 @@ type Querier interface { // considers outputs whose parent status is `pending` or `published`. // - Returns the nullable `spent_by_tx_id` column so callers can distinguish // between an external/unknown parent and a wallet-owned conflict. + // - Returns the owner address script so duplicate credit replay can verify it + // matches the already-recorded UTXO rather than only checking outpoint + // existence. // 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) + GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendByOutpointParams) (GetUtxoSpendByOutpointRow, 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) @@ -314,6 +317,17 @@ type Querier interface { ListAccountsByWalletAndName(ctx context.Context, arg ListAccountsByWalletAndNameParams) ([]ListAccountsByWalletAndNameRow, error) // Lists all accounts for a wallet and scope tuple. ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) + // Lists active wallet transaction rows and their raw transaction bytes. + // + // How: + // - Reads from transactions only and filters to rows that may currently spend + // wallet-owned outputs (`pending` and `published`). + // - Returns the primary key, transaction hash, block assignment, and raw + // transaction bytes so callers can rebuild input outpoints not normalized in + // the SQL schema. + // Performance: + // - Matches the wallet/status index used by active wallet history paths. + ListActiveTransactionRaws(ctx context.Context, walletID int64) ([]ListActiveTransactionRawsRow, error) // Lists all currently active leases for a wallet. // // How: @@ -338,6 +352,27 @@ type Querier interface { ListAddressesByScriptPubKeys(ctx context.Context, arg ListAddressesByScriptPubKeysParams) ([]ListAddressesByScriptPubKeysRow, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) + // Lists every output a recovery rescan must keep watching for one wallet. + // + // How: + // - Starts from the wallet's UTXO rows joined to their funding transaction; + // the rescan needs each outpoint plus the on-chain output script. The script + // is taken from the funding transaction's raw_tx (TxOut[output_index]) in the + // Store Go layer rather than from the credited address: a bare-multisig + // output the wallet partly owns is recorded against a member address whose + // own script differs from the multisig output script, so the address script + // would be the wrong thing to watch. Reading the actual output script matches + // the kvdb OutputsToWatch contract. + // - Includes outputs whose funding transaction is still active (pending or + // published); invalidated parents (replaced/failed/orphaned) are excluded so + // the watch set matches the legacy wtxmgr credit walk. + // - Keeps an output when it is either still unspent OR spent only by an + // unmined, still-active transaction. This mirrors the legacy behaviour of + // returning unmined credits and credits spent by other unmined txs, while + // dropping outputs already spent by a confirmed transaction. + // - Locked (leased) outputs are intentionally retained because leasing is + // modelled separately from existence and the rescan must still watch them. + ListOutputsToWatch(ctx context.Context, walletID int64) ([]ListOutputsToWatchRow, error) // ListOwnedInputPrevOutputsByTxHashes lists wallet-owned previous outputs that // may be spent by selected transaction inputs. // diff --git a/wallet/internal/sql/sqlite/sqlc/transactions.sql.go b/wallet/internal/sql/sqlite/sqlc/transactions.sql.go index 8924561f02..f1e85dc400 100644 --- a/wallet/internal/sql/sqlite/sqlc/transactions.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/transactions.sql.go @@ -245,6 +245,65 @@ func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionPa return id, err } +const ListActiveTransactionRaws = `-- name: ListActiveTransactionRaws :many +SELECT + t.id, + t.tx_hash, + t.block_height, + t.raw_tx +FROM transactions AS t +WHERE + t.wallet_id = ? + AND t.tx_status IN (0, 1) +ORDER BY t.id +` + +type ListActiveTransactionRawsRow struct { + ID int64 + TxHash []byte + BlockHeight sql.NullInt64 + RawTx []byte +} + +// Lists active wallet transaction rows and their raw transaction bytes. +// +// How: +// - Reads from transactions only and filters to rows that may currently spend +// wallet-owned outputs (`pending` and `published`). +// - Returns the primary key, transaction hash, block assignment, and raw +// transaction bytes so callers can rebuild input outpoints not normalized in +// the SQL schema. +// +// Performance: +// - Matches the wallet/status index used by active wallet history paths. +func (q *Queries) ListActiveTransactionRaws(ctx context.Context, walletID int64) ([]ListActiveTransactionRawsRow, error) { + rows, err := q.query(ctx, q.listActiveTransactionRawsStmt, ListActiveTransactionRaws, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListActiveTransactionRawsRow + for rows.Next() { + var i ListActiveTransactionRawsRow + if err := rows.Scan( + &i.ID, + &i.TxHash, + &i.BlockHeight, + &i.RawTx, + ); 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 ListOwnedInputPrevOutputsByTxHashes = `-- name: ListOwnedInputPrevOutputsByTxHashes :many SELECT t.tx_hash, diff --git a/wallet/internal/sql/sqlite/sqlc/utxos.sql.go b/wallet/internal/sql/sqlite/sqlc/utxos.sql.go index c4b0404917..83fc1f62d1 100644 --- a/wallet/internal/sql/sqlite/sqlc/utxos.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/utxos.sql.go @@ -406,11 +406,13 @@ func (q *Queries) GetUtxoIDByOutpoint(ctx context.Context, arg GetUtxoIDByOutpoi } const GetUtxoSpendByOutpoint = `-- name: GetUtxoSpendByOutpoint :one -SELECT utxos.spent_by_tx_id +SELECT utxos.spent_by_tx_id, a.script_pub_key FROM transactions AS t INNER JOIN utxos ON t.id = utxos.tx_id +INNER JOIN addresses AS a ON utxos.address_id = a.id WHERE t.wallet_id = ?1 + AND a.wallet_id = ?1 AND t.tx_hash = ?2 AND utxos.output_index = ?3 AND t.tx_status IN (0, 1) @@ -422,6 +424,11 @@ type GetUtxoSpendByOutpointParams struct { OutputIndex int64 } +type GetUtxoSpendByOutpointRow struct { + SpentByTxID sql.NullInt64 + ScriptPubKey []byte +} + // Returns the current spend edge for one wallet-owned outpoint. // // How: @@ -429,15 +436,18 @@ type GetUtxoSpendByOutpointParams struct { // considers outputs whose parent status is `pending` or `published`. // - Returns the nullable `spent_by_tx_id` column so callers can distinguish // between an external/unknown parent and a wallet-owned conflict. +// - Returns the owner address script so duplicate credit replay can verify it +// matches the already-recorded UTXO rather than only checking outpoint +// existence. // // 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) { +func (q *Queries) GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendByOutpointParams) (GetUtxoSpendByOutpointRow, 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 + var i GetUtxoSpendByOutpointRow + err := row.Scan(&i.SpentByTxID, &i.ScriptPubKey) + return i, err } const HasInvalidWalletUtxoByOutpoint = `-- name: HasInvalidWalletUtxoByOutpoint :one @@ -534,6 +544,76 @@ func (q *Queries) InsertUtxo(ctx context.Context, arg InsertUtxoParams) (int64, return id, err } +const ListOutputsToWatch = `-- name: ListOutputsToWatch :many +SELECT + t.tx_hash, + u.output_index, + t.raw_tx +FROM utxos AS u +INNER JOIN transactions AS t ON u.tx_id = t.id +LEFT JOIN transactions AS spend ON u.spent_by_tx_id = spend.id +WHERE + t.wallet_id = ?1 + AND t.tx_status IN (0, 1) + AND ( + u.spent_by_tx_id IS NULL + OR ( + spend.block_height IS NULL + AND spend.tx_status IN (0, 1) + ) + ) +ORDER BY t.tx_hash, u.output_index +` + +type ListOutputsToWatchRow struct { + TxHash []byte + OutputIndex int64 + RawTx []byte +} + +// Lists every output a recovery rescan must keep watching for one wallet. +// +// How: +// - Starts from the wallet's UTXO rows joined to their funding transaction; +// the rescan needs each outpoint plus the on-chain output script. The script +// is taken from the funding transaction's raw_tx (TxOut[output_index]) in the +// Store Go layer rather than from the credited address: a bare-multisig +// output the wallet partly owns is recorded against a member address whose +// own script differs from the multisig output script, so the address script +// would be the wrong thing to watch. Reading the actual output script matches +// the kvdb OutputsToWatch contract. +// - Includes outputs whose funding transaction is still active (pending or +// published); invalidated parents (replaced/failed/orphaned) are excluded so +// the watch set matches the legacy wtxmgr credit walk. +// - Keeps an output when it is either still unspent OR spent only by an +// unmined, still-active transaction. This mirrors the legacy behaviour of +// returning unmined credits and credits spent by other unmined txs, while +// dropping outputs already spent by a confirmed transaction. +// - Locked (leased) outputs are intentionally retained because leasing is +// modelled separately from existence and the rescan must still watch them. +func (q *Queries) ListOutputsToWatch(ctx context.Context, walletID int64) ([]ListOutputsToWatchRow, error) { + rows, err := q.query(ctx, q.listOutputsToWatchStmt, ListOutputsToWatch, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListOutputsToWatchRow + for rows.Next() { + var i ListOutputsToWatchRow + if err := rows.Scan(&i.TxHash, &i.OutputIndex, &i.RawTx); 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 ListSpendingTxIDsByParentTxID = `-- name: ListSpendingTxIDsByParentTxID :many SELECT DISTINCT u.spent_by_tx_id FROM utxos AS u