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
135 changes: 130 additions & 5 deletions config_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"sync/atomic"
"time"

"github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
Expand Down Expand Up @@ -84,6 +85,12 @@ const (
paymentMigration = 14
)

var (
// waddrmgrNamespaceKey is the namespace key where btcwallet stores the
// address manager state.
waddrmgrNamespaceKey = []byte("waddrmgr")
)

// GrpcRegistrar is an interface that must be satisfied by an external subserver
// that wants to be able to register its own gRPC server onto lnd's main
// grpc.Server instance.
Expand Down Expand Up @@ -1554,7 +1561,9 @@ func waitForWalletPassword(cfg *Config,
func importWatchOnlyAccounts(wallet *wallet.Wallet,
initMsg *walletunlocker.WalletInitMsg) error {

scopes := make([]waddrmgr.ScopedIndex, 0, len(initMsg.WatchOnlyAccounts))
scopes := make(
[]waddrmgr.ScopedIndex, 0, len(initMsg.WatchOnlyAccounts),
)
for scope := range initMsg.WatchOnlyAccounts {
scopes = append(scopes, scope)
}
Expand Down Expand Up @@ -1587,12 +1596,14 @@ func importWatchOnlyAccounts(wallet *wallet.Wallet,
name = "default"
}

_, err := wallet.ImportAccountWithScope(
name, initMsg.WatchOnlyAccounts[scope],
initMsg.WatchOnlyMasterFingerprint, scope.Scope,
addrSchema,
err := importWatchOnlyAccount(
wallet, scope, name, initMsg.WatchOnlyAccounts[scope],
initMsg.WatchOnlyMasterFingerprint, addrSchema,
)
if err != nil {
// Each account import runs in its own db transaction
// (as with the previous import path), so an error here
// does not roll back accounts imported before.
return fmt.Errorf("could not import account %v: %w",
name, err)
}
Expand All @@ -1601,6 +1612,120 @@ func importWatchOnlyAccounts(wallet *wallet.Wallet,
return nil
}

// importWatchOnlyAccount imports a single watch-only account.
//
// Account index 0 uses ImportAccountWithScope, which allocates the next
// sequential account number internally. This preserves the "default" account
// semantics and naming that the rest of lnd expects for on-chain operations.
//
// Non-zero accounts use NewRawAccountWatchingOnly to create the account at an
// explicit account number matching the key family index. This is critical for
// remote-signer setups where DeriveKey(KeyFamily=N) must resolve to btcwallet
// account N. Without explicit placement, btcwallet would assign contiguous
// numbers (1, 2, 3, ...) regardless of the intended key family, breaking
// sparse families like N=43210.
//
// After NewRawAccountWatchingOnly creates the account, it has the synthetic
// name "act:<index>". We rename it to lnd's human-readable format
// (e.g. "1017'/1'/43210'") so the account appears correctly in RPCs and UI.
//
// NOTE: Each account import runs in its own walletdb transaction. For the
// explicit (non-zero) path, both the account creation and rename happen in the
// same transaction: if any step fails, that account is rolled back atomically.
// However, accounts imported before a failure are already committed.
func importWatchOnlyAccount(wallet *wallet.Wallet, scope waddrmgr.ScopedIndex,
name string, accountPubKey *hdkeychain.ExtendedKey,
masterKeyFingerprint uint32,
addrSchema waddrmgr.ScopeAddrSchema) error {

if scope.Index == 0 {
_, err := wallet.ImportAccountWithScope(
name, accountPubKey, masterKeyFingerprint, scope.Scope,
addrSchema,
)

return err
}

// Validate the account xpub using btcwallet's own dry-run import
// path. This avoids duplicating btcwallet's internal validation
// (validateExtendedPubKey / isPubKeyForNet) while keeping the same
// rejection semantics as ImportAccountWithScope. The dry-run rolls
// back its database transaction, so no persistent state is created.
//
// We pass WitnessPubKey as the address type because custom-scope
// keys use standard version bytes (tpub/xpub) that require an
// explicit type for btcwallet's scope detection; the actual scope
// is handled by our own walletdb transaction below.
addrType := waddrmgr.WitnessPubKey
_, _, _, err := wallet.ImportAccountDryRun(
name, accountPubKey, masterKeyFingerprint, &addrType, 1,
)
if err != nil {
return err
}

db := wallet.Database()

return walletdb.Update(db, func(tx walletdb.ReadWriteTx) error {
addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey)
if addrmgrNs == nil {
return fmt.Errorf("waddrmgr namespace not found")
}

// Mirror what ImportAccountWithScope in btcwallet itself does:
// try to find an already-registered manager for this key scope.
// If the scope was already set up, we reuse it. Otherwise we
// create the scope in the DB and register the in-memory
// manager.
scopedMgr, err := wallet.Manager.FetchScopedKeyManager(
scope.Scope,
)
if err != nil {
var manErr waddrmgr.ManagerError
if !errors.As(err, &manErr) ||
manErr.ErrorCode != waddrmgr.ErrScopeNotFound {

return err
}

scopedMgr, err = wallet.Manager.NewScopedKeyManager(
addrmgrNs, scope.Scope, addrSchema,
)
if err != nil {
return err
}
}
Comment on lines +1681 to +1698
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's value in adding some docs for explaining the logic here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a comment:

// Mirror what ImportAccountWithScope in btcwallet itself does:
// try to find an already-registered manager for this key scope.
// If the scope was already set up, we reuse it. Otherwise we
// create the scope in the DB and register the in-memory
// manager.


// Ensure we don't accidentally overwrite an existing account.
_, err = scopedMgr.AccountName(addrmgrNs, scope.Index)
if err == nil {
return fmt.Errorf("account %d already exists in "+
"scope %v", scope.Index, scope.Scope)
}

var manErr waddrmgr.ManagerError
if !errors.As(err, &manErr) ||
manErr.ErrorCode != waddrmgr.ErrAccountNotFound {

return err
}

err = scopedMgr.NewRawAccountWatchingOnly(
addrmgrNs, scope.Index, accountPubKey,
masterKeyFingerprint, &addrSchema,
)
if err != nil {
return err
}

// NewRawAccountWatchingOnly assigns the synthetic account name
// "act:<index>". Rename it to lnd's expected human-readable
// account name to preserve existing RPC/UI naming behavior.
return scopedMgr.RenameAccount(addrmgrNs, scope.Index, name)
})
}

// handleNeutrinoPostgresDBMigration handles the migration of the neutrino db
// to postgres. Initially we kept the neutrino db in the bolt db when running
// with kvdb postgres backend. Now now move it to postgres as well. However we
Expand Down
174 changes: 174 additions & 0 deletions config_builder_watchonly_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package lnd

import (
"errors"
"fmt"
"testing"
"time"

"github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcwallet/waddrmgr"
"github.com/btcsuite/btcwallet/wallet"
"github.com/btcsuite/btcwallet/walletdb"
"github.com/lightningnetwork/lnd/keychain"
"github.com/lightningnetwork/lnd/kvdb"
"github.com/lightningnetwork/lnd/walletunlocker"
"github.com/stretchr/testify/require"
)

// TestImportWatchOnlyAccountsPreservesSparseAccountNumbers ensures watch-only
// initialization preserves explicit sparse account indices so deriving keys by
// KeyFamily uses the intended internal account number.
func TestImportWatchOnlyAccountsPreservesSparseAccountNumbers(t *testing.T) {
const (
pubPass = "public-pass"
sparseAccount = uint32(43210)
masterFingerprint = uint32(0x12345678)
)

loader := wallet.NewLoader(
&chaincfg.TestNet3Params, t.TempDir(), true,
kvdb.DefaultDBTimeout, 0,
)
testWallet, err := loader.CreateNewWatchingOnlyWallet(
[]byte(pubPass), time.Unix(0, 0),
)
require.NoError(t, err)

t.Cleanup(func() {
require.NoError(t, loader.UnloadWallet())
})

rootKey, err := hdkeychain.NewMaster(
[]byte("01234567890123456789012345678901"),
&chaincfg.TestNet3Params,
)
require.NoError(t, err)

bip84AccountXpub, err := deriveAccountXpub(
rootKey, waddrmgr.KeyScopeBIP0084.Purpose,
waddrmgr.KeyScopeBIP0084.Coin, 0,
)
require.NoError(t, err)

customScope := waddrmgr.KeyScope{
Purpose: keychain.BIP0043Purpose,
Coin: keychain.CoinTypeTestnet,
}
customAccountXpub, err := deriveAccountXpub(
rootKey, customScope.Purpose, customScope.Coin, sparseAccount,
)
require.NoError(t, err)

watchOnlyAccounts := map[waddrmgr.ScopedIndex]*hdkeychain.ExtendedKey{
{
Scope: waddrmgr.KeyScopeBIP0084,
Index: 0,
}: bip84AccountXpub,
{
Scope: customScope,
Index: sparseAccount,
}: customAccountXpub,
}
initMsg := &walletunlocker.WalletInitMsg{
WatchOnlyMasterFingerprint: masterFingerprint,
WatchOnlyAccounts: watchOnlyAccounts,
}
require.NoError(t, importWatchOnlyAccounts(testWallet, initMsg))

// This is the same call path WalletKit.DeriveKey uses for watch-only
// wallets: no account auto-creation, account number must already exist.
keyRing := keychain.NewBtcWalletKeyRing(
testWallet, keychain.CoinTypeTestnet,
)
keyDesc, err := keyRing.DeriveKey(keychain.KeyLocator{
Family: keychain.KeyFamily(sparseAccount),
Index: 0,
})
require.NoError(t, err)
require.NotNil(t, keyDesc.PubKey)

db := testWallet.Database()

err = walletdb.View(db, func(tx walletdb.ReadTx) error {
addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey)
if addrmgrNs == nil {
return fmt.Errorf("waddrmgr namespace not found")
}

// Ensure the sparse account number was preserved.
customMgr, err := testWallet.Manager.FetchScopedKeyManager(
customScope,
)
if err != nil {
return err
}

name, err := customMgr.AccountName(addrmgrNs, sparseAccount)
if err != nil {
return err
}
expectedName := fmt.Sprintf(
"%s/%d'", customScope.String(), sparseAccount,
)
if name != expectedName {
return fmt.Errorf("expected account %d name %q, got %q",
sparseAccount, expectedName, name)
}

// The old behavior would have created this account
// contiguously.
_, err = customMgr.AccountName(addrmgrNs, 1)
var managerErr waddrmgr.ManagerError
if !errors.As(err, &managerErr) ||
managerErr.ErrorCode != waddrmgr.ErrAccountNotFound {

return fmt.Errorf("expected account 1 to be missing, "+
"got: %v", err)
}

// Ensure account 0 in the default BIP84 scope keeps its
// "default" naming behavior.
bip84Mgr, err := testWallet.Manager.FetchScopedKeyManager(
waddrmgr.KeyScopeBIP0084,
)
if err != nil {
return err
}
defaultName, err := bip84Mgr.AccountName(addrmgrNs, 0)
if err != nil {
return err
}
if defaultName != "default" {
return fmt.Errorf("expected account 0 name default, "+
"got %q", defaultName)
}

return nil
})
require.NoError(t, err)
}

// deriveAccountXpub derives and neuters an account-level key at
// m/purpose'/coin_type'/account'.
func deriveAccountXpub(rootKey *hdkeychain.ExtendedKey, purpose, coinType,
account uint32) (*hdkeychain.ExtendedKey, error) {

path := []uint32{
purpose + hdkeychain.HardenedKeyStart,
coinType + hdkeychain.HardenedKeyStart,
account + hdkeychain.HardenedKeyStart,
}

current := rootKey
var err error
for _, pathPart := range path {
current, err = current.Derive(pathPart)
if err != nil {
return nil, err
}
}

return current.Neuter()
}
9 changes: 9 additions & 0 deletions docs/release-notes/release-notes-0.21.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@
the chain watch filter on restart. This was a pre-existing bug since
private taproot channels were first introduced.

- Watch-only wallet account imports
[now preserve](https://github.com/lightningnetwork/lnd/pull/10644)
explicit non-zero account numbers per scope. This fixes remote-signer setups
where `DeriveKey(KeyFamily=N)` failed for sparse custom key families (e.g.
`N=43210`) because accounts were previously remapped contiguously. Account
numbers are written to the wallet database at creation time and are not
re-imported on unlock, so downgrading after wallet creation does not remap
them.

# New Features

- [Basic Support](https://github.com/lightningnetwork/lnd/pull/9868) for onion
Expand Down
16 changes: 13 additions & 3 deletions docs/remote-signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,19 @@ watching-only`):
- Purpose: 49, coin type 0, account 0 (`m/49'/0'/0'`, np2wkh)
- Purpose: 84, coin type 0, account 0 (`m/84'/0'/0'`, p2wkh)
- Purpose: 86, coin type 0, account 0 (`m/86'/0'/0'`, p2tr)
- Purpose: 1017, coin type X (mainnet: 0, testnet/regtest: 1), account 0 to 255
(`m/1017'/X'/0'` up to `m/1017'/X'/255'`, internal to `lnd`, used for node
identity, channel keys, watchtower sessions and so on).
- Purpose: 1017, coin type X (mainnet: 0, testnet/regtest: 1), account 0 to
255 (`m/1017'/X'/0'` up to `m/1017'/X'/255'`, internal to `lnd`, used for
node identity, channel keys, watchtower sessions and so on).
- Any additional custom `m/1017'/X'/N'` accounts that your signer actively
uses (for example `N=43210`). Starting with `lnd v0.21.0`, these explicit
account numbers are preserved in watch-only wallets, so
`DeriveKey(KeyFamily=N)` works without requiring contiguous account creation.

**Note on older versions:** `lnd` versions before `v0.21.0` import watch-only
accounts contiguously, so a watch-only wallet *created* on an older version
will have sparse accounts remapped (e.g. account 43210 becomes account 1).
Downgrading after wallet creation is safe: accounts are already persisted at
the correct indices and are not re-imported on unlock.

## Example initialization script

Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,6 @@ replace google.golang.org/protobuf => github.com/lightninglabs/protobuf-go-hex-d
go 1.25.5

retract v0.0.2

// TODO(Boris): remove this when https://github.com/btcsuite/btcwallet/pull/1207 is merged
replace github.com/btcsuite/btcwallet => github.com/starius/btcwallet v0.16.17-fix-side-effects
Loading
Loading