From 7af46ecaf5bdb07a581e3374a4fccb0233db1788 Mon Sep 17 00:00:00 2001 From: wieghx Date: Tue, 18 Aug 2026 23:11:02 +0800 Subject: [PATCH 1/5] walletrpc+lncli: recover both address branches XCreateAccount recovery previously told operators to replay a single address count with NextAddr. NextAddr defaults to the external branch, so that leaves internal/change scripts unknown after restore. Record and replay external_key_count and internal_key_count separately. Signed-off-by: wieghx --- cmd/commands/walletrpc_active.go | 22 +++++++++---- lnrpc/walletrpc/walletkit.pb.go | 4 +-- lnrpc/walletrpc/walletkit.proto | 26 ++++++++++----- lnrpc/walletrpc/walletkit.swagger.json | 4 +-- lnrpc/walletrpc/walletkit_grpc.pb.go | 44 +++++++++++++++++++------- 5 files changed, 69 insertions(+), 31 deletions(-) diff --git a/cmd/commands/walletrpc_active.go b/cmd/commands/walletrpc_active.go index 4459dec931..4da5541e0d 100644 --- a/cmd/commands/walletrpc_active.go +++ b/cmd/commands/walletrpc_active.go @@ -1902,9 +1902,14 @@ var createAccountCommand = cli.Command{ IMPORTANT: funds held in an account created here are NOT found by a seed-only restore, because the wallet's recovery scan only rederives addresses for the default account. Recovering them additionally - requires the account's key scope and index, and re-deriving the - addresses it had issued, before rescanning. Record the derivation path - printed below alongside your seed before depositing to this account. + requires the account's key scope and index, plus both + external_key_count and internal_key_count. Replay NextAddr with + change=false at least external_key_count times and NextAddr with + change=true at least internal_key_count times before rescanning. + Record the derivation path and both counters printed below + alongside your seed before depositing to this account. The + counters start at zero and increase as the account is used, so + the path alone is not enough. `, Flags: []cli.Flag{ cli.StringFlag{ @@ -1962,12 +1967,15 @@ func createAccount(ctx *cli.Context) error { printRespJSON(resp) - // The derivation path in the response is what a later recovery needs, - // so point at it here rather than only in the command's help text: - // this is the one moment the operator is looking at it. + // The derivation path and both branch counters in the response are + // what a later recovery needs, so point at them here rather than + // only in the command's help text: this is the one moment the + // operator is looking at them. _, _ = fmt.Fprintf(os.Stderr, "\nNOTE: a seed-only restore will not "+ "find funds in this account. Record its derivation path "+ - "(above) with your seed before depositing.\n") + "and both external_key_count and internal_key_count "+ + "(above) with your seed. On restore, replay NextAddr "+ + "on each branch before rescanning.\n") return nil } diff --git a/lnrpc/walletrpc/walletkit.pb.go b/lnrpc/walletrpc/walletkit.pb.go index 9589532a85..045fc9e2d2 100644 --- a/lnrpc/walletrpc/walletkit.pb.go +++ b/lnrpc/walletrpc/walletkit.pb.go @@ -1326,8 +1326,8 @@ type XCreateAccountRequest struct { // Override the requirement for being in dev mode by setting this to true and // confirming the user knows what they are doing: funds held in an account // created here are not rediscovered by a seed-only restore, so recovering - // them requires having recorded the account's key scope and index and the - // number of addresses it issued. + // them requires having recorded the account's key scope and index and + // both external_key_count and internal_key_count. IKnowWhatIAmDoing bool `protobuf:"varint,3,opt,name=i_know_what_i_am_doing,json=iKnowWhatIAmDoing,proto3" json:"i_know_what_i_am_doing,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/lnrpc/walletrpc/walletkit.proto b/lnrpc/walletrpc/walletkit.proto index e46c0678a0..15b1e7ad48 100644 --- a/lnrpc/walletrpc/walletkit.proto +++ b/lnrpc/walletrpc/walletkit.proto @@ -134,12 +134,22 @@ service WalletKit { sequentially per key scope, shared with accounts created by ImportAccount. To keep an account recoverable, record its key scope, the account index - (the account's derivation_path in the response), and how many addresses - it has issued. To restore: re-create every account in that key scope in - their original order so the index counter lands on the same value, - re-derive at least as many addresses as were previously issued with - NextAddr — a rescan only searches for addresses already present in the - wallet database, and a freshly created account has none — and only then + (the account's derivation_path in the response), and both + external_key_count and internal_key_count. The two counters are + independent: NextAddr's change field selects the branch, and it + defaults to the external branch. Replaying a single aggregate address + count, or calling NextAddr without change=true, leaves internal/change + scripts unknown to the restored wallet even after a transaction + rescan. Recording only the derivation path when the account is created + is not enough, because both counters start at zero and increase as the + account is used. + + To restore: re-create every account in that key scope in their + original order so the index counter lands on the same value; call + NextAddr with change=false at least external_key_count times and + NextAddr with change=true at least internal_key_count times — a + rescan only searches for addresses already present in the wallet + database, and a freshly created account has none — and only then rescan with --reset-wallet-transactions. */ rpc XCreateAccount (XCreateAccountRequest) returns (XCreateAccountResponse); @@ -667,8 +677,8 @@ message XCreateAccountRequest { Override the requirement for being in dev mode by setting this to true and confirming the user knows what they are doing: funds held in an account created here are not rediscovered by a seed-only restore, so recovering - them requires having recorded the account's key scope and index and the - number of addresses it issued. + them requires having recorded the account's key scope and index and + both external_key_count and internal_key_count. */ bool i_know_what_i_am_doing = 3; } diff --git a/lnrpc/walletrpc/walletkit.swagger.json b/lnrpc/walletrpc/walletkit.swagger.json index dbc17b6a5c..9cea2e1f2d 100644 --- a/lnrpc/walletrpc/walletkit.swagger.json +++ b/lnrpc/walletrpc/walletkit.swagger.json @@ -99,7 +99,7 @@ "/v2/wallet/accounts/create": { "post": { "summary": "lncli: `wallet accounts create`\nXCreateAccount is an experimental API that creates a new named account\nwithin the wallet, deriving the account's keys from the wallet's master\nkey.", - "description": "In contrast to ImportAccount, which registers a watch-only account from an\nexternally supplied extended public key, the account created here is fully\nowned by the wallet: it derives its own addresses and can sign for its own\noutputs. That makes it usable as an isolated pocket of funds inside a single\nwallet, because coin selection, change, balance and address derivation can\nall be scoped to it by name.\n\nNOTE: The X prefix marks this API as experimental: it may change or be\nremoved without the usual deprecation period. It additionally requires\ni_know_what_i_am_doing on release builds, because a seed-only restore does\nnot rediscover the funds an account created here holds; see the recovery\nnote below. That second gate comes off once recovery handles these\naccounts, at which point the X can be dropped too.\n\nNOTE: The wallet must be unlocked, as deriving the account key requires\naccess to the master private key.\n\nNOTE: The call is not idempotent, and the account is created before the\nresponse is sent. A client that cancels or times out may still have had\nthe account created, in which case its retry fails with \"already exists\"\n— indistinguishable from a genuine name clash. Check ListAccounts before\nretrying.\n\nNOTE: The account's address type is permanent and also fixes the type of\nits change outputs. lnd resolves a custom account name within the key\nscope implied by the requested address type, so every later call must ask\nfor the address type that maps to the same scope or the account will\nappear not to exist. NextAddr and NewAddress take lnrpc.AddressType,\nwhich has no HYBRID_NESTED_WITNESS_PUBKEY_HASH member: an account created\nas HYBRID_NESTED_WITNESS_PUBKEY_HASH must be addressed with\nNESTED_PUBKEY_HASH, which maps to the same BIP-0049Plus scope.\nTAPROOT_PUBKEY and WITNESS_PUBKEY_HASH map across unchanged.\n\nNOTE: Funds held in an account created here are not rediscovered by a\nseed-only recovery, because lnd's recovery scan only rederives addresses\nfor the wallet's default account (btcwallet's RecoveryManager hardcodes\nwaddrmgr.DefaultAccountNum). They are still recoverable, but only by\nreconstructing the account first, and the account name is not what has to\nbe reproduced: accounts are derived from an index that btcwallet assigns\nsequentially per key scope, shared with accounts created by ImportAccount.\n\nTo keep an account recoverable, record its key scope, the account index\n(the account's derivation_path in the response), and how many addresses\nit has issued. To restore: re-create every account in that key scope in\ntheir original order so the index counter lands on the same value,\nre-derive at least as many addresses as were previously issued with\nNextAddr — a rescan only searches for addresses already present in the\nwallet database, and a freshly created account has none — and only then\nrescan with --reset-wallet-transactions.", + "description": "In contrast to ImportAccount, which registers a watch-only account from an\nexternally supplied extended public key, the account created here is fully\nowned by the wallet: it derives its own addresses and can sign for its own\noutputs. That makes it usable as an isolated pocket of funds inside a single\nwallet, because coin selection, change, balance and address derivation can\nall be scoped to it by name.\n\nNOTE: The X prefix marks this API as experimental: it may change or be\nremoved without the usual deprecation period. It additionally requires\ni_know_what_i_am_doing on release builds, because a seed-only restore does\nnot rediscover the funds an account created here holds; see the recovery\nnote below. That second gate comes off once recovery handles these\naccounts, at which point the X can be dropped too.\n\nNOTE: The wallet must be unlocked, as deriving the account key requires\naccess to the master private key.\n\nNOTE: The call is not idempotent, and the account is created before the\nresponse is sent. A client that cancels or times out may still have had\nthe account created, in which case its retry fails with \"already exists\"\n— indistinguishable from a genuine name clash. Check ListAccounts before\nretrying.\n\nNOTE: The account's address type is permanent and also fixes the type of\nits change outputs. lnd resolves a custom account name within the key\nscope implied by the requested address type, so every later call must ask\nfor the address type that maps to the same scope or the account will\nappear not to exist. NextAddr and NewAddress take lnrpc.AddressType,\nwhich has no HYBRID_NESTED_WITNESS_PUBKEY_HASH member: an account created\nas HYBRID_NESTED_WITNESS_PUBKEY_HASH must be addressed with\nNESTED_PUBKEY_HASH, which maps to the same BIP-0049Plus scope.\nTAPROOT_PUBKEY and WITNESS_PUBKEY_HASH map across unchanged.\n\nNOTE: Funds held in an account created here are not rediscovered by a\nseed-only recovery, because lnd's recovery scan only rederives addresses\nfor the wallet's default account (btcwallet's RecoveryManager hardcodes\nwaddrmgr.DefaultAccountNum). They are still recoverable, but only by\nreconstructing the account first, and the account name is not what has to\nbe reproduced: accounts are derived from an index that btcwallet assigns\nsequentially per key scope, shared with accounts created by ImportAccount.\n\nTo keep an account recoverable, record its key scope, the account index\n(the account's derivation_path in the response), and both\nexternal_key_count and internal_key_count. The two counters are\nindependent: NextAddr's change field selects the branch, and it\ndefaults to the external branch. Replaying a single aggregate address\ncount, or calling NextAddr without change=true, leaves internal/change\nscripts unknown to the restored wallet even after a transaction\nrescan. Recording only the derivation path when the account is created\nis not enough, because both counters start at zero and increase as the\naccount is used.\n\nTo restore: re-create every account in that key scope in their\noriginal order so the index counter lands on the same value; call\nNextAddr with change=false at least external_key_count times and\nNextAddr with change=true at least internal_key_count times — a\nrescan only searches for addresses already present in the wallet\ndatabase, and a freshly created account has none — and only then\nrescan with --reset-wallet-transactions.", "operationId": "WalletKit_XCreateAccount", "responses": { "200": { @@ -2506,7 +2506,7 @@ }, "i_know_what_i_am_doing": { "type": "boolean", - "description": "Override the requirement for being in dev mode by setting this to true and\nconfirming the user knows what they are doing: funds held in an account\ncreated here are not rediscovered by a seed-only restore, so recovering\nthem requires having recorded the account's key scope and index and the\nnumber of addresses it issued." + "description": "Override the requirement for being in dev mode by setting this to true and\nconfirming the user knows what they are doing: funds held in an account\ncreated here are not rediscovered by a seed-only restore, so recovering\nthem requires having recorded the account's key scope and index and\nboth external_key_count and internal_key_count." } } }, diff --git a/lnrpc/walletrpc/walletkit_grpc.pb.go b/lnrpc/walletrpc/walletkit_grpc.pb.go index 6cd3e67818..0c29f67058 100644 --- a/lnrpc/walletrpc/walletkit_grpc.pb.go +++ b/lnrpc/walletrpc/walletkit_grpc.pb.go @@ -104,12 +104,22 @@ type WalletKitClient interface { // sequentially per key scope, shared with accounts created by ImportAccount. // // To keep an account recoverable, record its key scope, the account index - // (the account's derivation_path in the response), and how many addresses - // it has issued. To restore: re-create every account in that key scope in - // their original order so the index counter lands on the same value, - // re-derive at least as many addresses as were previously issued with - // NextAddr — a rescan only searches for addresses already present in the - // wallet database, and a freshly created account has none — and only then + // (the account's derivation_path in the response), and both + // external_key_count and internal_key_count. The two counters are + // independent: NextAddr's change field selects the branch, and it + // defaults to the external branch. Replaying a single aggregate address + // count, or calling NextAddr without change=true, leaves internal/change + // scripts unknown to the restored wallet even after a transaction + // rescan. Recording only the derivation path when the account is created + // is not enough, because both counters start at zero and increase as the + // account is used. + // + // To restore: re-create every account in that key scope in their + // original order so the index counter lands on the same value; call + // NextAddr with change=false at least external_key_count times and + // NextAddr with change=true at least internal_key_count times — a + // rescan only searches for addresses already present in the wallet + // database, and a freshly created account has none — and only then // rescan with --reset-wallet-transactions. XCreateAccount(ctx context.Context, in *XCreateAccountRequest, opts ...grpc.CallOption) (*XCreateAccountResponse, error) // lncli: `wallet requiredreserve` @@ -715,12 +725,22 @@ type WalletKitServer interface { // sequentially per key scope, shared with accounts created by ImportAccount. // // To keep an account recoverable, record its key scope, the account index - // (the account's derivation_path in the response), and how many addresses - // it has issued. To restore: re-create every account in that key scope in - // their original order so the index counter lands on the same value, - // re-derive at least as many addresses as were previously issued with - // NextAddr — a rescan only searches for addresses already present in the - // wallet database, and a freshly created account has none — and only then + // (the account's derivation_path in the response), and both + // external_key_count and internal_key_count. The two counters are + // independent: NextAddr's change field selects the branch, and it + // defaults to the external branch. Replaying a single aggregate address + // count, or calling NextAddr without change=true, leaves internal/change + // scripts unknown to the restored wallet even after a transaction + // rescan. Recording only the derivation path when the account is created + // is not enough, because both counters start at zero and increase as the + // account is used. + // + // To restore: re-create every account in that key scope in their + // original order so the index counter lands on the same value; call + // NextAddr with change=false at least external_key_count times and + // NextAddr with change=true at least internal_key_count times — a + // rescan only searches for addresses already present in the wallet + // database, and a freshly created account has none — and only then // rescan with --reset-wallet-transactions. XCreateAccount(context.Context, *XCreateAccountRequest) (*XCreateAccountResponse, error) // lncli: `wallet requiredreserve` From dfa23c69f1e5e08a61790f095bdcbf7f15e884bf Mon Sep 17 00:00:00 2001 From: wieghx Date: Tue, 18 Aug 2026 23:11:02 +0800 Subject: [PATCH 2/5] itest: recover XCreateAccount on both branches Recreate a non-trivial account index, leave value on an internal change output via FundPsbt, then restore from seed, replay both NextAddr branches, rescan, and spend. Signed-off-by: wieghx --- docs/release-notes/release-notes-0.22.0.md | 6 + itest/lnd_wallet.go | 4 + itest/lnd_wallet_xcreate_account.go | 204 +++++++++++++++++++++ lntest/rpc/wallet_kit.go | 13 ++ 4 files changed, 227 insertions(+) diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index 750a20069c..84a8303a33 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -22,6 +22,12 @@ # Bug Fixes +* The `XCreateAccount` recovery procedure now [records and + replays](https://github.com/lightningnetwork/lnd/issues/11087) the external + and internal address branches separately. `NextAddr` defaults to the external + branch, so following the previous procedure after a `FundPsbt` spend could + leave change outputs invisible after restore. + * Bitcoind outbound peer health checks [now use](https://github.com/lightningnetwork/lnd/pull/10686) `getnetworkinfo.connections_out` instead of `getpeerinfo`. The same PR also [clarifies](https://github.com/lightningnetwork/lnd/issues/10568) the ZMQ diff --git a/itest/lnd_wallet.go b/itest/lnd_wallet.go index a3b6778397..cdc6cce379 100644 --- a/itest/lnd_wallet.go +++ b/itest/lnd_wallet.go @@ -20,6 +20,10 @@ var walletTestCases = []*lntest.TestCase{ Name: "xcreate account rejections", TestFunc: testXCreateAccountRejections, }, + { + Name: "xcreate account branch recovery", + TestFunc: testXCreateAccountBranchRecovery, + }, { Name: "listunspent P2WPKH", TestFunc: func(ht *lntest.HarnessTest) { diff --git a/itest/lnd_wallet_xcreate_account.go b/itest/lnd_wallet_xcreate_account.go index db828f6b2f..abcc6f31c4 100644 --- a/itest/lnd_wallet_xcreate_account.go +++ b/itest/lnd_wallet_xcreate_account.go @@ -14,6 +14,11 @@ const ( // createAccountName is the account these tests create and spend from. createAccountName = "custom" + // precedingAccountName is created first so the account under test is + // not the first custom account in its key scope. Recovery has to + // recreate accounts in the original order. + precedingAccountName = "preceding" + // defaultCreateAccountFeeRate is the sat/vB rate the miner uses when // funding the account under test. defaultCreateAccountFeeRate = btcutil.Amount(10) @@ -188,3 +193,202 @@ func testXCreateAccountRejections(ht *lntest.HarnessTest) { ) require.ErrorContains(ht, err, "cannot be created") } + +// testXCreateAccountBranchRecovery asserts the manual recovery procedure for +// a wallet-derived account: both address branches must be replayed, because +// NextAddr defaults to the external branch and FundPsbt change lives on the +// internal one. +func testXCreateAccountBranchRecovery(ht *lntest.HarnessTest) { + password := []byte("The Magic Words are Squeamish Ossifrage") + alice, mnemonic, _ := ht.NewNodeWithSeed( + "Alice", nil, password, false, + ) + + // A preceding account so the target is not index 1 of the BIP-0086 + // scope. Recovery has to recreate every account in that scope in + // order for the index counter to land on the same value. + alice.RPC.XCreateAccount(&walletrpc.XCreateAccountRequest{ + Name: precedingAccountName, + AddressType: walletrpc.AddressType_TAPROOT_PUBKEY, + }) + + account := alice.RPC.XCreateAccount(&walletrpc.XCreateAccountRequest{ + Name: createAccountName, + AddressType: walletrpc.AddressType_TAPROOT_PUBKEY, + }).GetAccount() + + // Fund an external address belonging to the target account. + extAddr := alice.RPC.NewAddress(&lnrpc.NewAddressRequest{ + Type: lnrpc.AddressType_TAPROOT_PUBKEY, + Account: createAccountName, + }).GetAddress() + + const fundAmt = btcutil.Amount(500_000) + ht.SendOutputsWithoutChange( + []*wire.TxOut{{ + Value: int64(fundAmt), + PkScript: ht.PayToAddrScript(ht.DecodeAddress(extAddr)), + }}, defaultCreateAccountFeeRate, + ) + ht.MineBlocksAndAssertNumTxes(1, 1) + + // Spend with FundPsbt so leftover value sits on an internal change + // address, while the destination stays on the external branch. + dest := alice.RPC.NewAddress(&lnrpc.NewAddressRequest{ + Type: lnrpc.AddressType_TAPROOT_PUBKEY, + Account: createAccountName, + }).GetAddress() + + funded := alice.RPC.FundPsbt(&walletrpc.FundPsbtRequest{ + Template: &walletrpc.FundPsbtRequest_Raw{ + Raw: &walletrpc.TxTemplate{ + Outputs: map[string]uint64{ + dest: uint64(fundAmt / 2), + }, + }, + }, + Fees: &walletrpc.FundPsbtRequest_SatPerVbyte{ + SatPerVbyte: 5, + }, + Account: createAccountName, + }) + + finalized := alice.RPC.FinalizePsbt(&walletrpc.FinalizePsbtRequest{ + FundedPsbt: funded.GetFundedPsbt(), + Account: createAccountName, + }) + alice.RPC.PublishTransaction(&walletrpc.Transaction{ + TxHex: finalized.GetRawFinalTx(), + }) + ht.MineBlocksAndAssertNumTxes(1, 1) + + listed := alice.RPC.ListAccounts(&walletrpc.ListAccountsRequest{ + Name: createAccountName, + AddressType: walletrpc.AddressType_TAPROOT_PUBKEY, + }).GetAccounts() + require.Len(ht, listed, 1) + + // Both counters must have moved. If the internal count is still + // zero, FundPsbt did not produce a change output and this test + // would not catch the recovery bug. + extCount := listed[0].GetExternalKeyCount() + intCount := listed[0].GetInternalKeyCount() + require.Greater(ht, extCount, uint32(0), + "external branch should have issued addresses") + require.Greater(ht, intCount, uint32(0), + "internal branch should have issued a change address") + + after := alice.RPC.WalletBalance().GetAccountBalance() + wantBal := after[createAccountName].GetConfirmedBalance() + require.Greater(ht, wantBal, int64(fundAmt/2), + "change should have stayed in the account") + + xpub := account.GetExtendedPublicKey() + path := account.GetDerivationPath() + + // Restore the seed into a fresh wallet. The recovery window only + // rederives the default account, so the custom account's coins are + // still invisible until we reconstruct it by hand. + restored := ht.RestoreNodeWithSeed( + "AliceRestore", nil, password, mnemonic, "", 0, nil, + ) + + restored.RPC.XCreateAccount(&walletrpc.XCreateAccountRequest{ + Name: precedingAccountName, + AddressType: walletrpc.AddressType_TAPROOT_PUBKEY, + }) + restoredAcct := restored.RPC.XCreateAccount( + &walletrpc.XCreateAccountRequest{ + Name: createAccountName, + AddressType: walletrpc.AddressType_TAPROOT_PUBKEY, + }, + ).GetAccount() + + require.Equal(ht, xpub, restoredAcct.GetExtendedPublicKey()) + require.Equal(ht, path, restoredAcct.GetDerivationPath()) + + // Replay each branch separately. NextAddr defaults to change=false, + // which is why a single aggregate count is not enough. + for i := uint32(0); i < extCount; i++ { + restored.RPC.NextAddr(&walletrpc.AddrRequest{ + Account: createAccountName, + Type: walletrpc.AddressType_TAPROOT_PUBKEY, + Change: false, + }) + } + for i := uint32(0); i < intCount; i++ { + restored.RPC.NextAddr(&walletrpc.AddrRequest{ + Account: createAccountName, + Type: walletrpc.AddressType_TAPROOT_PUBKEY, + Change: true, + }) + } + + // A rescan only searches for addresses already in the wallet DB. + ht.RestartNodeWithExtraArgs( + restored, []string{"--reset-wallet-transactions"}, + ) + + ht.AssertWalletAccountBalance(restored, createAccountName, wantBal, 0) + + // Confirm both branches actually hold coins, not just that the + // aggregate balance happens to match. + var sawExt, sawInt bool + for _, acct := range restored.RPC.ListAddresses( + &walletrpc.ListAddressesRequest{ + AccountName: createAccountName, + }, + ).GetAccountWithAddresses() { + for _, addr := range acct.GetAddresses() { + if addr.GetBalance() == 0 { + continue + } + if addr.GetIsInternal() { + sawInt = true + } else { + sawExt = true + } + } + } + require.True(ht, sawExt, "external branch funds should be recovered") + require.True(ht, sawInt, "internal branch funds should be recovered") + + // The reconstructed account must also be spendable. + spendDest := restored.RPC.NewAddress(&lnrpc.NewAddressRequest{ + Type: lnrpc.AddressType_TAPROOT_PUBKEY, + Account: createAccountName, + }).GetAddress() + spendAmt := uint64(wantBal / 4) + require.Greater(ht, spendAmt, uint64(0)) + + fundedAgain := restored.RPC.FundPsbt(&walletrpc.FundPsbtRequest{ + Template: &walletrpc.FundPsbtRequest_Raw{ + Raw: &walletrpc.TxTemplate{ + Outputs: map[string]uint64{ + spendDest: spendAmt, + }, + }, + }, + Fees: &walletrpc.FundPsbtRequest_SatPerVbyte{ + SatPerVbyte: 5, + }, + Account: createAccountName, + }) + finalAgain := restored.RPC.FinalizePsbt(&walletrpc.FinalizePsbtRequest{ + FundedPsbt: fundedAgain.GetFundedPsbt(), + Account: createAccountName, + }) + require.NotEmpty(ht, finalAgain.GetRawFinalTx(), + "restored account must be able to sign") + + restored.RPC.PublishTransaction(&walletrpc.Transaction{ + TxHex: finalAgain.GetRawFinalTx(), + }) + ht.MineBlocksAndAssertNumTxes(1, 1) + + afterSpend := restored.RPC.WalletBalance().GetAccountBalance() + got := afterSpend[createAccountName].GetConfirmedBalance() + require.Less(ht, got, wantBal, "the spend should have paid a fee") + require.Greater(ht, got, wantBal-int64(maxCreateAccountSpendFee), + "the account should still hold its funds minus fees") +} diff --git a/lntest/rpc/wallet_kit.go b/lntest/rpc/wallet_kit.go index eeab553cf3..6feb72c0a7 100644 --- a/lntest/rpc/wallet_kit.go +++ b/lntest/rpc/wallet_kit.go @@ -303,6 +303,19 @@ func (h *HarnessRPC) ListAccounts( return resp } +// NextAddr makes a RPC call to the node's WalletKitClient and asserts. +func (h *HarnessRPC) NextAddr( + req *walletrpc.AddrRequest) *walletrpc.AddrResponse { + + ctxt, cancel := context.WithTimeout(h.runCtx, DefaultTimeout) + defer cancel() + + resp, err := h.WalletKit.NextAddr(ctxt, req) + h.NoError(err, "NextAddr") + + return resp +} + // XCreateAccount makes a RPC call to the node's WalletKitClient and asserts. func (h *HarnessRPC) XCreateAccount(req *walletrpc.XCreateAccountRequest, ) *walletrpc.XCreateAccountResponse { From 98fe04a63c53fc4a02218ad1cd07ca1b56dbf954 Mon Sep 17 00:00:00 2001 From: wieghx Date: Fri, 21 Aug 2026 16:53:34 +0800 Subject: [PATCH 3/5] lncli: record XCreateAccount counters from ListAccounts The create-account CLI printed "record both counters (above)" right after XCreateAccount, when both counters are still zero. An operator following that note would store two zeros and recover nothing on the internal branch. Point the Description and the post-create note at ListAccounts for the counters, and keep the derivation path as the thing to record at creation time. Signed-off-by: wieghx --- cmd/commands/walletrpc_active.go | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/cmd/commands/walletrpc_active.go b/cmd/commands/walletrpc_active.go index 4da5541e0d..e1cc07622e 100644 --- a/cmd/commands/walletrpc_active.go +++ b/cmd/commands/walletrpc_active.go @@ -1906,10 +1906,11 @@ var createAccountCommand = cli.Command{ external_key_count and internal_key_count. Replay NextAddr with change=false at least external_key_count times and NextAddr with change=true at least internal_key_count times before rescanning. - Record the derivation path and both counters printed below - alongside your seed before depositing to this account. The - counters start at zero and increase as the account is used, so - the path alone is not enough. + Record the derivation path printed below alongside your seed now. + The counters in that output are still zero; they only become + meaningful once the account has issued addresses, so read both + from ListAccounts before you need to restore. The path alone is + not enough. `, Flags: []cli.Flag{ cli.StringFlag{ @@ -1967,15 +1968,18 @@ func createAccount(ctx *cli.Context) error { printRespJSON(resp) - // The derivation path and both branch counters in the response are - // what a later recovery needs, so point at them here rather than - // only in the command's help text: this is the one moment the - // operator is looking at them. + // The derivation path in the response is what recovery needs to + // recreate the account. The branch counters are still zero here + // and only become meaningful after addresses are issued, so the + // note below tells the operator to read them from ListAccounts + // later rather than recording the zeros just printed. _, _ = fmt.Fprintf(os.Stderr, "\nNOTE: a seed-only restore will not "+ "find funds in this account. Record its derivation path "+ - "and both external_key_count and internal_key_count "+ - "(above) with your seed. On restore, replay NextAddr "+ - "on each branch before rescanning.\n") + "(above) with your seed. Both branch counters start at "+ + "zero here; read external_key_count and "+ + "internal_key_count from ListAccounts before you need "+ + "to restore, then replay NextAddr on each branch before "+ + "rescanning.\n") return nil } From 4536b8b0a1eb173ea938ca7025478bf5c4d2af33 Mon Sep 17 00:00:00 2001 From: wieghx Date: Fri, 21 Aug 2026 16:53:35 +0800 Subject: [PATCH 4/5] itest: assert external-only replay misses change The branch-recovery itest only showed that replaying both NextAddr branches recovers both outputs. That would still pass if the internal change were found some other way. Restore, replay only change=false, rescan, and assert the internal output is absent; then replay change=true and rescan again. Also comment why RestoreNodeWithSeed uses RecoveryWindow 0: a non-zero window only rederives the default account. Signed-off-by: wieghx --- itest/lnd_wallet_xcreate_account.go | 95 ++++++++++++++++++++--------- 1 file changed, 67 insertions(+), 28 deletions(-) diff --git a/itest/lnd_wallet_xcreate_account.go b/itest/lnd_wallet_xcreate_account.go index abcc6f31c4..547a082221 100644 --- a/itest/lnd_wallet_xcreate_account.go +++ b/itest/lnd_wallet_xcreate_account.go @@ -197,7 +197,9 @@ func testXCreateAccountRejections(ht *lntest.HarnessTest) { // testXCreateAccountBranchRecovery asserts the manual recovery procedure for // a wallet-derived account: both address branches must be replayed, because // NextAddr defaults to the external branch and FundPsbt change lives on the -// internal one. +// internal one. It first proves the documented failure — replaying only the +// external branch leaves the internal change output unknown after a rescan — +// and then proves that replaying the internal branch recovers it. func testXCreateAccountBranchRecovery(ht *lntest.HarnessTest) { password := []byte("The Magic Words are Squeamish Ossifrage") alice, mnemonic, _ := ht.NewNodeWithSeed( @@ -223,7 +225,10 @@ func testXCreateAccountBranchRecovery(ht *lntest.HarnessTest) { Account: createAccountName, }).GetAddress() - const fundAmt = btcutil.Amount(500_000) + const ( + fundAmt = btcutil.Amount(500_000) + destAmt = fundAmt / 2 + ) ht.SendOutputsWithoutChange( []*wire.TxOut{{ Value: int64(fundAmt), @@ -243,7 +248,7 @@ func testXCreateAccountBranchRecovery(ht *lntest.HarnessTest) { Template: &walletrpc.FundPsbtRequest_Raw{ Raw: &walletrpc.TxTemplate{ Outputs: map[string]uint64{ - dest: uint64(fundAmt / 2), + dest: uint64(destAmt), }, }, }, @@ -280,15 +285,16 @@ func testXCreateAccountBranchRecovery(ht *lntest.HarnessTest) { after := alice.RPC.WalletBalance().GetAccountBalance() wantBal := after[createAccountName].GetConfirmedBalance() - require.Greater(ht, wantBal, int64(fundAmt/2), + require.Greater(ht, wantBal, int64(destAmt), "change should have stayed in the account") xpub := account.GetExtendedPublicKey() path := account.GetDerivationPath() - // Restore the seed into a fresh wallet. The recovery window only - // rederives the default account, so the custom account's coins are - // still invisible until we reconstruct it by hand. + // Restore the seed into a fresh wallet. RecoveryWindow is 0 + // because a non-zero window would not help here: the recovery + // scan only rederives the default account, and the custom + // account does not exist until we recreate it below. restored := ht.RestoreNodeWithSeed( "AliceRestore", nil, password, mnemonic, "", 0, nil, ) @@ -307,8 +313,9 @@ func testXCreateAccountBranchRecovery(ht *lntest.HarnessTest) { require.Equal(ht, xpub, restoredAcct.GetExtendedPublicKey()) require.Equal(ht, path, restoredAcct.GetDerivationPath()) - // Replay each branch separately. NextAddr defaults to change=false, - // which is why a single aggregate count is not enough. + // Replay only the external branch first. NextAddr defaults to + // change=false, so this is the procedure an operator following + // the old single-count instruction would run. for i := uint32(0); i < extCount; i++ { restored.RPC.NextAddr(&walletrpc.AddrRequest{ Account: createAccountName, @@ -316,6 +323,28 @@ func testXCreateAccountBranchRecovery(ht *lntest.HarnessTest) { Change: false, }) } + + // A rescan only searches for addresses already in the wallet DB. + ht.RestartNodeWithExtraArgs( + restored, []string{"--reset-wallet-transactions"}, + ) + + // External funds should be visible; the internal change output + // should not. If this assertion fails, the rescan found the + // change without a change=true NextAddr replay, and the + // documented recovery procedure is wrong. + ht.AssertWalletAccountBalance( + restored, createAccountName, int64(destAmt), 0, + ) + sawExt, sawInt := accountBranchFunds( + restored.RPC.ListAddresses(&walletrpc.ListAddressesRequest{ + AccountName: createAccountName, + }), + ) + require.True(ht, sawExt, "external branch funds should be recovered") + require.False(ht, sawInt, "internal change should still be unknown") + + // Now replay the internal branch and rescan again. for i := uint32(0); i < intCount; i++ { restored.RPC.NextAddr(&walletrpc.AddrRequest{ Account: createAccountName, @@ -324,32 +353,17 @@ func testXCreateAccountBranchRecovery(ht *lntest.HarnessTest) { }) } - // A rescan only searches for addresses already in the wallet DB. ht.RestartNodeWithExtraArgs( restored, []string{"--reset-wallet-transactions"}, ) ht.AssertWalletAccountBalance(restored, createAccountName, wantBal, 0) - // Confirm both branches actually hold coins, not just that the - // aggregate balance happens to match. - var sawExt, sawInt bool - for _, acct := range restored.RPC.ListAddresses( - &walletrpc.ListAddressesRequest{ + sawExt, sawInt = accountBranchFunds( + restored.RPC.ListAddresses(&walletrpc.ListAddressesRequest{ AccountName: createAccountName, - }, - ).GetAccountWithAddresses() { - for _, addr := range acct.GetAddresses() { - if addr.GetBalance() == 0 { - continue - } - if addr.GetIsInternal() { - sawInt = true - } else { - sawExt = true - } - } - } + }), + ) require.True(ht, sawExt, "external branch funds should be recovered") require.True(ht, sawInt, "internal branch funds should be recovered") @@ -392,3 +406,28 @@ func testXCreateAccountBranchRecovery(ht *lntest.HarnessTest) { require.Greater(ht, got, wantBal-int64(maxCreateAccountSpendFee), "the account should still hold its funds minus fees") } + +// accountBranchFunds reports whether the named account currently holds a +// non-zero confirmed balance on the external and internal address +// branches. Addresses the wallet has not issued yet do not appear, so a +// missing internal branch after an external-only NextAddr replay is the +// recovery failure this test documents. +func accountBranchFunds( + resp *walletrpc.ListAddressesResponse, +) (sawExt, sawInt bool) { + + for _, acct := range resp.GetAccountWithAddresses() { + for _, addr := range acct.GetAddresses() { + if addr.GetBalance() == 0 { + continue + } + if addr.GetIsInternal() { + sawInt = true + } else { + sawExt = true + } + } + } + + return +} From d85901853329b11fbcae370ff58a3101b90e3e92 Mon Sep 17 00:00:00 2001 From: wieghx Date: Fri, 21 Aug 2026 22:52:15 +0800 Subject: [PATCH 5/5] itest: wait for rescan before absence check RestoreNodeWithSeed leaves SkipUnlock set, so RestartNode does not wait for SyncedToChain. After --reset-wallet-transactions that flag stays false until the wallet rescan reaches tip. Wait before the destAmt / sawInt checks so they observe a finished rescan rather than a dest-only intermediate. Signed-off-by: wieghx --- itest/lnd_wallet_xcreate_account.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/itest/lnd_wallet_xcreate_account.go b/itest/lnd_wallet_xcreate_account.go index 547a082221..8b127021d5 100644 --- a/itest/lnd_wallet_xcreate_account.go +++ b/itest/lnd_wallet_xcreate_account.go @@ -329,6 +329,14 @@ func testXCreateAccountBranchRecovery(ht *lntest.HarnessTest) { restored, []string{"--reset-wallet-transactions"}, ) + // RestoreNodeWithSeed leaves SkipUnlock set, so RestartNode does + // not wait for SyncedToChain. That flag includes wallet.IsSynced(): + // after --reset-wallet-transactions the wallet height is the + // birthday until the rescan reaches tip. Wait here so destAmt + // and sawInt are checked against a finished rescan, not a + // dest-only intermediate. + ht.WaitForBlockchainSync(restored) + // External funds should be visible; the internal change output // should not. If this assertion fails, the rescan found the // change without a change=true NextAddr replay, and the @@ -356,6 +364,7 @@ func testXCreateAccountBranchRecovery(ht *lntest.HarnessTest) { ht.RestartNodeWithExtraArgs( restored, []string{"--reset-wallet-transactions"}, ) + ht.WaitForBlockchainSync(restored) ht.AssertWalletAccountBalance(restored, createAccountName, wantBal, 0)