Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions itest/list_on_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions itest/signer_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
87 changes: 57 additions & 30 deletions wallet/signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -468,27 +481,25 @@ 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()
if err != nil {
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.
Expand Down Expand Up @@ -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) (
Expand All @@ -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())
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
23 changes: 10 additions & 13 deletions wallet/signer_benchmark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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)
}
})
Expand Down
Loading
Loading