Skip to content
Draft
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
18 changes: 6 additions & 12 deletions bwtest/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"github.com/btcsuite/btcd/chaincfg/v2"
"github.com/btcsuite/btcwallet/chain"
"github.com/btcsuite/btcwallet/wallet"
"github.com/btcsuite/btcwallet/walletdb"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -40,8 +39,9 @@ type HarnessTest struct {
// passed to wallets under test.
ChainClient chain.Interface

// WalletDB is a wallet database instance created for the current subtest.
WalletDB walletdb.DB
// WalletDBPath is the wallet database path created for the current
Comment thread
yyforyongyu marked this conversation as resolved.
// subtest.
WalletDBPath string

// dbType is the configured wallet database backend.
dbType string
Expand Down Expand Up @@ -296,17 +296,11 @@ func (h *HarnessTest) setUpChainClient() {
h.ChainClient = chainClient
}

// setUpWalletDB opens a wallet database for the configured test backend.
// setUpWalletDB prepares a wallet database path for the configured test
// backend.
func (h *HarnessTest) setUpWalletDB() {
h.Helper()

dbDir := h.TempDir()
db, cleanup, err := OpenWalletDB(h.dbType, dbDir)
require.NoError(h, err, "unable to create wallet db")

h.Cleanup(func() {
require.NoError(h, cleanup(), "failed to close database")
})

h.WalletDB = db
h.WalletDBPath = filepath.Join(dbDir, wallet.WalletDBName)
}
13 changes: 9 additions & 4 deletions bwtest/harness_wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,13 @@ func (h *HarnessTest) CreateEmptyWallet() *wallet.Wallet {
name := "itest-" + strings.ReplaceAll(h.Name(), "/", "_")

cfg := wallet.Config{
// Use the subtest-scoped DB and chain client prepared by the harness.
DB: h.WalletDB,
// Use the subtest-scoped DB path and chain client prepared by
// the harness.
DB: wallet.DBConfig{
KVDB: wallet.KVDBConfig{
DBPath: h.WalletDBPath,
},
},
Chain: h.ChainClient,

// Keep network and startup behavior deterministic across tests.
Expand Down Expand Up @@ -119,8 +124,8 @@ func (h *HarnessTest) CreateFundedWallet() *wallet.Wallet {
return w
}

// init uses fast scrypt options for tests to avoid CPU exhaustion and timeouts,
// especially when running with -race.
func init() {
// Use fast scrypt options for tests to avoid CPU exhaustion and
// timeouts, especially when running with -race.
waddrmgr.DefaultScryptOptions = waddrmgr.FastScryptOptions
}
7 changes: 6 additions & 1 deletion itest/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ func testCreateWallet(h *bwtest.HarnessTest) {

// Create a wallet using the Manager API.
cfg := wallet.Config{
DB: h.WalletDB,
DB: wallet.DBConfig{
Backend: wallet.DBBackendKVDB,
KVDB: wallet.KVDBConfig{
DBPath: h.WalletDBPath,
},
},
Chain: h.ChainClient,
ChainParams: h.NetParams(),
RecoveryWindow: 20,
Expand Down
12 changes: 10 additions & 2 deletions waddrmgr/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ const (
// ImportedAddrAccountName is the name of the imported account.
ImportedAddrAccountName = "imported"

// BirthdaySafetyMargin is the duration subtracted from a wallet's
// requested birthday before it is persisted. Backing the birthday
// off by this margin guards against clock skew and deposits made in
// the window just before the stated birthday, so a rescan does not
// miss them.
BirthdaySafetyMargin = 48 * time.Hour
Comment thread
yyforyongyu marked this conversation as resolved.

// DefaultAccountNum is the number of the default account.
DefaultAccountNum = 0

Expand Down Expand Up @@ -2600,6 +2607,7 @@ func Create(ns walletdb.ReadWriteBucket, rootKey *hdkeychain.ExtendedKey,
return maybeConvertDbError(err)
}

// Use 48 hours as margin of safety for wallet birthday.
return putBirthday(ns, birthday.Add(-48*time.Hour))
// Back the birthday off by the safety margin so a rescan does not
// miss deposits made in the window just before the stated birthday.
return putBirthday(ns, birthday.Add(-BirthdaySafetyMargin))
}
38 changes: 26 additions & 12 deletions wallet/account_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import (
func (w *Wallet) buildAccountDeriveFn(
ctx context.Context) (db.AccountDerivationFunc, error) {

if w.addrStore.WatchOnly() {
if w.IsWatchOnly() {
return func(_ context.Context, _ db.KeyScope, _ uint32,
_ bool) (*db.DerivedAccountData, error) {

Expand Down Expand Up @@ -306,11 +306,6 @@ func (w *Wallet) ListAccountsByScope(ctx context.Context,

dbScope := db.KeyScope(scope)

_, err = w.addrStore.FetchScopedKeyManager(scope)
if err != nil {
return nil, err
}

return w.listAccountInfos(ctx, db.ListAccountsQuery{
WalletID: w.id,
Scope: &dbScope,
Expand Down Expand Up @@ -454,6 +449,11 @@ func (w *Wallet) importAccountInternal(ctx context.Context,
return nil, err
}

dbAddrSchema, err := dbScopeAddrSchema(addrSchema)
if err != nil {
return nil, err
}

info, err := w.store.CreateImportedAccount(ctx,
db.CreateImportedAccountParams{
WalletID: w.id,
Expand All @@ -462,7 +462,7 @@ func (w *Wallet) importAccountInternal(ctx context.Context,
MasterFingerprint: masterKeyFingerprint,
PublicKey: []byte(accountKey.String()),
DryRun: dryRun,
AddrSchema: dbScopeAddrSchema(addrSchema),
AddrSchema: dbAddrSchema,
},
)
if err != nil {
Expand All @@ -482,20 +482,34 @@ func (w *Wallet) importAccountInternal(ctx context.Context,

// dbScopeAddrSchema converts a waddrmgr per-account address schema override
// into the account-store contract type.
//
// The waddrmgr and db AddressType enums share names but not ordinals (e.g.
// waddrmgr.PubKeyHash is 0 while db.RawPubKey is 0), so a direct cast would
// silently corrupt the stored schema. The explicit wallet->store mapping is
// used instead, matching propertiesToAccountInfo's derived-account schema
// conversion.
func dbScopeAddrSchema(
schema *waddrmgr.ScopeAddrSchema) *db.ScopeAddrSchema {
schema *waddrmgr.ScopeAddrSchema) (*db.ScopeAddrSchema, error) {

if schema == nil {
return nil
// A nil schema means the account opts into the scope's default
// address schema; nil value with a nil error is the intended
// "no override" signal, not a missing-value bug.
//nolint:nilnil
return nil, nil
}

return &db.ScopeAddrSchema{
ExternalAddrType: db.AddressType(schema.ExternalAddrType),
InternalAddrType: db.AddressType(schema.InternalAddrType),
converted, err := db.ScopeAddrSchemaFromWaddrmgr(*schema)
if err != nil {
return nil, err
}

return &converted, nil
}

// validateExtendedPubKey ensures a sane derived public key is provided.
//
//nolint:unparam // Preserve address-key validation for legacy call sites.
func validateExtendedPubKey(pubKey *hdkeychain.ExtendedKey,
isAccountKey bool, chainParams *chaincfg.Params) error {

Expand Down
91 changes: 81 additions & 10 deletions wallet/account_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,6 @@ func TestListAccountsByScope(t *testing.T) {
KeyScope: dbScope,
},
}, nil).Once()
deps.addrStore.On("FetchScopedKeyManager", scope).Return(
deps.accountManager, nil,
).Once()

accounts, err := w.ListAccountsByScope(t.Context(), scope)
require.NoError(t, err)
Expand All @@ -263,15 +260,14 @@ func TestListAccountsByScopeUnknownScope(t *testing.T) {
w, deps := createStartedWalletWithMocks(t)

scope := waddrmgr.KeyScope{Purpose: 123, Coin: 456}
deps.addrStore.On("FetchScopedKeyManager", scope).Return(
nil, waddrmgr.ManagerError{
ErrorCode: waddrmgr.ErrScopeNotFound,
},
).Once()
dbScope := db.KeyScope(scope)
deps.store.On("ListAccounts", mock.Anything, db.ListAccountsQuery{
WalletID: 0,
Scope: &dbScope,
}).Return(nil, db.ErrUnknownKeyScope).Once()

_, err := w.ListAccountsByScope(t.Context(), scope)
require.Error(t, err)
require.True(t, waddrmgr.IsError(err, waddrmgr.ErrScopeNotFound))
require.ErrorIs(t, err, db.ErrUnknownKeyScope)
}

// TestListAccountsByName verifies the name filter narrows the query.
Expand Down Expand Up @@ -696,6 +692,81 @@ func TestImportAccountAddrSchema(t *testing.T) {
require.Equal(t, testAccountName, props.AccountName)
}

// TestDBScopeAddrSchemaMapsTypes verifies dbScopeAddrSchema converts a
// per-account schema override through the explicit wallet->store address-type
// mapping rather than a raw enum cast. The two enums do not share ordinals
// (waddrmgr.PubKeyHash=0 vs db.RawPubKey=0, waddrmgr.Script=1 vs
// db.PubKeyHash=1, waddrmgr.TaprootScript=7 vs db.Anchor=7), so a raw cast
// silently stores the wrong script type. P2PKH is the headline case: a
// BIP-0044 imported xpub whose external schema is PubKeyHash must be stored as
// db.PubKeyHash so NewAddress later derives a P2PKH script, not a raw pubkey.
func TestDBScopeAddrSchemaMapsTypes(t *testing.T) {
t.Parallel()

tests := []struct {
name string
external waddrmgr.AddressType
internal waddrmgr.AddressType
want db.ScopeAddrSchema
}{
{
name: "pubkeyhash not raw pubkey",
external: waddrmgr.PubKeyHash,
internal: waddrmgr.PubKeyHash,
want: db.ScopeAddrSchema{
ExternalAddrType: db.PubKeyHash,
InternalAddrType: db.PubKeyHash,
},
},
{
name: "script not pubkeyhash",
external: waddrmgr.Script,
internal: waddrmgr.Script,
want: db.ScopeAddrSchema{
ExternalAddrType: db.ScriptHash,
InternalAddrType: db.ScriptHash,
},
},
{
name: "raw pubkey not script hash",
external: waddrmgr.RawPubKey,
internal: waddrmgr.RawPubKey,
want: db.ScopeAddrSchema{
ExternalAddrType: db.RawPubKey,
InternalAddrType: db.RawPubKey,
},
},
{
name: "taproot script not anchor",
external: waddrmgr.TaprootScript,
internal: waddrmgr.TaprootScript,
want: db.ScopeAddrSchema{
ExternalAddrType: db.TaprootPubKey,
InternalAddrType: db.TaprootPubKey,
},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

got, err := dbScopeAddrSchema(&waddrmgr.ScopeAddrSchema{
ExternalAddrType: tc.external,
InternalAddrType: tc.internal,
})
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, tc.want, *got)
})
}

// A nil override stays nil so the store falls back to the scope default.
got, err := dbScopeAddrSchema(nil)
require.NoError(t, err)
require.Nil(t, got)
}

// importAccountTestKey derives an account-level public key for import routing
// tests using the requested BIP purpose.
func importAccountTestKey(t *testing.T,
Expand Down
46 changes: 34 additions & 12 deletions wallet/address_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,9 +368,14 @@ func addressInfoFromStoreAddress(storeAddr *db.AddressInfo,
return info, nil
}

// deriveAddressData derives one SQL-store address from account public material.
func (w *Wallet) deriveAddressData(_ context.Context,
params db.AddressDerivationParams) (*db.DerivedAddressData, error) {
// deriveStoreAddress derives one address, its pkScript, and the leaf compressed
// public key from account public material, using only in-memory HD math and the
// account's address schema — no walletdb and no walletdb-backed
// waddrmgr.Manager. It is the single encoder shared by SQL address generation
// (deriveAddressData) and SQL recovery lookahead, so both produce identical
// addresses for a given (account, branch, index).
func deriveStoreAddress(params db.AddressDerivationParams,
chainParams *chaincfg.Params) (address.Address, []byte, []byte, error) {

if len(params.AccountPubKey) == 0 {
account := "none"
Expand All @@ -380,51 +385,68 @@ func (w *Wallet) deriveAddressData(_ context.Context,
)
}

return nil, fmt.Errorf("%w: scope=%v account=%s",
return nil, nil, nil, fmt.Errorf("%w: scope=%v account=%s",
errMissingAccountPubKey, params.Scope, account)
}

accountPubKey, err := hdkeychain.NewKeyFromString(
string(params.AccountPubKey),
)
if err != nil {
return nil, fmt.Errorf("parse account pubkey: %w", err)
return nil, nil, nil, fmt.Errorf("parse account "+
"pubkey: %w", err)
}

branchKey, err := deriveChildKey(accountPubKey, params.Branch)
if err != nil {
return nil, fmt.Errorf("derive branch: %w", err)
return nil, nil, nil, fmt.Errorf("derive branch: %w", err)
}
defer branchKey.Zero()

addrKey, err := deriveChildKey(branchKey, params.Index)
if err != nil {
return nil, fmt.Errorf("derive address index: %w", err)
return nil, nil, nil, fmt.Errorf("derive address "+
"index: %w", err)
}
defer addrKey.Zero()

pubKey, err := addrKey.ECPubKey()
if err != nil {
return nil, fmt.Errorf("derive address pubkey: %w", err)
return nil, nil, nil, fmt.Errorf("derive address "+
"pubkey: %w", err)
}

pubKeyBytes := pubKey.SerializeCompressed()

walletAddrType, err := addresstype.ToWallet(params.AddrType, false)
if err != nil {
return nil, fmt.Errorf("address type: %w", err)
return nil, nil, nil, fmt.Errorf("address type: %w", err)
}

addr, err := walletAddrType.AddrFromPubKeyBytes(
pubKeyBytes, w.cfg.ChainParams,
pubKeyBytes, chainParams,
)
if err != nil {
return nil, fmt.Errorf("derive address: %w", err)
return nil, nil, nil, fmt.Errorf("derive address: %w", err)
}

scriptPubKey, err := txscript.PayToAddrScript(addr)
if err != nil {
return nil, fmt.Errorf("pay to addr: %w", err)
return nil, nil, nil, fmt.Errorf("pay to addr: %w", err)
}

return addr, scriptPubKey, pubKeyBytes, nil
}

// deriveAddressData derives one SQL-store address from account public material.
func (w *Wallet) deriveAddressData(_ context.Context,
params db.AddressDerivationParams) (*db.DerivedAddressData, error) {

_, scriptPubKey, pubKeyBytes, err := deriveStoreAddress(
params, w.cfg.ChainParams,
)
if err != nil {
return nil, err
}

return &db.DerivedAddressData{
Expand Down
Loading
Loading