diff --git a/bwtest/mock/account_store.go b/bwtest/mock/account_store.go index eaecfa0d18..34f6fce4b4 100644 --- a/bwtest/mock/account_store.go +++ b/bwtest/mock/account_store.go @@ -270,6 +270,13 @@ func (m *AccountStore) InvalidateAccountCache(account uint32) { m.Called(account) } +// EvictDerivedAddresses implements the waddrmgr.AccountStore interface. +func (m *AccountStore) EvictDerivedAddresses(account, branch, fromIndex, + toIndex uint32) { + + m.Called(account, branch, fromIndex, toIndex) +} + // ImportPrivateKey implements the waddrmgr.AccountStore interface. func (m *AccountStore) ImportPrivateKey(ns walletdb.ReadWriteBucket, wif *btcutil.WIF, diff --git a/waddrmgr/interface.go b/waddrmgr/interface.go index 75c6e6c52e..0d1d133c83 100644 --- a/waddrmgr/interface.go +++ b/waddrmgr/interface.go @@ -354,6 +354,13 @@ type AccountStore interface { // InvalidateAccountCache invalidates the account cache. InvalidateAccountCache(account uint32) + // EvictDerivedAddresses removes the in-memory traces (recent-address + // cache entries and pending unlock-derivation entries) of the + // addresses derived for the given account branch over the half-open + // index range [fromIndex, toIndex), undoing a rolled-back horizon + // extension. + EvictDerivedAddresses(account, branch, fromIndex, toIndex uint32) + // ImportPrivateKey imports a private key. ImportPrivateKey(ns walletdb.ReadWriteBucket, wif *btcutil.WIF, bs *BlockStamp) (ManagedPubKeyAddress, error) diff --git a/waddrmgr/scoped_manager.go b/waddrmgr/scoped_manager.go index 9ad188d2cd..014abec024 100644 --- a/waddrmgr/scoped_manager.go +++ b/waddrmgr/scoped_manager.go @@ -2918,6 +2918,39 @@ func (s *ScopedKeyManager) InvalidateAccountCache(account uint32) { delete(s.acctInfo, account) } +// EvictDerivedAddresses removes the in-memory traces of the addresses derived +// for the given account branch over the half-open index range [fromIndex, +// toIndex) from the live manager. It drops those addresses from the +// recent-address cache, discards any matching pending deriveOnUnlock entries, +// and invalidates the cached account info so the bumped next-index and +// last-address fields are reloaded from the committed state. +// +// extendAddresses mutates this in-memory state synchronously, before the +// surrounding walletdb transaction commits, so a horizon extension that is +// later rolled back would otherwise leave scan-derived addresses observable +// through Address, queued for derivation on the next unlock, and reflected in +// the account's next-index and last-address fields even though they were never +// persisted. This lets the caller undo exactly those in-memory mutations for a +// rolled-back batch without disturbing the persisted state. +func (s *ScopedKeyManager) EvictDerivedAddresses(account, branch, fromIndex, + toIndex uint32) { + + s.mtx.Lock() + defer s.mtx.Unlock() + + s.evictDerivedAddressCache(account, branch, fromIndex, toIndex) + s.evictDeriveOnUnlock(account, branch, fromIndex, toIndex) + + // extendAddresses also advanced this account's cached next-index and + // last-address fields. Those were not persisted by the rolled-back + // transaction, so drop the cached account entry as well; loadAccountInfo + // reloads the committed state from the database on the next read instead + // of exposing the uncommitted horizon through AccountProperties or the + // last-address getters. The lock is already held, so the delete is inlined + // rather than routed through InvalidateAccountCache. + delete(s.acctInfo, account) +} + // NewAddress returns a new address for the given account. The `change` // parameter dictates whether a change address (internal) or a receiving // address (external) should be generated. The caller is responsible for @@ -2962,6 +2995,91 @@ func (s *ScopedKeyManager) NewAddress(addrmgrNs walletdb.ReadWriteBucket, return addr, nil } +// evictDerivedAddressCache removes rolled-back derived addresses from the +// recent-address cache by scanning the bounded in-memory cache entries. +func (s *ScopedKeyManager) evictDerivedAddressCache(account, branch, fromIndex, + toIndex uint32) { + + var keys []addrKey + s.addrs.Range(func(key addrKey, cachedAddr *cachedAddr) bool { + if cachedAddr == nil { + return true + } + + match := derivedAddressMatches( + cachedAddr.addr, account, branch, fromIndex, toIndex, + ) + if match { + keys = append(keys, key) + } + + return true + }) + + for _, key := range keys { + s.addrs.Delete(key) + } +} + +// evictDeriveOnUnlock removes rolled-back pending private-key derivations for +// one account branch and index range. +func (s *ScopedKeyManager) evictDeriveOnUnlock(account, branch, fromIndex, + toIndex uint32) { + + // Drop the pending unlock-derivation entries the rolled-back extension + // queued for this account, branch, and range, filtering in place so entries + // from other accounts, branches, or unrelated extensions survive. + filtered := s.deriveOnUnlock[:0] + for _, info := range s.deriveOnUnlock { + if pendingDeriveMatches(info, account, branch, fromIndex, toIndex) { + continue + } + + filtered = append(filtered, info) + } + + // Clear the tail the in-place filter left behind so the dropped entries' + // managed addresses can be garbage collected. + for i := len(filtered); i < len(s.deriveOnUnlock); i++ { + s.deriveOnUnlock[i] = nil + } + + s.deriveOnUnlock = filtered +} + +// pendingDeriveMatches reports whether one unlock-time derivation request is in +// the rolled-back account branch and index range. +func pendingDeriveMatches(info *unlockDeriveInfo, account, branch, fromIndex, + toIndex uint32) bool { + + if info == nil || info.managedAddr == nil { + return false + } + + return info.managedAddr.InternalAccount() == account && + info.branch == branch && + info.index >= fromIndex && info.index < toIndex +} + +// derivedAddressMatches reports whether one cached address belongs to the +// rolled-back account branch and index range. +func derivedAddressMatches(addr ManagedAddress, account, branch, fromIndex, + toIndex uint32) bool { + + addrWithPath, ok := addr.(ManagedPubKeyAddress) + if !ok { + return false + } + + _, path, ok := addrWithPath.DerivationInfo() + if !ok { + return false + } + + return path.InternalAccount == account && path.Branch == branch && + path.Index >= fromIndex && path.Index < toIndex +} + // getNextIndex fetches the current next address index for the specified branch // (internal/external) of an account directly from the database. This bypasses // the in-memory cache to ensure the most up-to-date state is used, avoiding diff --git a/waddrmgr/scoped_manager_test.go b/waddrmgr/scoped_manager_test.go index 932a8cc4b5..88849d941d 100644 --- a/waddrmgr/scoped_manager_test.go +++ b/waddrmgr/scoped_manager_test.go @@ -2,6 +2,7 @@ package waddrmgr import ( "bytes" + "errors" "math" "testing" @@ -611,3 +612,414 @@ func TestDeriveAddr(t *testing.T) { }) require.NoError(t, err) } + +// TestEvictDerivedAddressesUnlocked verifies that EvictDerivedAddresses removes +// exactly the addresses derived over the given range from the recent-address +// cache, leaving addresses outside the range cached. This is the in-memory undo +// a rolled-back scan-batch horizon extension relies on so unpersisted +// scan-derived addresses do not stay observable through the cache. +func TestEvictDerivedAddressesUnlocked(t *testing.T) { + t.Parallel() + + // Arrange: an unlocked manager with a handful of derived external + // addresses cached. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + err := walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + return mgr.Unlock(ns, privPassphrase) + }) + require.NoError(t, err) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0084) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + const lastIndex = 4 + + var derived []ManagedAddress + + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + err := scopedMgr.ExtendAddresses( + ns, DefaultAccountNum, lastIndex, ExternalBranch, + ) + if err != nil { + return err + } + + // Re-derive the addresses for indices [0, lastIndex] so the test + // can address the exact cache keys ExtendAddresses inserted. + for index := uint32(0); index <= lastIndex; index++ { + addr, _, err := scopedMgr.DeriveAddr( + DefaultAccountNum, ExternalBranch, index, + ) + if err != nil { + return err + } + + ma, err := scopedMgr.Address(ns, addr) + if err != nil { + return err + } + + derived = append(derived, ma) + } + + return nil + }) + require.NoError(t, err) + require.Len(t, derived, lastIndex+1) + + // Sanity check: every derived address starts out cached. + for _, ma := range derived { + _, err := scopedMgr.addrs.Get(addrKey(ma.Address().ScriptAddress())) + require.NoError(t, err) + } + + // Act: evict the addresses derived over the upper part of the range using + // the same full-account upper bound a horizon rollback uses. Eviction must + // be bounded by the current cache contents, not by this large index span. + const evictFrom = 1 + scopedMgr.EvictDerivedAddresses( + DefaultAccountNum, ExternalBranch, evictFrom, + MaxAddressesPerAccount+1, + ) + + // Assert: indices in [evictFrom, lastIndex] are gone from the cache while + // index 0 stays cached, proving the eviction is bounded to the range. + for index, ma := range derived { + key := addrKey(ma.Address().ScriptAddress()) + _, err := scopedMgr.addrs.Get(key) + + if index < evictFrom { + require.NoError(t, err) + continue + } + + require.Error(t, err) + } +} + +// TestEvictDerivedAddressesLockedDropsPending verifies that +// EvictDerivedAddresses discards the pending unlock-derivation entries an +// extension queued while the manager was locked. A rolled-back scan batch must +// not leave addresses queued for private-key derivation on the next unlock when +// their persisted rows were rolled back. +func TestEvictDerivedAddressesLockedDropsPending(t *testing.T) { + t.Parallel() + + // Arrange: a locked manager. Extending addresses while locked queues the + // derived children for derivation on the next unlock. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0084) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + require.True(t, mgr.IsLocked()) + + const lastIndex = 3 + + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + return scopedMgr.ExtendAddresses( + ns, DefaultAccountNum, lastIndex, ExternalBranch, + ) + }) + require.NoError(t, err) + + // Sanity check: the locked extension queued the children for unlock-time + // derivation. + require.NotEmpty(t, scopedMgr.deriveOnUnlock) + + // Act: evict the queued range. + scopedMgr.EvictDerivedAddresses( + DefaultAccountNum, ExternalBranch, 0, lastIndex+1, + ) + + // Assert: no pending unlock-derivation entries remain for the evicted + // branch range. + for _, info := range scopedMgr.deriveOnUnlock { + stillQueued := info.branch == ExternalBranch && + info.index <= lastIndex + require.False(t, stillQueued) + } +} + +// TestEvictDerivedAddressesMultipleBranches verifies that a second branch +// eviction for the same account still removes branch-specific cached and +// pending state after the first eviction invalidates the account cache. +func TestEvictDerivedAddressesMultipleBranches(t *testing.T) { + t.Parallel() + + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0084) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + require.True(t, mgr.IsLocked()) + + const lastIndex = 2 + + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + err := scopedMgr.ExtendAddresses( + ns, DefaultAccountNum, lastIndex, ExternalBranch, + ) + if err != nil { + return err + } + + return scopedMgr.ExtendAddresses( + ns, DefaultAccountNum, lastIndex, InternalBranch, + ) + }) + require.NoError(t, err) + + countPending := func(branch uint32) int { + count := 0 + for _, info := range scopedMgr.deriveOnUnlock { + match := pendingDeriveMatches( + info, DefaultAccountNum, branch, 0, lastIndex+1, + ) + if match { + count++ + } + } + + return count + } + + countCached := func(branch uint32) int { + count := 0 + scopedMgr.addrs.Range(func(_ addrKey, cachedAddr *cachedAddr) bool { + if cachedAddr == nil { + return true + } + + match := derivedAddressMatches( + cachedAddr.addr, DefaultAccountNum, branch, 0, + lastIndex+1, + ) + if match { + count++ + } + + return true + }) + + return count + } + + require.GreaterOrEqual(t, countPending(ExternalBranch), int(lastIndex+1)) + require.GreaterOrEqual(t, countPending(InternalBranch), int(lastIndex+1)) + require.GreaterOrEqual(t, countCached(ExternalBranch), int(lastIndex+1)) + require.GreaterOrEqual(t, countCached(InternalBranch), int(lastIndex+1)) + + scopedMgr.EvictDerivedAddresses( + DefaultAccountNum, ExternalBranch, 0, lastIndex+1, + ) + require.Zero(t, countPending(ExternalBranch)) + require.Zero(t, countCached(ExternalBranch)) + require.GreaterOrEqual(t, countPending(InternalBranch), int(lastIndex+1)) + require.GreaterOrEqual(t, countCached(InternalBranch), int(lastIndex+1)) + + scopedMgr.mtx.RLock() + _, cached := scopedMgr.acctInfo[DefaultAccountNum] + scopedMgr.mtx.RUnlock() + require.False(t, cached) + + scopedMgr.EvictDerivedAddresses( + DefaultAccountNum, InternalBranch, 0, lastIndex+1, + ) + require.Zero(t, countPending(InternalBranch)) + require.Zero(t, countCached(InternalBranch)) +} + +// TestEvictDerivedAddressesLockedKeepsOtherAccountsPending verifies that +// EvictDerivedAddresses only removes pending unlock-derivation entries for the +// requested account. A rolled-back scan batch for one account must not discard +// pending private-key derivations queued by another account at the same branch +// and child indexes. +func TestEvictDerivedAddressesLockedKeepsOtherAccountsPending(t *testing.T) { + t.Parallel() + + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0084) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + var otherAccount uint32 + + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + err := mgr.Unlock(ns, privPassphrase) + if err != nil { + return err + } + + otherAccount, err = scopedMgr.NewAccount(ns, "acct-1") + + return err + }) + require.NoError(t, err) + require.NoError(t, mgr.Lock()) + require.True(t, mgr.IsLocked()) + + const lastIndex = 2 + + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + err := scopedMgr.ExtendAddresses( + ns, DefaultAccountNum, lastIndex, ExternalBranch, + ) + if err != nil { + return err + } + + return scopedMgr.ExtendAddresses( + ns, otherAccount, lastIndex, ExternalBranch, + ) + }) + require.NoError(t, err) + + countPending := func(account uint32) int { + count := 0 + for _, info := range scopedMgr.deriveOnUnlock { + matches := info.managedAddr.InternalAccount() == account && + info.branch == ExternalBranch && + info.index <= lastIndex + if matches { + count++ + } + } + + return count + } + + defaultPending := countPending(DefaultAccountNum) + otherPending := countPending(otherAccount) + + require.GreaterOrEqual(t, defaultPending, int(lastIndex+1)) + require.Equal(t, int(lastIndex+1), otherPending) + + scopedMgr.EvictDerivedAddresses( + DefaultAccountNum, ExternalBranch, 0, lastIndex+1, + ) + + require.Zero(t, countPending(DefaultAccountNum)) + require.Equal(t, otherPending, countPending(otherAccount)) +} + +// errForcedRollback is a static sentinel used by the rolled-back-extension +// test to force its walletdb transaction to roll back. +var errForcedRollback = errors.New("forced rollback") + +// TestEvictDerivedAddressesInvalidatesAccountCache verifies that, after a +// rolled-back horizon extension, EvictDerivedAddresses invalidates the cached +// account info so the account's external key count no longer reports the +// uncommitted horizon. extendAddresses bumps the in-memory next-index before +// the transaction commits, so a rollback leaves the cache ahead of the +// persisted state until the cache is invalidated. +func TestEvictDerivedAddressesInvalidatesAccountCache(t *testing.T) { + t.Parallel() + + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + err := walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + return mgr.Unlock(ns, privPassphrase) + }) + require.NoError(t, err) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0084) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + // Record the committed external key count before any extension. + var committedKeyCount uint32 + + err = walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + + props, err := scopedMgr.AccountProperties(ns, DefaultAccountNum) + if err != nil { + return err + } + + committedKeyCount = props.ExternalKeyCount + + return nil + }) + require.NoError(t, err) + + // Extend the external branch inside a transaction that then fails, so the + // persisted rows roll back while extendAddresses leaves the in-memory + // next-index bumped. + const lastIndex = 5 + + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + extErr := scopedMgr.ExtendAddresses( + ns, DefaultAccountNum, lastIndex, ExternalBranch, + ) + if extErr != nil { + return extErr + } + + return errForcedRollback + }) + require.ErrorIs(t, err, errForcedRollback) + + // The cache now reports the uncommitted horizon: the extension advanced + // the cached next-external-index past the committed key count. + scopedMgr.mtx.RLock() + cachedInfo, cached := scopedMgr.acctInfo[DefaultAccountNum] + require.True(t, cached) + require.Greater(t, cachedInfo.nextExternalIndex, committedKeyCount) + scopedMgr.mtx.RUnlock() + + // Undo the rolled-back extension's in-memory traces. + scopedMgr.EvictDerivedAddresses( + DefaultAccountNum, ExternalBranch, committedKeyCount, lastIndex+1, + ) + + // AccountProperties reloads the committed state, so the external key + // count no longer reflects the uncommitted horizon. + err = walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + + props, err := scopedMgr.AccountProperties(ns, DefaultAccountNum) + if err != nil { + return err + } + + require.Equal(t, committedKeyCount, props.ExternalKeyCount) + + return nil + }) + require.NoError(t, err) +} diff --git a/wallet/db_ops.go b/wallet/db_ops.go index eb5acc026e..8d0b988e31 100644 --- a/wallet/db_ops.go +++ b/wallet/db_ops.go @@ -27,6 +27,8 @@ var ( // ErrMissingTxManager is returned when the transaction manager namespace is // missing from the database. ErrMissingTxManager = errors.New("missing transaction manager namespace") + + errUnknownBranch = errors.New("unknown branch") ) // DBCreateWallet initializes the database structure for a new wallet. @@ -525,12 +527,16 @@ func (s *syncer) DBPutRewind(_ context.Context, func (s *syncer) DBPutSyncBatch(ctx context.Context, results []scanResult) error { + var horizonRollback addrHorizonRollback + // TODO(yy): build a single SQL query for this. err := walletdb.Update(s.cfg.DB, func(dbtx walletdb.ReadWriteTx) error { addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) // 1. Update Address State (Horizons). - err := s.putAddrHorizons(ctx, addrmgrNs, results) + var err error + + horizonRollback, err = s.putAddrHorizons(ctx, addrmgrNs, results) if err != nil { return err } @@ -559,6 +565,8 @@ func (s *syncer) DBPutSyncBatch(ctx context.Context, return nil }) if err != nil { + horizonRollback.evict() + return fmt.Errorf("process scan batch: %w", err) } @@ -574,11 +582,15 @@ func (s *syncer) DBPutSyncBatch(ctx context.Context, func (s *syncer) DBPutTargetedBatch(ctx context.Context, results []scanResult) error { + var horizonRollback addrHorizonRollback + err := walletdb.Update(s.cfg.DB, func(dbtx walletdb.ReadWriteTx) error { addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) // 1. Update Address State (Horizons). - err := s.putAddrHorizons(ctx, addrmgrNs, results) + var err error + + horizonRollback, err = s.putAddrHorizons(ctx, addrmgrNs, results) if err != nil { return err } @@ -592,6 +604,8 @@ func (s *syncer) DBPutTargetedBatch(ctx context.Context, return nil }) if err != nil { + horizonRollback.evict() + return fmt.Errorf("process rescan batch: %w", err) } @@ -740,13 +754,56 @@ func (s *syncer) filterBranchScopes(_ context.Context, dbtx walletdb.ReadTx, return scopes, nil } -// putAddrHorizons aggregates found address horizons from the scan -// results and updates the address manager state (extends horizons) in the -// database. -func (s *syncer) putAddrHorizons(_ context.Context, - ns walletdb.ReadWriteBucket, results []scanResult) error { +// addrHorizonExtension records one in-memory horizon extension that should be +// evicted if the surrounding walletdb transaction rolls back. +type addrHorizonExtension struct { + scopedMgr waddrmgr.AccountStore + account uint32 + branch uint32 + fromIndex uint32 + toIndex uint32 +} - // Aggregate Horizon Expansion. +// addrHorizonRollback records successful in-memory horizon extensions that +// still depend on the surrounding walletdb transaction committing. +type addrHorizonRollback []addrHorizonExtension + +// evict removes all in-memory horizon extensions tracked for a failed batch. +func (r addrHorizonRollback) evict() { + for _, extension := range r { + extension.scopedMgr.EvictDerivedAddresses( + extension.account, + extension.branch, + extension.fromIndex, + extension.toIndex, + ) + } +} + +// branchNextIndex returns the next child index for an account branch. +func branchNextIndex(scopedMgr waddrmgr.AccountStore, + ns walletdb.ReadBucket, account, branch uint32) (uint32, error) { + + props, err := scopedMgr.AccountProperties(ns, account) + if err != nil { + return 0, fmt.Errorf("account properties: %w", err) + } + + switch branch { + case waddrmgr.ExternalBranch: + return props.ExternalKeyCount, nil + + case waddrmgr.InternalBranch: + return props.InternalKeyCount, nil + + default: + return 0, fmt.Errorf("%w: %d", errUnknownBranch, branch) + } +} + +// scanBatchHorizons returns the greatest child index discovered for each branch +// across a scan batch. +func scanBatchHorizons(results []scanResult) map[waddrmgr.BranchScope]uint32 { batchHorizons := make(map[waddrmgr.BranchScope]uint32) for _, res := range results { for bs, idx := range res.FoundHorizons { @@ -758,25 +815,74 @@ func (s *syncer) putAddrHorizons(_ context.Context, } } + return batchHorizons +} + +// putAddrHorizons aggregates found address horizons from the scan results, +// updates the address manager state, and records rollback cleanup for +// successful in-memory horizon extensions. +func (s *syncer) putAddrHorizons(_ context.Context, + ns walletdb.ReadWriteBucket, + results []scanResult) (addrHorizonRollback, error) { + + batchHorizons := scanBatchHorizons(results) if len(batchHorizons) == 0 { - return nil + return nil, nil } + + var rollback addrHorizonRollback + // Update the database. for bs, maxFoundIndex := range batchHorizons { scopedMgr, err := s.addrStore.FetchScopedKeyManager(bs.Scope) if err != nil { - return fmt.Errorf("fetch scoped manager: %w", err) + return rollback, fmt.Errorf("fetch scoped manager: %w", err) + } + + fromIndex, err := branchNextIndex( + scopedMgr, ns, bs.Account, bs.Branch, + ) + if err != nil { + return rollback, err } err = scopedMgr.ExtendAddresses( ns, bs.Account, maxFoundIndex, bs.Branch, ) if err != nil { - return fmt.Errorf("extend addresses: %w", err) + return rollback, fmt.Errorf("extend addresses: %w", err) + } + + toIndex, err := branchNextIndex( + scopedMgr, ns, bs.Account, bs.Branch, + ) + if err != nil { + // ExtendAddresses mutates live caches before this read. If the + // read fails, the caller will not get a rollback entry, so evict + // a conservative range immediately. The extension can skip + // HD-invalid children and cache valid children past maxFoundIndex. + if maxFoundIndex >= fromIndex { + scopedMgr.EvictDerivedAddresses( + bs.Account, bs.Branch, fromIndex, + waddrmgr.MaxAddressesPerAccount+1, + ) + } + + return rollback, err + } + + if toIndex > fromIndex { + rollback = append(rollback, addrHorizonExtension{ + scopedMgr: scopedMgr, + account: bs.Account, + branch: bs.Branch, + fromIndex: fromIndex, + toIndex: toIndex, + }) } } - return nil + return rollback, nil } // putScanTxns processes relevant transactions found during the scan diff --git a/wallet/db_ops_test.go b/wallet/db_ops_test.go index 5f60ecca39..088b01bd34 100644 --- a/wallet/db_ops_test.go +++ b/wallet/db_ops_test.go @@ -450,20 +450,33 @@ func TestPutAddrHorizons(t *testing.T) { mocks.addrStore.On("FetchScopedKeyManager", bs.Scope).Return( scopedMgr, nil, ).Once() + scopedMgr.On("AccountProperties", mock.Anything, bs.Account).Return( + &waddrmgr.AccountProperties{ExternalKeyCount: 4}, nil, + ).Once() scopedMgr.On("ExtendAddresses", mock.Anything, bs.Account, uint32(10), bs.Branch, ).Return(nil).Once() + scopedMgr.On("AccountProperties", mock.Anything, bs.Account).Return( + &waddrmgr.AccountProperties{ExternalKeyCount: 11}, nil, + ).Once() // Act: Trigger the horizon expansion within a database transaction. + var rollback addrHorizonRollback + err := walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - return s.putAddrHorizons(t.Context(), ns, res) + var err error + + rollback, err = s.putAddrHorizons(t.Context(), ns, res) + + return err }) // Assert: Verify that the horizons were extended without error. require.NoError(t, err) + require.Len(t, rollback, 1) } // TestDBGetScanData verifies that the wallet can successfully retrieve all @@ -790,6 +803,129 @@ func TestDBPutTargetedBatch_Errors(t *testing.T) { require.ErrorIs(t, err, errDBInsert) } +// expectHorizonExtension sets the mock expectations for one successful horizon +// extension over a branch key range. +func expectHorizonExtension(scopedMgr *bwmock.AccountStore, + bs waddrmgr.BranchScope, fromIndex, maxFoundIndex, toIndex uint32) { + + before := &waddrmgr.AccountProperties{} + after := &waddrmgr.AccountProperties{} + + if bs.Branch == waddrmgr.InternalBranch { + before.InternalKeyCount = fromIndex + after.InternalKeyCount = toIndex + } else { + before.ExternalKeyCount = fromIndex + after.ExternalKeyCount = toIndex + } + + scopedMgr.On("AccountProperties", mock.Anything, bs.Account).Return( + before, nil, + ).Once() + scopedMgr.On("ExtendAddresses", mock.Anything, bs.Account, maxFoundIndex, + bs.Branch).Return(nil).Once() + scopedMgr.On("AccountProperties", mock.Anything, bs.Account).Return( + after, nil, + ).Once() +} + +// TestDBPutSyncBatchEvictsHorizonsOnSyncTipError verifies that a failed batch +// sync-tip update evicts addresses derived by the rolled-back horizon update. +func TestDBPutSyncBatchEvictsHorizonsOnSyncTipError(t *testing.T) { + t.Parallel() + + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &bwmock.AddrStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + + bs := waddrmgr.BranchScope{ + Scope: waddrmgr.KeyScopeBIP0084, + Account: 0, + Branch: waddrmgr.ExternalBranch, + } + result := scanResult{ + meta: &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{Height: 100}, + Time: time.Unix(1710005000, 0), + }, + BlockProcessResult: &BlockProcessResult{ + FoundHorizons: map[waddrmgr.BranchScope]uint32{ + bs: 10, + }, + }, + } + + scopedMgr := &bwmock.AccountStore{} + mockAddrStore.On("FetchScopedKeyManager", bs.Scope).Return( + scopedMgr, nil, + ).Once() + expectHorizonExtension(scopedMgr, bs, 4, 10, 11) + scopedMgr.On( + "EvictDerivedAddresses", bs.Account, bs.Branch, uint32(4), + uint32(11), + ).Once() + mockAddrStore.On("SetSyncedTo", mock.Anything, mock.Anything).Return( + errSetFail, + ).Once() + + err := s.DBPutSyncBatch(t.Context(), []scanResult{result}) + require.ErrorIs(t, err, errSetFail) +} + +// TestDBPutTargetedBatchEvictsHorizonsOnTxError verifies that a failed targeted +// transaction write evicts addresses derived by the rolled-back horizon update. +func TestDBPutTargetedBatchEvictsHorizonsOnTxError(t *testing.T) { + t.Parallel() + + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &bwmock.AddrStore{} + mockTxStore := &bwmock.TxStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, mockTxStore, nil) + + bs := waddrmgr.BranchScope{ + Scope: waddrmgr.KeyScopeBIP0084, + Account: 0, + Branch: waddrmgr.ExternalBranch, + } + rec, err := wtxmgr.NewTxRecordFromMsgTx(wire.NewMsgTx(1), time.Now()) + require.NoError(t, err) + + result := scanResult{ + meta: &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{Height: 100}, + Time: time.Unix(1710005100, 0), + }, + BlockProcessResult: &BlockProcessResult{ + FoundHorizons: map[waddrmgr.BranchScope]uint32{ + bs: 10, + }, + RelevantOutputs: TxEntries{{ + Rec: rec, + Entries: []AddrEntry{}, + }}, + }, + } + + scopedMgr := &bwmock.AccountStore{} + mockAddrStore.On("FetchScopedKeyManager", bs.Scope).Return( + scopedMgr, nil, + ).Once() + expectHorizonExtension(scopedMgr, bs, 4, 10, 11) + scopedMgr.On( + "EvictDerivedAddresses", bs.Account, bs.Branch, uint32(4), + uint32(11), + ).Once() + mockTxStore.On("InsertConfirmedTx", mock.Anything, mock.Anything, + mock.Anything, mock.Anything).Return(errDBInsert).Once() + + err = s.DBPutTargetedBatch(t.Context(), []scanResult{result}) + require.ErrorIs(t, err, errDBInsert) +} + // TestDBPutTxns_Error verifies error propagation in DBPutTxns. func TestDBPutTxns_Error(t *testing.T) { t.Parallel() @@ -980,12 +1116,59 @@ func TestPutAddrHorizons_Error(t *testing.T) { mock.Anything).Return(nil, errManager).Once() // Act: Attempt to persist address horizons. - err := s.putAddrHorizons(t.Context(), nil, results) + _, err := s.putAddrHorizons(t.Context(), nil, results) // Assert: Verify failure. require.ErrorIs(t, err, errManager) } +// TestPutAddrHorizonsEvictsOnPostExtensionReadError verifies that a failed +// branch state read after horizon extension evicts the in-memory derivation +// range before returning the error. +func TestPutAddrHorizonsEvictsOnPostExtensionReadError(t *testing.T) { + t.Parallel() + + mockAddrStore := &bwmock.AddrStore{} + s := newSyncer(Config{}, mockAddrStore, nil, nil) + + bs := waddrmgr.BranchScope{ + Scope: waddrmgr.KeyScopeBIP0084, + Account: 0, + Branch: waddrmgr.ExternalBranch, + } + results := []scanResult{{ + BlockProcessResult: &BlockProcessResult{ + FoundHorizons: map[waddrmgr.BranchScope]uint32{ + bs: 10, + }, + }, + }} + + scopedMgr := &bwmock.AccountStore{} + mockAddrStore.On("FetchScopedKeyManager", bs.Scope).Return( + scopedMgr, nil, + ).Once() + scopedMgr.On("AccountProperties", mock.Anything, bs.Account).Return( + &waddrmgr.AccountProperties{ExternalKeyCount: 4}, nil, + ).Once() + scopedMgr.On("ExtendAddresses", mock.Anything, bs.Account, uint32(10), + bs.Branch).Return(nil).Once() + scopedMgr.On("AccountProperties", mock.Anything, bs.Account).Return( + (*waddrmgr.AccountProperties)(nil), errManager, + ).Once() + scopedMgr.On( + "EvictDerivedAddresses", bs.Account, bs.Branch, uint32(4), + uint32(waddrmgr.MaxAddressesPerAccount+1), + ).Once() + + rollback, err := s.putAddrHorizons(t.Context(), nil, results) + require.ErrorIs(t, err, errManager) + require.Empty(t, rollback) + + mockAddrStore.AssertExpectations(t) + scopedMgr.AssertExpectations(t) +} + // TestDBGetScanData_AddressError verifies active address loading failure. func TestDBGetScanData_AddressError(t *testing.T) { t.Parallel() diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index c3c50a8254..b4b59c5c75 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -36,6 +36,14 @@ const ( // hardened-child cap of 2^31 - 1; the -2 leaves the topmost child // number for the legacy imported-account sentinel. MaxAccountNumber uint32 = (1 << 31) - 2 //nolint:mnd + + // MaxAddressIndex is the recovery horizon extension bound, not a global + // store address-allocation limit: ordinary address allocation may use the + // full uint32 child-index range. It matches waddrmgr.MaxAddressesPerAccount + // (hdkeychain.HardenedKeyStart - 1) so recovery horizon extension rejects + // the same out-of-range index the legacy non-hardened address manager + // would, keeping the SQL and kvdb recovery paths in lockstep. + MaxAddressIndex uint32 = (1 << 31) - 1 //nolint:mnd ) // ============================================================================ @@ -681,11 +689,19 @@ type AddressInfo struct { HasDerivationPath bool // Branch is the BIP44 branch number (0=external, 1=internal/change). - // Zero value for imported addresses. + // HD-derived addresses carry a real branch: this includes both normal + // derived accounts and imported-xpub watch-only children, which the + // scan-batch horizon extension derives from the account xpub and + // persists with a real path. Raw single imports (the keyless imported + // bucket) have no chain position, so the field is left at its zero + // value; use HasDerivationPath to tell the two apart. Branch uint32 - // Index is the BIP44 index within the branch. Zero value for imported - // addresses. + // Index is the BIP44 index within the branch. As with Branch, it is set + // for HD-derived addresses (normal derived accounts and imported-xpub + // watch-only children) and left at its zero value for raw single + // imports. Use HasDerivationPath to disambiguate a genuine (0, 0) HD + // child from an unset path. Index uint32 // ScriptPubKey is the script pubkey (plaintext). @@ -1092,6 +1108,51 @@ type TxBatchParams struct { SyncedTo *Block } +// ScanHorizon records the highest recovered address index for one account +// branch. +type ScanHorizon struct { + // Scope is the key scope containing the branch. + Scope KeyScope + + // AccountID is the stable store-local row identity of the horizon's owning + // account. SQL backends resolve horizons from this ID because account names + // are mutable and account numbers do not identify imported xpub accounts. + AccountID *uint32 + + // Account is the BIP44 account number containing the branch. Imported xpub + // accounts do not have a BIP44 account number, so this field is derivation + // metadata only and must not be used to identify the owning account. + Account uint32 + + // AccountName is the human-readable account name observed with the horizon. + // It is mutable metadata only and must not be used as the durable account + // identity. + AccountName string + + // Branch is the account branch number. + Branch uint32 + + // Index is the highest discovered address child index on the branch. + Index uint32 +} + +// ScanBatchParams contains the database updates produced by one recovery scan +// batch. +type ScanBatchParams struct { + // WalletID is the ID of the wallet receiving the batch. + WalletID uint32 + + // Horizons contains address horizon extensions discovered by the scan. + Horizons []ScanHorizon + + // Transactions contains relevant transaction records found by the scan. + Transactions []CreateTxParams + + // SyncedBlocks contains the synced block sequence to connect after writing + // horizons and transactions. Targeted rescans leave this empty. + SyncedBlocks []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/pg/txstore_applyscanbatch.go b/wallet/internal/db/pg/txstore_applyscanbatch.go new file mode 100644 index 0000000000..8a61f6aa35 --- /dev/null +++ b/wallet/internal/db/pg/txstore_applyscanbatch.go @@ -0,0 +1,201 @@ +package pg + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" +) + +// scanHorizonOps adapts the PostgreSQL sqlc queries to the shared horizon +// extension workflow. +type scanHorizonOps struct { + qtx *sqlc.Queries + walletID uint32 +} + +// A compile-time assertion that scanHorizonOps satisfies the shared interface. +var _ db.ScanHorizonOps = (*scanHorizonOps)(nil) + +// horizonAccountRow is the subset of an account lookup row that both the +// by-name and by-number horizon resolution paths produce, letting them share a +// single HorizonAccount builder. +type horizonAccountRow struct { + id int64 + accountNumber sql.NullInt64 + publicKey []byte + internalTypeID int16 + externalTypeID int16 + externalKeyCount int64 + internalKeyCount int64 +} + +// GetHorizonAccount loads the account state needed to extend a horizon. The +// stable AccountID is mandatory because account names are mutable and imported +// xpub accounts do not have BIP44 account numbers. +func (o scanHorizonOps) GetHorizonAccount(ctx context.Context, + horizon db.ScanHorizon) (*db.HorizonAccount, error) { + + if horizon.AccountID == nil { + return nil, fmt.Errorf("%w: scan horizon account id is required", + db.ErrInvalidParam) + } + + return o.horizonAccountByID(ctx, *horizon.AccountID, horizon.Scope) +} + +// buildHorizonAccount assembles the HorizonAccount the shared extension +// workflow needs from a resolved account row. An imported xpub account has a +// NULL account_number, surfaced as a nil AccountNumber so the derivation +// callback receives no wallet-derived number; xpub-based derivation keys off +// the account public key alone. A derived account's number is passed through. +func buildHorizonAccount(row horizonAccountRow) (*db.HorizonAccount, error) { + schema, err := db.DerivedAddressAccountSchema( + row.internalTypeID, row.externalTypeID, + ) + if err != nil { + return nil, fmt.Errorf("account addr schema: %w", err) + } + + // A derived account carries a real BIP44 number; an imported xpub + // account has a NULL account_number and is surfaced as nil so the shared + // extension presents no wallet-derived number for it. + var accountNumber *uint32 + if row.accountNumber.Valid { + num, err := db.Int64ToUint32(row.accountNumber.Int64) + if err != nil { + return nil, fmt.Errorf("account number: %w", err) + } + + accountNumber = &num + } + + nextExternal, err := db.Int64ToUint32(row.externalKeyCount) + if err != nil { + return nil, fmt.Errorf("external next index: %w", err) + } + + nextInternal, err := db.Int64ToUint32(row.internalKeyCount) + if err != nil { + return nil, fmt.Errorf("internal next index: %w", err) + } + + return &db.HorizonAccount{ + AccountID: row.id, + AccountNumber: accountNumber, + AccountPubKey: row.publicKey, + AddrSchema: schema, + NextExternalIndex: nextExternal, + NextInternalIndex: nextInternal, + }, nil +} + +// errAddressBranchOutOfRange is returned when a derived address branch index +// does not fit the SMALLINT address_branch column. +var errAddressBranchOutOfRange = errors.New( + "address branch out of int16 range", +) + +// InsertDerivedAddress persists one derived address row at a fixed index. +func (o scanHorizonOps) InsertDerivedAddress(ctx context.Context, + accountID int64, addrType db.AddressType, branch uint32, index uint32, + scriptPubKey []byte, pubKey []byte) error { + + if branch > math.MaxInt16 { + return fmt.Errorf("%w: %d", errAddressBranchOutOfRange, branch) + } + + row, err := o.qtx.CreateDerivedAddress( + ctx, buildDerivedAddressParams( + int64(o.walletID), accountID, addrType, scriptPubKey, pubKey, + ), + ) + if err != nil { + return fmt.Errorf("create derived address: %w", err) + } + + err = o.qtx.CreateDerivedAddressPath( + ctx, sqlc.CreateDerivedAddressPathParams{ + AddressID: row.ID, + AccountID: accountID, + AddressBranch: int16(branch), + AddressIndex: int64(index), + }, + ) + if err != nil { + return fmt.Errorf("create derived address path: %w", err) + } + + return nil +} + +// AdvanceNextIndex moves the branch's next-index counter up to nextIndex. +func (o scanHorizonOps) AdvanceNextIndex(ctx context.Context, accountID int64, + branch uint32, nextIndex uint32) error { + + if branch == 1 { + err := o.qtx.AdvanceNextInternalIndex( + ctx, sqlc.AdvanceNextInternalIndexParams{ + NextIndex: int64(nextIndex), + ID: accountID, + }, + ) + if err != nil { + return fmt.Errorf("advance internal index: %w", err) + } + + return nil + } + + err := o.qtx.AdvanceNextExternalIndex( + ctx, sqlc.AdvanceNextExternalIndexParams{ + NextIndex: int64(nextIndex), + ID: accountID, + }, + ) + if err != nil { + return fmt.Errorf("advance external index: %w", err) + } + + return nil +} + +// horizonAccountByID resolves the horizon's owning account by stable account +// row ID and verifies that the account belongs to the horizon's key scope. +func (o scanHorizonOps) horizonAccountByID(ctx context.Context, + accountID uint32, scope db.KeyScope) (*db.HorizonAccount, error) { + + row, err := o.qtx.GetAccountPropsByWalletAndId( + ctx, sqlc.GetAccountPropsByWalletAndIdParams{ + WalletID: int64(o.walletID), + ID: int64(accountID), + }, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, db.ErrAccountNotFound + } + + if err != nil { + return nil, fmt.Errorf("get account by id %d: %w", accountID, err) + } + + scopeMatches := row.Purpose == int64(scope.Purpose) && + row.CoinType == int64(scope.Coin) + if !scopeMatches { + return nil, db.ErrAccountNotFound + } + + return buildHorizonAccount(horizonAccountRow{ + id: int64(accountID), + accountNumber: row.AccountNumber, + publicKey: row.PublicKey, + internalTypeID: row.InternalTypeID, + externalTypeID: row.ExternalTypeID, + externalKeyCount: row.ExternalKeyCount, + internalKeyCount: row.InternalKeyCount, + }) +} diff --git a/wallet/internal/db/scan_batch_common.go b/wallet/internal/db/scan_batch_common.go new file mode 100644 index 0000000000..93a0e4a83f --- /dev/null +++ b/wallet/internal/db/scan_batch_common.go @@ -0,0 +1,239 @@ +package db + +import ( + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcutil/v2/hdkeychain" +) + +// externalBranch and internalBranch are the BIP44 branch numbers the horizon +// extension distinguishes when picking the address type and next-index column. +const ( + externalBranch uint32 = 0 + internalBranch uint32 = 1 +) + +// HorizonAccount carries the account state a single horizon extension needs: +// the row ID, the account-level extended public key, the effective address +// schema, and the branch next-index counters as they stand before extension. +type HorizonAccount struct { + // AccountID is the database row ID of the account. + AccountID int64 + + // AccountNumber is the wallet-derived BIP44 account number used by the + // derivation callback. It is nil for imported xpub accounts, which have + // no wallet-derived number and derive children from AccountPubKey alone, + // mirroring AddressDerivationParams.DerivedAccountNumber. + AccountNumber *uint32 + + // AccountPubKey is the plaintext account-level extended public key the + // derivation callback derives child addresses from. + AccountPubKey []byte + + // AddrSchema is the account's effective external/internal address schema. + AddrSchema ScopeAddrSchema + + // NextExternalIndex is the next unused external (branch 0) child index. + NextExternalIndex uint32 + + // NextInternalIndex is the next unused internal (branch 1) child index. + NextInternalIndex uint32 +} + +// branchState returns the address type and current next index for the +// requested branch. +func (a *HorizonAccount) branchState(branch uint32) (AddressType, uint32) { + if branch == internalBranch { + return a.AddrSchema.InternalAddrType, a.NextInternalIndex + } + + return a.AddrSchema.ExternalAddrType, a.NextExternalIndex +} + +// ScanHorizonOps abstracts the backend SQL operations one recovery horizon +// extension performs. Implementations run inside the caller's write +// transaction so the whole scan batch stays atomic. +type ScanHorizonOps interface { + // GetHorizonAccount loads the account a horizon targets. Implementations + // must resolve by horizon.AccountID, which is the stable account row + // identity. AccountName is mutable and Account does not identify imported + // xpub accounts, so neither field may be used as the durable target. + // Implementations return ErrAccountNotFound when no such account exists. + GetHorizonAccount(ctx context.Context, + horizon ScanHorizon) (*HorizonAccount, error) + + // InsertDerivedAddress persists one HD-derived address row at the given + // branch and child index. + InsertDerivedAddress(ctx context.Context, accountID int64, + addrType AddressType, branch uint32, index uint32, + scriptPubKey []byte, pubKey []byte) error + + // AdvanceNextIndex moves the branch's next-index counter up to nextIndex. + // Implementations apply a monotonic guard so a concurrent slower writer + // cannot regress the counter. + AdvanceNextIndex(ctx context.Context, accountID int64, branch uint32, + nextIndex uint32) error +} + +// ExtendScanHorizon ensures every valid child through horizon.Index is derived +// and persisted on the requested branch, mirroring the legacy +// ScopedKeyManager.ExtendAddresses semantics used by the kvdb backend: +// +// - Derivation starts from the branch's current next-index and runs through +// horizon.Index inclusive. +// - When horizon.Index is already below the current next-index the call is a +// no-op, so replaying a horizon never inserts duplicate rows. +// - HD-invalid child indices are skipped (the next-index simply advances) +// instead of failing, so the SQL path matches the kvdb invalid-child skip. +// - After derivation the branch next-index advances to one past the last +// derived child, leaving the same terminal counter the legacy path would. +func ExtendScanHorizon(ctx context.Context, ops ScanHorizonOps, + deriveFn AddressDerivationFunc, horizon ScanHorizon) error { + + branch, err := validateHorizon(deriveFn, horizon) + if err != nil { + return err + } + + account, err := ops.GetHorizonAccount(ctx, horizon) + if err != nil { + return fmt.Errorf("extend horizon: %w", err) + } + + addrType, nextIndex := account.branchState(branch) + + // Nothing to do when the scan did not advance past the persisted tip. + if horizon.Index < nextIndex { + return nil + } + + // kvdb caps a single extension at MaxAddressesPerAccount; mirror the bound + // for horizons that actually require new derivation work. + if horizon.Index > MaxAddressIndex { + return fmt.Errorf("extend horizon: %w", ErrMaxAddressIndexReached) + } + + nextIndex, err = deriveHorizonRange( + ctx, ops, deriveFn, account, horizon, branch, addrType, nextIndex, + ) + if err != nil { + return err + } + + // Persist the advanced next-index so subsequent address allocation and + // horizon replays resume past the derived range. + err = ops.AdvanceNextIndex(ctx, account.AccountID, branch, nextIndex) + if err != nil { + return fmt.Errorf("extend horizon: advance next index: %w", err) + } + + return nil +} + +// validateHorizon checks the derivation callback and branch number, returning +// the validated branch number. +func validateHorizon(deriveFn AddressDerivationFunc, + horizon ScanHorizon) (uint32, error) { + + if deriveFn == nil { + return 0, fmt.Errorf("extend horizon: %w", + errNilAddressDerivationFunc) + } + + // Recovery only ever reports the external or internal branch; reject + // anything else up front so an unexpected branch cannot silently derive + // against the wrong next-index column. + branch := horizon.Branch + if branch != externalBranch && branch != internalBranch { + return 0, fmt.Errorf("extend horizon: %w: branch %d", + ErrInvalidParam, branch) + } + + return branch, nil +} + +// deriveHorizonRange derives and persists one valid child per index from +// nextIndex through horizon.Index inclusive and returns the advanced next +// index. It mirrors the nested loop in the legacy extendAddresses: each outer +// step finds the next valid child, skipping HD-invalid indices even past +// horizon.Index, so the terminal next index matches the address manager. +func deriveHorizonRange(ctx context.Context, ops ScanHorizonOps, + deriveFn AddressDerivationFunc, account *HorizonAccount, + horizon ScanHorizon, branch uint32, addrType AddressType, + nextIndex uint32) (uint32, error) { + + for nextIndex <= horizon.Index { + next, err := deriveNextValidChild( + ctx, ops, deriveFn, account, horizon.Scope, branch, addrType, + nextIndex, + ) + if err != nil { + return 0, err + } + + nextIndex = next + } + + return nextIndex, nil +} + +// deriveNextValidChild derives and persists the first valid child at or after +// startIndex, returning the index immediately past the persisted child. +// HD-invalid children are skipped without persisting a row, exactly like the +// inner loop of the legacy extendAddresses. +func deriveNextValidChild(ctx context.Context, ops ScanHorizonOps, + deriveFn AddressDerivationFunc, account *HorizonAccount, scope KeyScope, + branch uint32, addrType AddressType, startIndex uint32) (uint32, error) { + + accountID, err := Int64ToUint32(account.AccountID) + if err != nil { + return 0, fmt.Errorf("extend horizon: account id: %w", err) + } + + for index := startIndex; ; index++ { + // Re-check the recovery bound on every candidate: an invalid-child + // skip below can advance index to MaxAddressIndex+1, and without + // this guard the loop would derive and persist an out-of-range + // child past the bound ExtendScanHorizon enforced for horizon.Index. + if index > MaxAddressIndex { + return 0, fmt.Errorf("extend horizon: %w", + ErrMaxAddressIndexReached) + } + + derived, err := deriveFn(ctx, AddressDerivationParams{ + AccountID: &accountID, + Scope: scope, + DerivedAccountNumber: account.AccountNumber, + Branch: branch, + Index: index, + AddrType: addrType, + AccountPubKey: account.AccountPubKey, + }) + if errors.Is(err, hdkeychain.ErrInvalidChild) { + continue + } + + if err != nil { + return 0, fmt.Errorf("extend horizon: derive index %d: %w", + index, err) + } + + if derived == nil { + return 0, fmt.Errorf("extend horizon: derive index %d: %w", + index, errNilDerivedAddressData) + } + + err = ops.InsertDerivedAddress( + ctx, account.AccountID, addrType, branch, index, + derived.ScriptPubKey, derived.PubKey, + ) + if err != nil { + return 0, fmt.Errorf("extend horizon: insert index %d: %w", + index, err) + } + + return index + 1, nil + } +} diff --git a/wallet/internal/db/scan_batch_common_test.go b/wallet/internal/db/scan_batch_common_test.go new file mode 100644 index 0000000000..b75c3f4e29 --- /dev/null +++ b/wallet/internal/db/scan_batch_common_test.go @@ -0,0 +1,364 @@ +package db + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/btcutil/v2/hdkeychain" + "github.com/stretchr/testify/require" +) + +// fakeHorizonOps is a minimal ScanHorizonOps used by the ExtendScanHorizon +// tests. It records the inserted child indices and the final advanced +// next-index so each scenario can assert the exact derivation work performed. +type fakeHorizonOps struct { + account *HorizonAccount + + inserted []uint32 + advancedTo uint32 + advanceCall bool +} + +// GetHorizonAccount returns the preconfigured account. +func (o *fakeHorizonOps) GetHorizonAccount(_ context.Context, + _ ScanHorizon) (*HorizonAccount, error) { + + return o.account, nil +} + +// InsertDerivedAddress records the child index that was derived and persisted. +func (o *fakeHorizonOps) InsertDerivedAddress(_ context.Context, _ int64, + _ AddressType, _ uint32, index uint32, _ []byte, _ []byte) error { + + o.inserted = append(o.inserted, index) + + return nil +} + +// AdvanceNextIndex records the advanced next-index value. +func (o *fakeHorizonOps) AdvanceNextIndex(_ context.Context, _ int64, + _ uint32, nextIndex uint32) error { + + o.advanceCall = true + o.advancedTo = nextIndex + + return nil +} + +// validChildDeriveFunc returns a derivation callback that always derives a +// valid address, recording nothing of the input. +func validChildDeriveFunc() AddressDerivationFunc { + return func(_ context.Context, + _ AddressDerivationParams) (*DerivedAddressData, error) { + + return &DerivedAddressData{ + ScriptPubKey: []byte{0x00, 0x14}, + PubKey: []byte{0x02}, + }, nil + } +} + +// TestExtendScanHorizonNoOpBelowNextIndex verifies that ExtendScanHorizon does +// nothing when the discovered index is below the branch's current next index, +// so replaying an already-covered horizon never re-derives or advances. +func TestExtendScanHorizonNoOpBelowNextIndex(t *testing.T) { + t.Parallel() + + ops := &fakeHorizonOps{ + account: &HorizonAccount{ + AccountID: 7, + NextExternalIndex: 5, + }, + } + + // The horizon index is below the persisted next-external index, so the + // call must be a no-op. + err := ExtendScanHorizon(t.Context(), ops, validChildDeriveFunc(), + ScanHorizon{Branch: externalBranch, Index: 3}) + require.NoError(t, err) + + require.Empty(t, ops.inserted) + require.False(t, ops.advanceCall) +} + +// TestExtendScanHorizonNoOpAboveMaxBelowNextIndex verifies that a horizon above +// MaxAddressIndex is still accepted when the branch's next index already covers +// it, because no new recovery derivation is needed. +func TestExtendScanHorizonNoOpAboveMaxBelowNextIndex(t *testing.T) { + t.Parallel() + + ops := &fakeHorizonOps{ + account: &HorizonAccount{ + AccountID: 7, + NextExternalIndex: MaxAddressIndex + 2, + }, + } + + err := ExtendScanHorizon(t.Context(), ops, validChildDeriveFunc(), + ScanHorizon{Branch: externalBranch, Index: MaxAddressIndex + 1}) + require.NoError(t, err) + + require.Empty(t, ops.inserted) + require.False(t, ops.advanceCall) +} + +// TestExtendScanHorizonRejectsInvalidBranch verifies that a branch other than +// external or internal is rejected with ErrInvalidParam before any account +// load or derivation. +func TestExtendScanHorizonRejectsInvalidBranch(t *testing.T) { + t.Parallel() + + ops := &fakeHorizonOps{account: &HorizonAccount{AccountID: 7}} + + err := ExtendScanHorizon(t.Context(), ops, validChildDeriveFunc(), + ScanHorizon{Branch: 2, Index: 1}) + require.ErrorIs(t, err, ErrInvalidParam) + + require.Empty(t, ops.inserted) + require.False(t, ops.advanceCall) +} + +// TestExtendScanHorizonRejectsMaxIndex verifies that a horizon index above +// MaxAddressIndex is rejected with ErrMaxAddressIndexReached, matching the +// legacy address manager's per-account child bound. +func TestExtendScanHorizonRejectsMaxIndex(t *testing.T) { + t.Parallel() + + ops := &fakeHorizonOps{account: &HorizonAccount{AccountID: 7}} + + err := ExtendScanHorizon(t.Context(), ops, validChildDeriveFunc(), + ScanHorizon{Branch: externalBranch, Index: MaxAddressIndex + 1}) + require.ErrorIs(t, err, ErrMaxAddressIndexReached) + + require.Empty(t, ops.inserted) + require.False(t, ops.advanceCall) +} + +// TestExtendScanHorizonSkipsInvalidChild verifies that an HD-invalid child +// index is skipped without persisting a row while derivation advances to the +// next valid child, mirroring the legacy extendAddresses inner loop. +func TestExtendScanHorizonSkipsInvalidChild(t *testing.T) { + t.Parallel() + + ops := &fakeHorizonOps{ + account: &HorizonAccount{ + AccountID: 7, + NextExternalIndex: 0, + }, + } + + // Index 1 derives an ErrInvalidChild, so it must be skipped: only indices + // 0 and 2 are persisted while the loop still reaches horizon index 2. + deriveFn := func(_ context.Context, + params AddressDerivationParams) (*DerivedAddressData, error) { + + if params.Index == 1 { + return nil, hdkeychain.ErrInvalidChild + } + + return &DerivedAddressData{ + ScriptPubKey: []byte{0x00, 0x14}, + PubKey: []byte{0x02}, + }, nil + } + + err := ExtendScanHorizon(t.Context(), ops, deriveFn, + ScanHorizon{Branch: externalBranch, Index: 2}) + require.NoError(t, err) + + // Index 1 was skipped; indices 0 and 2 were persisted. + require.Equal(t, []uint32{0, 2}, ops.inserted) + + // The next index advanced past the last derived child. + require.True(t, ops.advanceCall) + require.Equal(t, uint32(3), ops.advancedTo) +} + +// TestExtendScanHorizonInvalidChildSkipRespectsMaxIndex verifies that an +// invalid-child skip cannot derive or persist a child past MaxAddressIndex. +// The horizon index sits exactly at the bound, but the child there is +// HD-invalid; skipping it advances the candidate to MaxAddressIndex+1, which +// the extension must reject with ErrMaxAddressIndexReached rather than derive +// and insert an out-of-range row. Without the per-candidate bound re-check the +// loop would persist a child at MaxAddressIndex+1, exceeding the recovery +// bound validateHorizon enforces for the horizon index itself. +func TestExtendScanHorizonInvalidChildSkipRespectsMaxIndex(t *testing.T) { + t.Parallel() + + // The branch's next index is already the bound, so the only candidate in + // range is MaxAddressIndex itself. + ops := &fakeHorizonOps{ + account: &HorizonAccount{ + AccountID: 7, + NextExternalIndex: MaxAddressIndex, + }, + } + + // The child at the bound is HD-invalid, so the inner loop skips it and + // steps to MaxAddressIndex+1. The stub then reports a perfectly valid + // child there: an unguarded loop would derive and INSERT that out-of-range + // row before terminating, so the bound re-check, not the stub, must be + // what stops the loop. + deriveFn := func(_ context.Context, + params AddressDerivationParams) (*DerivedAddressData, error) { + + if params.Index == MaxAddressIndex { + return nil, hdkeychain.ErrInvalidChild + } + + return &DerivedAddressData{ + ScriptPubKey: []byte{0x00, 0x14}, + PubKey: []byte{0x02}, + }, nil + } + + // The horizon index sits at the bound, so validateHorizon admits it and + // the failure can only come from the post-skip candidate at + // MaxAddressIndex+1. + err := ExtendScanHorizon(t.Context(), ops, deriveFn, + ScanHorizon{Branch: externalBranch, Index: MaxAddressIndex}) + require.ErrorIs(t, err, ErrMaxAddressIndexReached) + + // No child was persisted at or past the bound, and the next-index was + // never advanced. + require.Empty(t, ops.inserted) + require.False(t, ops.advanceCall) +} + +// TestExtendScanHorizonAdvancesAfterInserts verifies that, after deriving the +// full range, ExtendScanHorizon persists every child and advances the branch +// next-index to one past the last derived child. +func TestExtendScanHorizonAdvancesAfterInserts(t *testing.T) { + t.Parallel() + + ops := &fakeHorizonOps{ + account: &HorizonAccount{ + AccountID: 7, + NextInternalIndex: 1, + AddrSchema: ScopeAddrSchema{ + InternalAddrType: WitnessPubKey, + ExternalAddrType: WitnessPubKey, + }, + }, + } + + // Extend the internal branch from its next index 1 through index 3. + err := ExtendScanHorizon(t.Context(), ops, validChildDeriveFunc(), + ScanHorizon{Branch: internalBranch, Index: 3}) + require.NoError(t, err) + + require.Equal(t, []uint32{1, 2, 3}, ops.inserted) + require.True(t, ops.advanceCall) + require.Equal(t, uint32(4), ops.advancedTo) +} + +// capturingDeriveFunc returns a derivation callback that appends every +// DerivedAccountNumber it is handed to seen and, when requested, every +// AccountID it is handed to seenIDs. It lets tests assert the exact account +// identities the shared extension presents per child. +func capturingDeriveFunc(seen *[]*uint32, + seenIDs ...*[]*uint32) AddressDerivationFunc { + + return func(_ context.Context, + params AddressDerivationParams) (*DerivedAddressData, error) { + + *seen = append(*seen, params.DerivedAccountNumber) + if len(seenIDs) > 0 { + *seenIDs[0] = append(*seenIDs[0], params.AccountID) + } + + return &DerivedAddressData{ + ScriptPubKey: []byte{0x00, 0x14}, + PubKey: []byte{0x02}, + }, nil + } +} + +// TestExtendScanHorizonImportedAccountNilNumber verifies that extending the +// horizon of an imported xpub account presents a nil DerivedAccountNumber to +// the derivation callback. An imported account has no wallet-derived BIP44 +// number (HorizonAccount.AccountNumber is nil), so the shared extension must +// forward nil and let derivation key off the account public key alone, +// honouring the AddressDerivationParams.DerivedAccountNumber contract. The old +// non-pointer field forced a literal account 0 here, masking an imported +// account as the default derived account. +func TestExtendScanHorizonImportedAccountNilNumber(t *testing.T) { + t.Parallel() + + // An imported xpub account: no wallet-derived number. + ops := &fakeHorizonOps{ + account: &HorizonAccount{ + AccountID: 7, + AccountNumber: nil, + NextExternalIndex: 0, + AddrSchema: ScopeAddrSchema{ + ExternalAddrType: WitnessPubKey, + InternalAddrType: WitnessPubKey, + }, + }, + } + + var ( + seen []*uint32 + seenIDs []*uint32 + ) + + err := ExtendScanHorizon(t.Context(), ops, + capturingDeriveFunc(&seen, &seenIDs), + ScanHorizon{Branch: externalBranch, Index: 1}) + require.NoError(t, err) + + // Two children were derived, and each must have been handed a nil + // account number rather than a pointer to a fabricated account 0. The + // Store account ID remains available for callbacks that need stable row + // identity. + require.Equal(t, []uint32{0, 1}, ops.inserted) + require.Len(t, seen, 2) + require.Len(t, seenIDs, 2) + + for i, num := range seen { + require.Nilf(t, num, "child %d must carry a nil account number", i) + require.NotNilf(t, seenIDs[i], "child %d must carry an account ID", i) + require.Equalf(t, uint32(7), *seenIDs[i], + "child %d must carry the store account ID", i) + } +} + +// TestExtendScanHorizonDerivedAccountKeepsNumber verifies that extending a +// wallet-derived account's horizon presents that account's real BIP44 number +// to the derivation callback, the contrasting shape to the imported-account +// case. It guards against a regression that would drop the derived number to +// nil along with the imported fix. +func TestExtendScanHorizonDerivedAccountKeepsNumber(t *testing.T) { + t.Parallel() + + accountNumber := uint32(5) + + // A wallet-derived account: a real BIP44 number is present. + ops := &fakeHorizonOps{ + account: &HorizonAccount{ + AccountID: 9, + AccountNumber: &accountNumber, + NextExternalIndex: 0, + AddrSchema: ScopeAddrSchema{ + ExternalAddrType: WitnessPubKey, + InternalAddrType: WitnessPubKey, + }, + }, + } + + var seen []*uint32 + + err := ExtendScanHorizon(t.Context(), ops, capturingDeriveFunc(&seen), + ScanHorizon{Branch: externalBranch, Index: 1}) + require.NoError(t, err) + + require.Equal(t, []uint32{0, 1}, ops.inserted) + require.Len(t, seen, 2) + + for i, num := range seen { + require.NotNilf(t, num, "child %d must carry an account number", i) + require.Equalf(t, accountNumber, *num, + "child %d must carry the derived account number", i) + } +} diff --git a/wallet/internal/db/sqlite/txstore_applyscanbatch.go b/wallet/internal/db/sqlite/txstore_applyscanbatch.go new file mode 100644 index 0000000000..d3a4487775 --- /dev/null +++ b/wallet/internal/db/sqlite/txstore_applyscanbatch.go @@ -0,0 +1,190 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" +) + +// scanHorizonOps adapts the SQLite sqlc queries to the shared horizon +// extension workflow. +type scanHorizonOps struct { + qtx *sqlc.Queries + walletID uint32 +} + +// A compile-time assertion that scanHorizonOps satisfies the shared interface. +var _ db.ScanHorizonOps = (*scanHorizonOps)(nil) + +// horizonAccountRow is the subset of an account lookup row that both the +// by-name and by-number horizon resolution paths produce, letting them share a +// single HorizonAccount builder. +type horizonAccountRow struct { + id int64 + accountNumber sql.NullInt64 + publicKey []byte + internalTypeID int64 + externalTypeID int64 + externalKeyCount int64 + internalKeyCount int64 +} + +// GetHorizonAccount loads the account state needed to extend a horizon. The +// stable AccountID is mandatory because account names are mutable and imported +// xpub accounts do not have BIP44 account numbers. +func (o scanHorizonOps) GetHorizonAccount(ctx context.Context, + horizon db.ScanHorizon) (*db.HorizonAccount, error) { + + if horizon.AccountID == nil { + return nil, fmt.Errorf("%w: scan horizon account id is required", + db.ErrInvalidParam) + } + + return o.horizonAccountByID(ctx, *horizon.AccountID, horizon.Scope) +} + +// buildHorizonAccount assembles the HorizonAccount the shared extension +// workflow needs from a resolved account row. An imported xpub account has a +// NULL account_number, surfaced as a nil AccountNumber so the derivation +// callback receives no wallet-derived number; xpub-based derivation keys off +// the account public key alone. A derived account's number is passed through. +func buildHorizonAccount(row horizonAccountRow) (*db.HorizonAccount, error) { + schema, err := db.DerivedAddressAccountSchema( + row.internalTypeID, row.externalTypeID, + ) + if err != nil { + return nil, fmt.Errorf("account addr schema: %w", err) + } + + // A derived account carries a real BIP44 number; an imported xpub + // account has a NULL account_number and is surfaced as nil so the shared + // extension presents no wallet-derived number for it. + var accountNumber *uint32 + if row.accountNumber.Valid { + num, err := db.Int64ToUint32(row.accountNumber.Int64) + if err != nil { + return nil, fmt.Errorf("account number: %w", err) + } + + accountNumber = &num + } + + nextExternal, err := db.Int64ToUint32(row.externalKeyCount) + if err != nil { + return nil, fmt.Errorf("external next index: %w", err) + } + + nextInternal, err := db.Int64ToUint32(row.internalKeyCount) + if err != nil { + return nil, fmt.Errorf("internal next index: %w", err) + } + + return &db.HorizonAccount{ + AccountID: row.id, + AccountNumber: accountNumber, + AccountPubKey: row.publicKey, + AddrSchema: schema, + NextExternalIndex: nextExternal, + NextInternalIndex: nextInternal, + }, nil +} + +// InsertDerivedAddress persists one derived address row at a fixed index. +func (o scanHorizonOps) InsertDerivedAddress(ctx context.Context, + accountID int64, addrType db.AddressType, branch uint32, index uint32, + scriptPubKey []byte, pubKey []byte) error { + + row, err := o.qtx.CreateDerivedAddress( + ctx, buildDerivedAddressParams( + int64(o.walletID), accountID, addrType, scriptPubKey, pubKey, + ), + ) + if err != nil { + return fmt.Errorf("create derived address: %w", err) + } + + err = o.qtx.CreateDerivedAddressPath( + ctx, sqlc.CreateDerivedAddressPathParams{ + AddressID: row.ID, + AccountID: accountID, + AddressBranch: int64(branch), + AddressIndex: int64(index), + }, + ) + if err != nil { + return fmt.Errorf("create derived address path: %w", err) + } + + return nil +} + +// AdvanceNextIndex moves the branch's next-index counter up to nextIndex. +func (o scanHorizonOps) AdvanceNextIndex(ctx context.Context, accountID int64, + branch uint32, nextIndex uint32) error { + + if branch == 1 { + err := o.qtx.AdvanceNextInternalIndex( + ctx, sqlc.AdvanceNextInternalIndexParams{ + NextIndex: int64(nextIndex), + ID: accountID, + }, + ) + if err != nil { + return fmt.Errorf("advance internal index: %w", err) + } + + return nil + } + + err := o.qtx.AdvanceNextExternalIndex( + ctx, sqlc.AdvanceNextExternalIndexParams{ + NextIndex: int64(nextIndex), + ID: accountID, + }, + ) + if err != nil { + return fmt.Errorf("advance external index: %w", err) + } + + return nil +} + +// horizonAccountByID resolves the horizon's owning account by stable account +// row ID and verifies that the account belongs to the horizon's key scope. +func (o scanHorizonOps) horizonAccountByID(ctx context.Context, + accountID uint32, scope db.KeyScope) (*db.HorizonAccount, error) { + + row, err := o.qtx.GetAccountPropsByWalletAndId( + ctx, sqlc.GetAccountPropsByWalletAndIdParams{ + WalletID: int64(o.walletID), + ID: int64(accountID), + }, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, db.ErrAccountNotFound + } + + if err != nil { + return nil, fmt.Errorf("get account by id %d: %w", accountID, err) + } + + scopeMatches := row.Purpose == int64(scope.Purpose) && + row.CoinType == int64(scope.Coin) + if !scopeMatches { + return nil, db.ErrAccountNotFound + } + + return buildHorizonAccount(horizonAccountRow{ + id: int64(accountID), + accountNumber: row.AccountNumber, + publicKey: row.PublicKey, + internalTypeID: row.InternalTypeID, + externalTypeID: row.ExternalTypeID, + externalKeyCount: row.ExternalKeyCount, + internalKeyCount: row.InternalKeyCount, + }) +} diff --git a/wallet/internal/sql/pg/queries/accounts.sql b/wallet/internal/sql/pg/queries/accounts.sql index 0f2dc09a2f..a859e7298e 100644 --- a/wallet/internal/sql/pg/queries/accounts.sql +++ b/wallet/internal/sql/pg/queries/accounts.sql @@ -168,7 +168,30 @@ SELECT FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id INNER JOIN wallets AS w ON a.wallet_id = w.id -WHERE a.id = $1; +WHERE a.id = $1 +FOR UPDATE OF a; + +-- name: GetAccountPropsByWalletAndId :one +-- Returns full account properties by wallet id and account id. +SELECT + a.account_number, + a.account_name, + a.is_derived, + a.public_key, + a.master_fingerprint, + a.created_at, + ks.purpose, + ks.coin_type, + ks.internal_type_id, + ks.external_type_id, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + w.is_watch_only AS wallet_is_watch_only +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +INNER JOIN wallets AS w ON a.wallet_id = w.id +WHERE a.wallet_id = $1 AND a.id = $2 +FOR UPDATE OF a; -- name: ListAccountsByScope :many -- Lists all accounts in a scope. Accounts without BIP44 numbers appear last. @@ -406,3 +429,27 @@ WHERE OR (acc.is_derived = FALSE AND acc.account_number IS NULL) ) GROUP BY da.account_id; + +-- name: AdvanceNextExternalIndex :exec +-- Advances the external branch's next index to the supplied value during +-- recovery horizon extension. The GREATEST guard keeps the counter monotonic +-- so a slower concurrent writer cannot regress it below an already-recorded +-- index. +UPDATE accounts +SET + next_external_index = greatest( + next_external_index, sqlc.arg('next_index') + ) +WHERE id = sqlc.arg('id'); + +-- name: AdvanceNextInternalIndex :exec +-- Advances the internal/change branch's next index to the supplied value +-- during recovery horizon extension. The GREATEST guard keeps the counter +-- monotonic so a slower concurrent writer cannot regress it below an +-- already-recorded index. +UPDATE accounts +SET + next_internal_index = greatest( + next_internal_index, sqlc.arg('next_index') + ) +WHERE id = sqlc.arg('id'); diff --git a/wallet/internal/sql/pg/sqlc/accounts.sql.go b/wallet/internal/sql/pg/sqlc/accounts.sql.go index d492128971..505d44fbf9 100644 --- a/wallet/internal/sql/pg/sqlc/accounts.sql.go +++ b/wallet/internal/sql/pg/sqlc/accounts.sql.go @@ -161,6 +161,52 @@ func (q *Queries) AccountBalancesByIDs(ctx context.Context, arg AccountBalancesB return items, nil } +const AdvanceNextExternalIndex = `-- name: AdvanceNextExternalIndex :exec +UPDATE accounts +SET + next_external_index = greatest( + next_external_index, $1 + ) +WHERE id = $2 +` + +type AdvanceNextExternalIndexParams struct { + NextIndex int64 + ID int64 +} + +// Advances the external branch's next index to the supplied value during +// recovery horizon extension. The GREATEST guard keeps the counter monotonic +// so a slower concurrent writer cannot regress it below an already-recorded +// index. +func (q *Queries) AdvanceNextExternalIndex(ctx context.Context, arg AdvanceNextExternalIndexParams) error { + _, err := q.exec(ctx, q.advanceNextExternalIndexStmt, AdvanceNextExternalIndex, arg.NextIndex, arg.ID) + return err +} + +const AdvanceNextInternalIndex = `-- name: AdvanceNextInternalIndex :exec +UPDATE accounts +SET + next_internal_index = greatest( + next_internal_index, $1 + ) +WHERE id = $2 +` + +type AdvanceNextInternalIndexParams struct { + NextIndex int64 + ID int64 +} + +// Advances the internal/change branch's next index to the supplied value +// during recovery horizon extension. The GREATEST guard keeps the counter +// monotonic so a slower concurrent writer cannot regress it below an +// already-recorded index. +func (q *Queries) AdvanceNextInternalIndex(ctx context.Context, arg AdvanceNextInternalIndexParams) error { + _, err := q.exec(ctx, q.advanceNextInternalIndexStmt, AdvanceNextInternalIndex, arg.NextIndex, arg.ID) + return err +} + const CreateAccountSecret = `-- name: CreateAccountSecret :exec INSERT INTO account_secrets ( account_id, @@ -589,6 +635,7 @@ FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id INNER JOIN wallets AS w ON a.wallet_id = w.id WHERE a.id = $1 +FOR UPDATE OF a ` type GetAccountPropsByIdRow struct { @@ -629,6 +676,71 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount return i, err } +const GetAccountPropsByWalletAndId = `-- name: GetAccountPropsByWalletAndId :one +SELECT + a.account_number, + a.account_name, + a.is_derived, + a.public_key, + a.master_fingerprint, + a.created_at, + ks.purpose, + ks.coin_type, + ks.internal_type_id, + ks.external_type_id, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + w.is_watch_only AS wallet_is_watch_only +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +INNER JOIN wallets AS w ON a.wallet_id = w.id +WHERE a.wallet_id = $1 AND a.id = $2 +FOR UPDATE OF a +` + +type GetAccountPropsByWalletAndIdParams struct { + WalletID int64 + ID int64 +} + +type GetAccountPropsByWalletAndIdRow struct { + AccountNumber sql.NullInt64 + AccountName string + IsDerived bool + PublicKey []byte + MasterFingerprint sql.NullInt64 + CreatedAt time.Time + Purpose int64 + CoinType int64 + InternalTypeID int16 + ExternalTypeID int16 + ExternalKeyCount int64 + InternalKeyCount int64 + WalletIsWatchOnly bool +} + +// Returns full account properties by wallet id and account id. +func (q *Queries) GetAccountPropsByWalletAndId(ctx context.Context, arg GetAccountPropsByWalletAndIdParams) (GetAccountPropsByWalletAndIdRow, error) { + row := q.queryRow(ctx, q.getAccountPropsByWalletAndIdStmt, GetAccountPropsByWalletAndId, arg.WalletID, arg.ID) + var i GetAccountPropsByWalletAndIdRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.IsDerived, + &i.PublicKey, + &i.MasterFingerprint, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.WalletIsWatchOnly, + ) + return i, err +} + const GetAndIncrementNextExternalIndex = `-- name: GetAndIncrementNextExternalIndex :one UPDATE accounts SET next_external_index = next_external_index + 1 diff --git a/wallet/internal/sql/pg/sqlc/db.go b/wallet/internal/sql/pg/sqlc/db.go index 4f1ebea521..bc48371515 100644 --- a/wallet/internal/sql/pg/sqlc/db.go +++ b/wallet/internal/sql/pg/sqlc/db.go @@ -33,6 +33,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.acquireUtxoLeaseStmt, err = db.PrepareContext(ctx, AcquireUtxoLease); err != nil { return nil, fmt.Errorf("error preparing query AcquireUtxoLease: %w", err) } + if q.advanceNextExternalIndexStmt, err = db.PrepareContext(ctx, AdvanceNextExternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query AdvanceNextExternalIndex: %w", err) + } + if q.advanceNextInternalIndexStmt, err = db.PrepareContext(ctx, AdvanceNextInternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query AdvanceNextInternalIndex: %w", err) + } if q.balanceStmt, err = db.PrepareContext(ctx, Balance); err != nil { return nil, fmt.Errorf("error preparing query Balance: %w", err) } @@ -99,6 +105,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { return nil, fmt.Errorf("error preparing query GetAccountPropsById: %w", err) } + if q.getAccountPropsByWalletAndIdStmt, err = db.PrepareContext(ctx, GetAccountPropsByWalletAndId); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountPropsByWalletAndId: %w", err) + } if q.getActiveUtxoLeaseLockIDStmt, err = db.PrepareContext(ctx, GetActiveUtxoLeaseLockID); err != nil { return nil, fmt.Errorf("error preparing query GetActiveUtxoLeaseLockID: %w", err) } @@ -314,6 +323,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing acquireUtxoLeaseStmt: %w", cerr) } } + if q.advanceNextExternalIndexStmt != nil { + if cerr := q.advanceNextExternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing advanceNextExternalIndexStmt: %w", cerr) + } + } + if q.advanceNextInternalIndexStmt != nil { + if cerr := q.advanceNextInternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing advanceNextInternalIndexStmt: %w", cerr) + } + } if q.balanceStmt != nil { if cerr := q.balanceStmt.Close(); cerr != nil { err = fmt.Errorf("error closing balanceStmt: %w", cerr) @@ -424,6 +443,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAccountPropsByIdStmt: %w", cerr) } } + if q.getAccountPropsByWalletAndIdStmt != nil { + if cerr := q.getAccountPropsByWalletAndIdStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountPropsByWalletAndIdStmt: %w", cerr) + } + } if q.getActiveUtxoLeaseLockIDStmt != nil { if cerr := q.getActiveUtxoLeaseLockIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getActiveUtxoLeaseLockIDStmt: %w", cerr) @@ -791,6 +815,8 @@ type Queries struct { accountBalanceStmt *sql.Stmt accountBalancesByIDsStmt *sql.Stmt acquireUtxoLeaseStmt *sql.Stmt + advanceNextExternalIndexStmt *sql.Stmt + advanceNextInternalIndexStmt *sql.Stmt balanceStmt *sql.Stmt clearUtxosSpentByTxIDStmt *sql.Stmt createAccountSecretStmt *sql.Stmt @@ -813,6 +839,7 @@ type Queries struct { getAccountByWalletScopeAndNameStmt *sql.Stmt getAccountByWalletScopeAndNumberStmt *sql.Stmt getAccountPropsByIdStmt *sql.Stmt + getAccountPropsByWalletAndIdStmt *sql.Stmt getActiveUtxoLeaseLockIDStmt *sql.Stmt getAddressByScriptPubKeyStmt *sql.Stmt getAddressSecretStmt *sql.Stmt @@ -887,6 +914,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { accountBalanceStmt: q.accountBalanceStmt, accountBalancesByIDsStmt: q.accountBalancesByIDsStmt, acquireUtxoLeaseStmt: q.acquireUtxoLeaseStmt, + advanceNextExternalIndexStmt: q.advanceNextExternalIndexStmt, + advanceNextInternalIndexStmt: q.advanceNextInternalIndexStmt, balanceStmt: q.balanceStmt, clearUtxosSpentByTxIDStmt: q.clearUtxosSpentByTxIDStmt, createAccountSecretStmt: q.createAccountSecretStmt, @@ -909,6 +938,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + getAccountPropsByWalletAndIdStmt: q.getAccountPropsByWalletAndIdStmt, getActiveUtxoLeaseLockIDStmt: q.getActiveUtxoLeaseLockIDStmt, getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, getAddressSecretStmt: q.getAddressSecretStmt, diff --git a/wallet/internal/sql/pg/sqlc/querier.go b/wallet/internal/sql/pg/sqlc/querier.go index 77d89da9fc..8f23c27125 100644 --- a/wallet/internal/sql/pg/sqlc/querier.go +++ b/wallet/internal/sql/pg/sqlc/querier.go @@ -41,6 +41,16 @@ type Querier interface { // - Locks the target utxo row during resolution so concurrent spend updates on // that row serialize with lease acquisition. AcquireUtxoLease(ctx context.Context, arg AcquireUtxoLeaseParams) (time.Time, error) + // Advances the external branch's next index to the supplied value during + // recovery horizon extension. The GREATEST guard keeps the counter monotonic + // so a slower concurrent writer cannot regress it below an already-recorded + // index. + AdvanceNextExternalIndex(ctx context.Context, arg AdvanceNextExternalIndexParams) error + // Advances the internal/change branch's next index to the supplied value + // during recovery horizon extension. The GREATEST guard keeps the counter + // monotonic so a slower concurrent writer cannot regress it below an + // already-recorded index. + AdvanceNextInternalIndex(ctx context.Context, arg AdvanceNextInternalIndexParams) error // Returns the total and locked value represented by the wallet's current // unspent UTXO set. // @@ -143,6 +153,8 @@ type Querier interface { GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) // Returns full account properties by account id. GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) + // Returns full account properties by wallet id and account id. + GetAccountPropsByWalletAndId(ctx context.Context, arg GetAccountPropsByWalletAndIdParams) (GetAccountPropsByWalletAndIdRow, error) // Returns the lock ID for the current active lease on a UTXO ID. // // How: diff --git a/wallet/internal/sql/sqlite/queries/accounts.sql b/wallet/internal/sql/sqlite/queries/accounts.sql index 3885966990..4c7c9f0041 100644 --- a/wallet/internal/sql/sqlite/queries/accounts.sql +++ b/wallet/internal/sql/sqlite/queries/accounts.sql @@ -170,6 +170,27 @@ INNER JOIN key_scopes AS ks ON a.scope_id = ks.id INNER JOIN wallets AS w ON a.wallet_id = w.id WHERE a.id = ?; +-- name: GetAccountPropsByWalletAndId :one +-- Returns full account properties by wallet id and account id. +SELECT + a.account_number, + a.account_name, + a.is_derived, + a.public_key, + a.master_fingerprint, + a.created_at, + ks.purpose, + ks.coin_type, + ks.internal_type_id, + ks.external_type_id, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + w.is_watch_only AS wallet_is_watch_only +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +INNER JOIN wallets AS w ON a.wallet_id = w.id +WHERE a.wallet_id = ? AND a.id = ?; + -- name: ListAccountsByScope :many -- Lists all accounts in a scope. Accounts without BIP44 numbers appear last. SELECT @@ -406,3 +427,26 @@ WHERE OR (acc.is_derived = FALSE AND acc.account_number IS NULL) ) GROUP BY da.account_id; + +-- name: AdvanceNextExternalIndex :exec +-- Advances the external branch's next index to the supplied value during +-- recovery horizon extension. The MAX guard keeps the counter monotonic so a +-- slower concurrent writer cannot regress it below an already-recorded index. +UPDATE accounts +SET + next_external_index = max( + next_external_index, cast(sqlc.arg('next_index') AS INTEGER) + ) +WHERE id = sqlc.arg('id'); + +-- name: AdvanceNextInternalIndex :exec +-- Advances the internal/change branch's next index to the supplied value +-- during recovery horizon extension. The MAX guard keeps the counter monotonic +-- so a slower concurrent writer cannot regress it below an already-recorded +-- index. +UPDATE accounts +SET + next_internal_index = max( + next_internal_index, cast(sqlc.arg('next_index') AS INTEGER) + ) +WHERE id = sqlc.arg('id'); diff --git a/wallet/internal/sql/sqlite/sqlc/accounts.sql.go b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go index 35935a196b..6c31c39895 100644 --- a/wallet/internal/sql/sqlite/sqlc/accounts.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go @@ -171,6 +171,51 @@ func (q *Queries) AccountBalancesByIDs(ctx context.Context, arg AccountBalancesB return items, nil } +const AdvanceNextExternalIndex = `-- name: AdvanceNextExternalIndex :exec +UPDATE accounts +SET + next_external_index = max( + next_external_index, cast(?1 AS INTEGER) + ) +WHERE id = ?2 +` + +type AdvanceNextExternalIndexParams struct { + NextIndex int64 + ID int64 +} + +// Advances the external branch's next index to the supplied value during +// recovery horizon extension. The MAX guard keeps the counter monotonic so a +// slower concurrent writer cannot regress it below an already-recorded index. +func (q *Queries) AdvanceNextExternalIndex(ctx context.Context, arg AdvanceNextExternalIndexParams) error { + _, err := q.exec(ctx, q.advanceNextExternalIndexStmt, AdvanceNextExternalIndex, arg.NextIndex, arg.ID) + return err +} + +const AdvanceNextInternalIndex = `-- name: AdvanceNextInternalIndex :exec +UPDATE accounts +SET + next_internal_index = max( + next_internal_index, cast(?1 AS INTEGER) + ) +WHERE id = ?2 +` + +type AdvanceNextInternalIndexParams struct { + NextIndex int64 + ID int64 +} + +// Advances the internal/change branch's next index to the supplied value +// during recovery horizon extension. The MAX guard keeps the counter monotonic +// so a slower concurrent writer cannot regress it below an already-recorded +// index. +func (q *Queries) AdvanceNextInternalIndex(ctx context.Context, arg AdvanceNextInternalIndexParams) error { + _, err := q.exec(ctx, q.advanceNextInternalIndexStmt, AdvanceNextInternalIndex, arg.NextIndex, arg.ID) + return err +} + const CreateAccountSecret = `-- name: CreateAccountSecret :exec INSERT INTO account_secrets ( account_id, @@ -639,6 +684,70 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount return i, err } +const GetAccountPropsByWalletAndId = `-- name: GetAccountPropsByWalletAndId :one +SELECT + a.account_number, + a.account_name, + a.is_derived, + a.public_key, + a.master_fingerprint, + a.created_at, + ks.purpose, + ks.coin_type, + ks.internal_type_id, + ks.external_type_id, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + w.is_watch_only AS wallet_is_watch_only +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +INNER JOIN wallets AS w ON a.wallet_id = w.id +WHERE a.wallet_id = ? AND a.id = ? +` + +type GetAccountPropsByWalletAndIdParams struct { + WalletID int64 + ID int64 +} + +type GetAccountPropsByWalletAndIdRow struct { + AccountNumber sql.NullInt64 + AccountName string + IsDerived bool + PublicKey []byte + MasterFingerprint sql.NullInt64 + CreatedAt time.Time + Purpose int64 + CoinType int64 + InternalTypeID int64 + ExternalTypeID int64 + ExternalKeyCount int64 + InternalKeyCount int64 + WalletIsWatchOnly bool +} + +// Returns full account properties by wallet id and account id. +func (q *Queries) GetAccountPropsByWalletAndId(ctx context.Context, arg GetAccountPropsByWalletAndIdParams) (GetAccountPropsByWalletAndIdRow, error) { + row := q.queryRow(ctx, q.getAccountPropsByWalletAndIdStmt, GetAccountPropsByWalletAndId, arg.WalletID, arg.ID) + var i GetAccountPropsByWalletAndIdRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.IsDerived, + &i.PublicKey, + &i.MasterFingerprint, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.WalletIsWatchOnly, + ) + return i, err +} + const GetAndIncrementNextExternalIndex = `-- name: GetAndIncrementNextExternalIndex :one UPDATE accounts SET next_external_index = next_external_index + 1 diff --git a/wallet/internal/sql/sqlite/sqlc/db.go b/wallet/internal/sql/sqlite/sqlc/db.go index 4f1ebea521..bc48371515 100644 --- a/wallet/internal/sql/sqlite/sqlc/db.go +++ b/wallet/internal/sql/sqlite/sqlc/db.go @@ -33,6 +33,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.acquireUtxoLeaseStmt, err = db.PrepareContext(ctx, AcquireUtxoLease); err != nil { return nil, fmt.Errorf("error preparing query AcquireUtxoLease: %w", err) } + if q.advanceNextExternalIndexStmt, err = db.PrepareContext(ctx, AdvanceNextExternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query AdvanceNextExternalIndex: %w", err) + } + if q.advanceNextInternalIndexStmt, err = db.PrepareContext(ctx, AdvanceNextInternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query AdvanceNextInternalIndex: %w", err) + } if q.balanceStmt, err = db.PrepareContext(ctx, Balance); err != nil { return nil, fmt.Errorf("error preparing query Balance: %w", err) } @@ -99,6 +105,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { return nil, fmt.Errorf("error preparing query GetAccountPropsById: %w", err) } + if q.getAccountPropsByWalletAndIdStmt, err = db.PrepareContext(ctx, GetAccountPropsByWalletAndId); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountPropsByWalletAndId: %w", err) + } if q.getActiveUtxoLeaseLockIDStmt, err = db.PrepareContext(ctx, GetActiveUtxoLeaseLockID); err != nil { return nil, fmt.Errorf("error preparing query GetActiveUtxoLeaseLockID: %w", err) } @@ -314,6 +323,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing acquireUtxoLeaseStmt: %w", cerr) } } + if q.advanceNextExternalIndexStmt != nil { + if cerr := q.advanceNextExternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing advanceNextExternalIndexStmt: %w", cerr) + } + } + if q.advanceNextInternalIndexStmt != nil { + if cerr := q.advanceNextInternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing advanceNextInternalIndexStmt: %w", cerr) + } + } if q.balanceStmt != nil { if cerr := q.balanceStmt.Close(); cerr != nil { err = fmt.Errorf("error closing balanceStmt: %w", cerr) @@ -424,6 +443,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAccountPropsByIdStmt: %w", cerr) } } + if q.getAccountPropsByWalletAndIdStmt != nil { + if cerr := q.getAccountPropsByWalletAndIdStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountPropsByWalletAndIdStmt: %w", cerr) + } + } if q.getActiveUtxoLeaseLockIDStmt != nil { if cerr := q.getActiveUtxoLeaseLockIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getActiveUtxoLeaseLockIDStmt: %w", cerr) @@ -791,6 +815,8 @@ type Queries struct { accountBalanceStmt *sql.Stmt accountBalancesByIDsStmt *sql.Stmt acquireUtxoLeaseStmt *sql.Stmt + advanceNextExternalIndexStmt *sql.Stmt + advanceNextInternalIndexStmt *sql.Stmt balanceStmt *sql.Stmt clearUtxosSpentByTxIDStmt *sql.Stmt createAccountSecretStmt *sql.Stmt @@ -813,6 +839,7 @@ type Queries struct { getAccountByWalletScopeAndNameStmt *sql.Stmt getAccountByWalletScopeAndNumberStmt *sql.Stmt getAccountPropsByIdStmt *sql.Stmt + getAccountPropsByWalletAndIdStmt *sql.Stmt getActiveUtxoLeaseLockIDStmt *sql.Stmt getAddressByScriptPubKeyStmt *sql.Stmt getAddressSecretStmt *sql.Stmt @@ -887,6 +914,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { accountBalanceStmt: q.accountBalanceStmt, accountBalancesByIDsStmt: q.accountBalancesByIDsStmt, acquireUtxoLeaseStmt: q.acquireUtxoLeaseStmt, + advanceNextExternalIndexStmt: q.advanceNextExternalIndexStmt, + advanceNextInternalIndexStmt: q.advanceNextInternalIndexStmt, balanceStmt: q.balanceStmt, clearUtxosSpentByTxIDStmt: q.clearUtxosSpentByTxIDStmt, createAccountSecretStmt: q.createAccountSecretStmt, @@ -909,6 +938,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + getAccountPropsByWalletAndIdStmt: q.getAccountPropsByWalletAndIdStmt, getActiveUtxoLeaseLockIDStmt: q.getActiveUtxoLeaseLockIDStmt, getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, getAddressSecretStmt: q.getAddressSecretStmt, diff --git a/wallet/internal/sql/sqlite/sqlc/querier.go b/wallet/internal/sql/sqlite/sqlc/querier.go index 8249201d26..5525a73556 100644 --- a/wallet/internal/sql/sqlite/sqlc/querier.go +++ b/wallet/internal/sql/sqlite/sqlc/querier.go @@ -41,6 +41,15 @@ type Querier interface { // - SQLite executes the resolution and lease write atomically inside one // statement, which avoids stale-ID races between separate helper calls. AcquireUtxoLease(ctx context.Context, arg AcquireUtxoLeaseParams) (time.Time, error) + // Advances the external branch's next index to the supplied value during + // recovery horizon extension. The MAX guard keeps the counter monotonic so a + // slower concurrent writer cannot regress it below an already-recorded index. + AdvanceNextExternalIndex(ctx context.Context, arg AdvanceNextExternalIndexParams) error + // Advances the internal/change branch's next index to the supplied value + // during recovery horizon extension. The MAX guard keeps the counter monotonic + // so a slower concurrent writer cannot regress it below an already-recorded + // index. + AdvanceNextInternalIndex(ctx context.Context, arg AdvanceNextInternalIndexParams) error // Returns the total and locked value represented by the wallet's current // unspent UTXO set. // @@ -141,6 +150,8 @@ type Querier interface { GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) // Returns full account properties by account id. GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) + // Returns full account properties by wallet id and account id. + GetAccountPropsByWalletAndId(ctx context.Context, arg GetAccountPropsByWalletAndIdParams) (GetAccountPropsByWalletAndIdRow, error) // Returns the lock ID for the current active lease on a UTXO ID. // // How: