diff --git a/itest/list_on_test.go b/itest/list_on_test.go index 8c21ce3b7a..c331b41cb6 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -47,6 +47,12 @@ var allTestCases = []*testCase{ Name: "controller info", TestFunc: testControllerInfo, }, + // Keep the public Signer request in the integration matrix so callers + // cannot accidentally depend on wallet-internal database types. + { + Name: "signer derive pubkey", + TestFunc: testSignerDerivePubKey, + }, { Name: "utxomanager list unspent", TestFunc: testListUnspent, diff --git a/itest/signer_test.go b/itest/signer_test.go new file mode 100644 index 0000000000..f2b168fd60 --- /dev/null +++ b/itest/signer_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +//go:build itest + +package itest + +import ( + "github.com/btcsuite/btcwallet/bwtest" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet" + "github.com/stretchr/testify/require" +) + +// testSignerDerivePubKey verifies the exported selector-bearing Signer +// contract can derive a public key without wallet-internal database types. +func testSignerDerivePubKey(h *bwtest.HarnessTest) { + // Arrange: Use the harness-owned lifecycle and address fixture to create a + // real started wallet with its default BIP84 account. The fixture restores + // the wallet's locked state before the Signer request is constructed. + w, _ := h.NewWallet(bwtest.WalletFixture{}) + h.NewWalletAddressOfType(w, waddrmgr.WitnessPubKey) + params := wallet.DerivePubKeyParams{ + Account: wallet.NewAccountSelectorByName( + waddrmgr.KeyScopeBIP0084, waddrmgr.DefaultAccountName, + ), + Branch: 0, + Index: 7, + } + + var signer wallet.Signer = w + + // Act: Invoke public derivation through the imported Signer interface so + // this integration package compiles against the caller-facing request. + pubKey, err := signer.DerivePubKey(h.Context(), params) + + // Assert: A locked wallet can return the requested public child without + // any backend identifier or private signing access in the request. + require.NoError(h, err) + require.NotNil(h, pubKey) +} diff --git a/wallet/signer.go b/wallet/signer.go index 13fba47fa5..cd0e60be72 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -45,12 +45,26 @@ var ( ErrAccountNotInStore = errors.New("account not in store") ) +// DerivePubKeyParams identifies an account and the unhardened child key to +// derive from its extended public key. +type DerivePubKeyParams struct { + // Account identifies the account by portable wallet semantics. + Account AccountSelector + + // Branch is the account child branch to derive. + Branch uint32 + + // Index is the child index within Branch to derive. + Index uint32 +} + // Signer provides an interface for common, safe cryptographic operations, // including signing and key derivation. type Signer interface { - // DerivePubKey derives a public key from a full BIP-32 derivation - // path. - DerivePubKey(ctx context.Context, path BIP32Path) ( + // DerivePubKey derives a public child key from the selected account's + // extended public key. The account may be selected by name when it has + // no BIP44 account number, such as an imported XPub account. + DerivePubKey(ctx context.Context, params DerivePubKeyParams) ( *btcec.PublicKey, error) // ECDH performs a scalar multiplication (ECDH-like operation) between @@ -115,8 +129,7 @@ type UnsafeSigner interface { *btcec.PrivateKey, error) } -// A compile-time check to ensure that Wallet implements the Signer and -// UnsafeSigner interfaces. +// Compile-time checks ensure that Wallet implements the signer interfaces. var _ Signer = (*Wallet)(nil) var _ UnsafeSigner = (*Wallet)(nil) @@ -468,17 +481,12 @@ var _ SpendDetails = (*LegacySpendDetails)(nil) var _ SpendDetails = (*SegwitV0SpendDetails)(nil) var _ SpendDetails = (*TaprootSpendDetails)(nil) -// DerivePubKey derives a public key from a full BIP-32 derivation path. -// -// The public key is resolved entirely through the durable store: the -// account-level extended public key is fetched by the path's BIP44 account -// number and the branch and index derived locally. This single path covers -// both SQL-backed and kvdb-backed wallets because the kvdb store adapter -// exports the legacy address manager's account material through the same -// account-secret contract. It is the public-key counterpart of -// derivePathPrivKey and, since the account xpub is stored in plaintext, it -// also serves watch-only accounts that hold no encrypted private material. -func (w *Wallet) DerivePubKey(ctx context.Context, path BIP32Path) ( +// DerivePubKey derives a public child key from a semantically selected +// account. The account XPub is read from the durable store, then the branch and +// child index are derived in memory. Public derivation requires a started +// wallet but remains available while the wallet is locked. +func (w *Wallet) DerivePubKey(ctx context.Context, + params DerivePubKeyParams) ( *btcec.PublicKey, error) { err := w.state.validateStarted() @@ -486,9 +494,12 @@ func (w *Wallet) DerivePubKey(ctx context.Context, path BIP32Path) ( return nil, err } - return w.resolveDerivedPubKeyFromStore( - ctx, path.KeyScope, path.DerivationPath, - ) + err = params.Account.validate() + if err != nil { + return nil, err + } + + return w.resolveDerivedPubKeyFromStore(ctx, params) } // derivePathPrivKey resolves the signing private key for a full BIP-32 path. @@ -682,8 +693,9 @@ func (w *Wallet) ComputeUnlockingScript(ctx context.Context, // privKeyForOutput returns the private key needed to sign for the given // wallet-controlled output. // -// Derived addresses resolve through the account-level secret; imported -// addresses have no derivation path and resolve through their own encrypted +// Derived addresses resolve through the account-level secret. A derived child +// without wallet-seed derivation metadata, such as an imported-XPub child, +// cannot sign. Only raw imported addresses resolve through their own encrypted // private key material in the store. func (w *Wallet) privKeyForOutput(ctx context.Context, scriptInfo OutputScriptInfo) ( @@ -693,6 +705,10 @@ func (w *Wallet) privKeyForOutput(ctx context.Context, return w.privKeyForAddressInfo(ctx, scriptInfo.AddressInfo) } + if !scriptInfo.Imported { + return nil, ErrNoAssocPrivateKey + } + return w.resolveImportedAddrPrivKey(ctx, scriptInfo.scriptPubKey()) } @@ -989,19 +1005,26 @@ func deriveStoredAccountChildKey(vault keyvault.Vault, // account xpub is stored in plaintext, it also serves watch-only accounts that // hold no encrypted private material. func (w *Wallet) resolveDerivedPubKeyFromStore(ctx context.Context, - keyScope waddrmgr.KeyScope, - path waddrmgr.DerivationPath) (*btcec.PublicKey, error) { + params DerivePubKeyParams) (*btcec.PublicKey, error) { + + query := db.GetAccountQuery{ + WalletID: w.id, + Scope: db.KeyScope(params.Account.keyScope), + SkipBalance: true, + } + + if params.Account.accountName != nil { + query.Name = params.Account.accountName + } else { + accountNumber := uint32(*params.Account.accountNumber) + query.AccountNumber = &accountNumber + } // The account xpub is public metadata, so it comes from the account read // rather than the secret read: AccountSecret carries encrypted private // material only. SkipBalance keeps this to one backend query, and the // public path works while the wallet is locked. - account, err := w.cache.GetAccount(ctx, db.GetAccountQuery{ - WalletID: w.id, - Scope: db.KeyScope(keyScope), - AccountNumber: &path.InternalAccount, - SkipBalance: true, - }) + account, err := w.cache.GetAccount(ctx, query) switch { case errors.Is(err, db.ErrAccountNotFound): return nil, ErrAccountNotInStore @@ -1016,7 +1039,11 @@ func (w *Wallet) resolveDerivedPubKeyFromStore(ctx context.Context, ) } - return deriveStoredAccountChildPubKey(account.PublicKey, path) + return deriveStoredAccountChildPubKey(account.PublicKey, + waddrmgr.DerivationPath{ + Branch: params.Branch, + Index: params.Index, + }) } // deriveStoredAccountChildPubKey parses an account-level extended public key diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go index b5e7f272c4..00282b6ab3 100644 --- a/wallet/signer_benchmark_test.go +++ b/wallet/signer_benchmark_test.go @@ -14,8 +14,8 @@ import ( // BenchmarkDerivePubKey benchmarks the DerivePubKey method across different // wallet sizes. The benchmark measures the performance of deriving a public -// key from a BIP-32 path, which involves database lookups and cryptographic -// operations. +// key from a selected account, which involves database lookups and +// cryptographic operations. func BenchmarkDerivePubKey(b *testing.B) { const ( startGrowthIteration = 0 @@ -25,7 +25,7 @@ func BenchmarkDerivePubKey(b *testing.B) { var ( // accountGrowth uses linearGrowth to test how performance // scales with the number of accounts in the wallet. Key - // derivation uses the account index in the BIP-32 path, so + // derivation uses the semantic account selector, so // database lookup time should remain constant due to indexed // lookups. accountGrowth = mapRange( @@ -72,23 +72,20 @@ func BenchmarkDerivePubKey(b *testing.B) { }, ) - // Use a path from the middle of the account range + // Select an account from the middle of the account range // for representative performance. - accountIndex := uint32(accountGrowth[i] / 2) - path := BIP32Path{ - KeyScope: scopes[0], - DerivationPath: waddrmgr.DerivationPath{ - InternalAccount: accountIndex, - Branch: 0, - Index: 0, - }, + accountNumber := AccountNumber(accountGrowth[i] / 2) + params := DerivePubKeyParams{ + Account: NewAccountSelectorByNumber( + scopes[0], accountNumber, + ), } b.ReportAllocs() b.ResetTimer() for b.Loop() { - _, err := w.DerivePubKey(b.Context(), path) + _, err := w.DerivePubKey(b.Context(), params) require.NoError(b, err) } }) diff --git a/wallet/signer_test.go b/wallet/signer_test.go index 37a86b4f9c..37a38ad530 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -91,27 +91,87 @@ func TestDerivePubKeySuccess(t *testing.T) { // the store account-secret lookup that the pubkey resolver reads. w, mocks := createUnlockedWalletWithMocks(t) - path := BIP32Path{ - KeyScope: waddrmgr.KeyScopeBIP0084, - DerivationPath: waddrmgr.DerivationPath{ - InternalAccount: 0, - Branch: 0, - Index: 0, - }, + scope := waddrmgr.KeyScopeBIP0084 + path := waddrmgr.DerivationPath{ + InternalAccount: 0, + Branch: 0, + Index: 0, + } + params := DerivePubKeyParams{ + Account: NewAccountSelectorByNumber(scope, 0), + Branch: path.Branch, + Index: path.Index, } - pubKey := expectStorePubKey( - t, mocks, w.id, path.KeyScope, path.DerivationPath, - ) + pubKey := expectStorePubKey(t, mocks, w.id, scope, path) // Act: Derive the public key. - derivedKey, err := w.DerivePubKey(t.Context(), path) + derivedKey, err := w.DerivePubKey(t.Context(), params) // Assert: Check that the correct key is returned without error. require.NoError(t, err) require.True(t, pubKey.IsEqual(derivedKey)) } +// TestPublicKeyDerivationByName verifies that semantic name selection reads +// the selected account XPub and derives its requested child. +func TestPublicKeyDerivationByName(t *testing.T) { + t.Parallel() + + // Arrange: Store a distinct account XPub behind a name-only selector so + // the test proves semantic lookup, rather than numeric fallback, owns the + // derived child. + w, mocks := createUnlockedWalletWithMocks(t) + accountName := "imported-xpub" + account := testAccountXPrv(t) + accountPub, err := account.Neuter() + require.NoError(t, err) + _, want := deriveLeafKeys(t, account, 1, 7) + + mocks.store.On("GetAccount", mock.Anything, db.GetAccountQuery{ + WalletID: w.id, + Scope: db.KeyScope(waddrmgr.KeyScopeBIP0084), + Name: &accountName, + SkipBalance: true, + }).Return(&db.AccountInfo{ + PublicKey: []byte(accountPub.String()), + }, nil).Once() + + // Act: Derive the requested child through the public semantic API. + got, err := w.DerivePubKey( + t.Context(), DerivePubKeyParams{ + Account: NewAccountSelectorByName( + waddrmgr.KeyScopeBIP0084, accountName, + ), + Branch: 1, + Index: 7, + }, + ) + + // Assert: The name-selected XPub produced the expected leaf key. + require.NoError(t, err) + require.True(t, want.IsEqual(got)) +} + +// TestPublicKeyDerivationRejectsInvalidSelector verifies selector validation +// fails before the durable Store is accessed. +func TestPublicKeyDerivationRejectsInvalidSelector(t *testing.T) { + t.Parallel() + + // Arrange: Keep the selector empty and retain the Store mock so the test + // can prove validation rejects it before any backend lookup. + w, mocks := createUnlockedWalletWithMocks(t) + + // Act: Attempt public derivation without an account identity. + _, err := w.DerivePubKey( + t.Context(), DerivePubKeyParams{}, + ) + + // Assert: Selector validation failed locally and the Store was untouched. + require.ErrorIs(t, err, errInvalidAccountSelector) + mocks.store.AssertNotCalled(t, "GetAccount", mock.Anything, mock.Anything) +} + // TestDerivePubKeyDeriveFails verifies that a failure inside public-child // derivation itself propagates. The other retained Store tests cover account // absence, an unexpected Store error, and missing account public material, but @@ -125,13 +185,12 @@ func TestDerivePubKeyDeriveFails(t *testing.T) { // whose branch is hardened. w, mocks := createUnlockedWalletWithMocks(t) - path := BIP32Path{ - KeyScope: waddrmgr.KeyScopeBIP0084, - DerivationPath: waddrmgr.DerivationPath{ - InternalAccount: 0, - Branch: hdkeychain.HardenedKeyStart, - Index: 0, - }, + scope := waddrmgr.KeyScopeBIP0084 + accountNumber := uint32(0) + params := DerivePubKeyParams{ + Account: NewAccountSelectorByNumber(scope, 0), + Branch: hdkeychain.HardenedKeyStart, + Index: 0, } acct := testAccountXPrv(t) @@ -140,15 +199,15 @@ func TestDerivePubKeyDeriveFails(t *testing.T) { mocks.store.On("GetAccount", mock.Anything, db.GetAccountQuery{ WalletID: w.id, - Scope: db.KeyScope(path.KeyScope), - AccountNumber: &path.DerivationPath.InternalAccount, + Scope: db.KeyScope(scope), + AccountNumber: &accountNumber, SkipBalance: true, }).Return(&db.AccountInfo{ PublicKey: []byte(acctPub.String()), }, nil).Once() // Act: Attempt to derive the public key. - _, err = w.DerivePubKey(t.Context(), path) + _, err = w.DerivePubKey(t.Context(), params) // Assert: the derivation error is propagated, not masked as a missing // account or a Store failure. @@ -164,17 +223,22 @@ func TestDerivePubKeyAccountNotInStore(t *testing.T) { // Arrange: Set up the wallet and a test path. Configure the store to // report that the account row is absent. w, mocks := createUnlockedWalletWithMocks(t) - path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} + accountNumber := uint32(0) + params := DerivePubKeyParams{ + Account: NewAccountSelectorByNumber( + waddrmgr.KeyScopeBIP0084, 0, + ), + } mocks.store.On("GetAccount", mock.Anything, db.GetAccountQuery{ WalletID: w.id, - Scope: db.KeyScope(path.KeyScope), - AccountNumber: &path.DerivationPath.InternalAccount, + Scope: db.KeyScope(waddrmgr.KeyScopeBIP0084), + AccountNumber: &accountNumber, SkipBalance: true, }).Return((*db.AccountInfo)(nil), db.ErrAccountNotFound).Once() // Act: Attempt to derive the public key. - _, err := w.DerivePubKey(t.Context(), path) + _, err := w.DerivePubKey(t.Context(), params) // Assert: Check that the account-miss error is surfaced. require.ErrorIs(t, err, ErrAccountNotInStore) @@ -189,17 +253,22 @@ func TestDerivePubKeyStoreFails(t *testing.T) { // Arrange: Set up the wallet and a test path. Configure the store to // return an unexpected error. w, mocks := createUnlockedWalletWithMocks(t) - path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} + accountNumber := uint32(0) + params := DerivePubKeyParams{ + Account: NewAccountSelectorByNumber( + waddrmgr.KeyScopeBIP0084, 0, + ), + } mocks.store.On("GetAccount", mock.Anything, db.GetAccountQuery{ WalletID: w.id, - Scope: db.KeyScope(path.KeyScope), - AccountNumber: &path.DerivationPath.InternalAccount, + Scope: db.KeyScope(waddrmgr.KeyScopeBIP0084), + AccountNumber: &accountNumber, SkipBalance: true, }).Return((*db.AccountInfo)(nil), errDerivationFailed).Once() // Act: Attempt to derive the public key. - _, err := w.DerivePubKey(t.Context(), path) + _, err := w.DerivePubKey(t.Context(), params) // Assert: Check that the error is propagated correctly. require.ErrorIs(t, err, errDerivationFailed) @@ -213,17 +282,22 @@ func TestDerivePubKeyMissingAccountPubKey(t *testing.T) { // Arrange: Set up the wallet and a test path. Configure the store to // return an account with an empty public key. w, mocks := createUnlockedWalletWithMocks(t) - path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} + accountNumber := uint32(0) + params := DerivePubKeyParams{ + Account: NewAccountSelectorByNumber( + waddrmgr.KeyScopeBIP0084, 0, + ), + } mocks.store.On("GetAccount", mock.Anything, db.GetAccountQuery{ WalletID: w.id, - Scope: db.KeyScope(path.KeyScope), - AccountNumber: &path.DerivationPath.InternalAccount, + Scope: db.KeyScope(waddrmgr.KeyScopeBIP0084), + AccountNumber: &accountNumber, SkipBalance: true, }).Return(&db.AccountInfo{}, nil).Once() // Act: Attempt to derive the public key. - _, err := w.DerivePubKey(t.Context(), path) + _, err := w.DerivePubKey(t.Context(), params) // Assert: Check that the missing-material error is surfaced. require.ErrorIs(t, err, ErrMissingParam) @@ -1302,6 +1376,62 @@ func TestComputeUnlockingScriptFail_PrivKey(t *testing.T) { require.ErrorContains(t, err, "privkey error") } +// TestComputeUnlockingScriptImportedXPub verifies a numberless imported-XPub +// child fails before either account or raw-address secrets are queried. +func TestComputeUnlockingScriptImportedXPub(t *testing.T) { + t.Parallel() + + // Arrange: Resolve the output as a derived imported-XPub child with + // branch/index facts but no wallet-seed account number. Retaining every + // secret mock lets the test prove signing stops before either private-key + // lookup path or vault decryption. + _, pubKey := deterministicPrivKey(t) + addr, err := address.NewAddressWitnessPubKeyHash( + address.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + prevOut, tx := createDummyTestTx(pkScript) + w, mocks := createUnlockedWalletWithMocks(t) + accountID := uint32(7) + + expectStoreAddressInfo(t, w, mocks, addr, &db.AddressInfo{ + AccountID: &accountID, + AccountName: "imported-xpub", + KeyScope: db.KeyScope(waddrmgr.KeyScopeBIP0084), + AddrType: db.WitnessPubKey, + IsImported: true, + HasDerivationPath: true, + Branch: 0, + Index: 3, + ScriptPubKey: pkScript, + PubKey: pubKey.SerializeCompressed(), + }) + + fetcher := txscript.NewCannedPrevOutputFetcher(pkScript, prevOut.Value) + + // Act: Attempt to build an unlocking script for the public-only child. + _, err = w.ComputeUnlockingScript(t.Context(), &UnlockingScriptParams{ + Tx: tx, + Output: prevOut, + SigHashes: txscript.NewTxSigHashes(tx, fetcher), + HashType: txscript.SigHashAll, + }) + + // Assert: Signing reports no associated private key without consulting + // account secrets, raw-address secrets, or the decryption vault. + require.ErrorIs(t, err, ErrNoAssocPrivateKey) + mocks.store.AssertNotCalled( + t, "GetAccountSecret", mock.Anything, mock.Anything, + ) + mocks.store.AssertNotCalled( + t, "GetAddressSecret", mock.Anything, mock.Anything, + ) + mocks.vault.AssertNotCalled(t, "Decrypt", mock.Anything, mock.Anything) +} + // TestComputeUnlockingScriptImportedAddress verifies that // ComputeUnlockingScript signs for an imported address by resolving the private // key from its own encrypted secret in the store rather than an account @@ -1922,6 +2052,104 @@ func newSQLAddressSigningWallet(t *testing.T) (*Wallet, *bwmock.Chain, return w, chain, vault } +// TestSQLImportedXPubDerivation verifies locked public derivation selects a +// numberless imported account by name even when its SQL row projection equals +// another account's BIP44 number. +func TestSQLImportedXPubDerivation(t *testing.T) { + t.Parallel() + + // Arrange: Create a numberless imported-XPub account whose immutable SQL + // row projection collides with another account's BIP44 number. Distinct + // expected children make any selector mix-up observable, and locking the + // wallet proves public derivation does not require private-key access. + w, _, _ := newSQLAddressSigningWallet(t) + scope := waddrmgr.KeyScopeBIP0084 + dbScope := db.KeyScope(scope) + importedKey := testAccountXPrv(t) + importedPub, err := importedKey.Neuter() + require.NoError(t, err) + + imported, err := w.store.CreateImportedAccount( + t.Context(), db.CreateImportedAccountParams{ + WalletID: w.id, + Name: "imported-xpub", + Scope: dbScope, + PublicKey: []byte(importedPub.String()), + EncryptedPrivateKey: []byte(importedKey.String()), + }, + ) + require.NoError(t, err) + + _, err = w.store.CreateDerivedAccount( + t.Context(), db.CreateDerivedAccountParams{ + WalletID: w.id, Scope: dbScope, Name: "account-one", + }, testAccountDerivationFunc(), + ) + require.NoError(t, err) + + collision, err := w.store.CreateDerivedAccount( + t.Context(), db.CreateDerivedAccountParams{ + WalletID: w.id, Scope: dbScope, Name: "collision", + }, testAccountDerivationFunc(), + ) + require.NoError(t, err) + require.NotNil(t, imported.AccountID) + require.NotNil(t, collision.AccountNumber) + require.Equal(t, *imported.AccountID, *collision.AccountNumber) + + path := waddrmgr.DerivationPath{Branch: 0, Index: 7} + wantImported, err := deriveStoredAccountChildPubKey( + imported.PublicKey, path, + ) + require.NoError(t, err) + wantDerived, err := deriveStoredAccountChildPubKey( + collision.PublicKey, path, + ) + require.NoError(t, err) + require.False(t, wantImported.IsEqual(wantDerived)) + + w.state.toLocked() + + // Act: Derive the child by the imported account's semantic name. + byName, err := w.DerivePubKey( + t.Context(), DerivePubKeyParams{ + Account: NewAccountSelectorByName(scope, imported.AccountName), + Branch: path.Branch, + Index: path.Index, + }, + ) + + // Assert: Name selection returned the imported XPub's child. + require.NoError(t, err) + require.True(t, wantImported.IsEqual(byName)) + + accountNumber := AccountNumber(*collision.AccountNumber) + + // Act: Derive the same path by the colliding BIP44 account number. + byNumber, err := w.DerivePubKey( + t.Context(), DerivePubKeyParams{ + Account: NewAccountSelectorByNumber(scope, accountNumber), + Branch: path.Branch, + Index: path.Index, + }, + ) + + // Assert: Numeric selection returned the derived account's child instead + // of leaking the imported account's backend identity. + require.NoError(t, err) + require.True(t, wantDerived.IsEqual(byNumber)) + + // Act: Attempt to derive from a name absent from the live SQL store. + _, err = w.DerivePubKey( + t.Context(), DerivePubKeyParams{ + Account: NewAccountSelectorByName(scope, "missing"), + }, + ) + + // Assert: The missing semantic identity maps to the wallet-level error. + require.ErrorIs(t, err, ErrAccountNotInStore) +} + // newSpendableAddressManager creates and unlocks a deterministic legacy // waddrmgr manager for signer integration tests. func newSpendableAddressManager(t *testing.T,