From 3cba026a29c850f9d414150a1acbcfab282264f6 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 24 Jul 2026 16:44:07 -0700 Subject: [PATCH 1/4] lnwallet: expose the wallet's master key birthday In this commit, we add a Birthday method to the WalletController interface, returning the birthday of the wallet's master key as btcwallet's address manager has it. That's the earliest time any of the wallet's keys could have been used, and therefore where a rescan of the chain has to start. The wallet has always known this, there just wasn't a way to ask it from the RPC layer. The next commit needs it so ListAccounts can hand the birthday to whoever imports those accounts into a watch-only wallet. --- lntest/mock/walletcontroller.go | 7 +++++++ lnwallet/btcwallet/btcwallet.go | 8 ++++++++ lnwallet/interface.go | 6 ++++++ lnwallet/mock.go | 7 +++++++ 4 files changed, 28 insertions(+) diff --git a/lntest/mock/walletcontroller.go b/lntest/mock/walletcontroller.go index 90a3310ddf..73011f09ac 100644 --- a/lntest/mock/walletcontroller.go +++ b/lntest/mock/walletcontroller.go @@ -34,6 +34,7 @@ type WalletController struct { PublishedTransactions chan *wire.MsgTx index uint32 Utxos []*lnwallet.Utxo + WalletBirthday time.Time } // A compile time check to ensure this mocked WalletController implements the @@ -285,6 +286,12 @@ func (w *WalletController) GetRecoveryInfo() (bool, float64, error) { return true, float64(1), nil } +// Birthday returns the mock wallet's birthday, which is the zero time unless a +// test set one explicitly. +func (w *WalletController) Birthday() time.Time { + return w.WalletBirthday +} + // Start currently does nothing. func (w *WalletController) Start() error { return nil diff --git a/lnwallet/btcwallet/btcwallet.go b/lnwallet/btcwallet/btcwallet.go index ecb7b57e0b..7f53c23329 100644 --- a/lnwallet/btcwallet/btcwallet.go +++ b/lnwallet/btcwallet/btcwallet.go @@ -1883,6 +1883,14 @@ func (b *BtcWallet) GetRecoveryInfo() (bool, float64, error) { return isRecoveryMode, progress, nil } +// Birthday returns the birthday of the wallet's master key, meaning the +// earliest time at which any of its keys could have been used. +// +// This is a part of the WalletController interface. +func (b *BtcWallet) Birthday() time.Time { + return b.wallet.AddrManager().Birthday() +} + // FetchTx attempts to fetch a transaction in the wallet's database identified // by the passed transaction hash. If the transaction can't be found, then a // nil pointer is returned. diff --git a/lnwallet/interface.go b/lnwallet/interface.go index d2adfd2eed..05091f2d5d 100644 --- a/lnwallet/interface.go +++ b/lnwallet/interface.go @@ -550,6 +550,12 @@ type WalletController interface { // recovery progress made so far. GetRecoveryInfo() (bool, float64, error) + // Birthday returns the birthday of the wallet's master key, meaning the + // earliest time at which any of its keys could have been used. Blocks + // before that point cannot contain any of the wallet's outputs, so this + // is where a rescan of the chain starts. + Birthday() time.Time + // Start initializes the wallet, making any necessary connections, // starting up required goroutines etc. Start() error diff --git a/lnwallet/mock.go b/lnwallet/mock.go index 5583073e6c..22d90984c0 100644 --- a/lnwallet/mock.go +++ b/lnwallet/mock.go @@ -40,6 +40,7 @@ type mockWalletController struct { PublishedTransactions chan *wire.MsgTx index uint32 Utxos []*Utxo + walletBirthday time.Time } // A compile time check to ensure that mockWalletController implements the @@ -301,6 +302,12 @@ func (w *mockWalletController) GetRecoveryInfo() (bool, float64, error) { return true, float64(1), nil } +// Birthday returns the mock wallet's birthday, which is the zero time unless a +// test set one explicitly. +func (w *mockWalletController) Birthday() time.Time { + return w.walletBirthday +} + // Start currently does nothing. func (w *mockWalletController) Start() error { return nil From 3d3648b4cb6f1608b5b2d62d73fb25b8e384147a Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 24 Jul 2026 16:44:07 -0700 Subject: [PATCH 2/4] walletrpc: return the master key birthday from ListAccounts In this commit, we add a master_key_birthday_timestamp field to ListAccountsResponse and populate it from the wallet. The accounts in that response are extended public keys, and an xpub says nothing about when it was created. That's a problem for the one thing the response exists for: exporting a signer's accounts so a watch-only wallet can import them. The importing side has no birthday to work with, so lnd substitutes the aezeed "Bitcoin Days Genesis" epoch of 2017-08-24 and rescans from there. On mainnet that's hundreds of thousands of blocks and several hours, even for a node with no history whatsoever. See lightninglabs/lndinit#97 for what that looks like in practice. The field is named to match WatchOnly.master_key_birthday_timestamp on the InitWallet request, which is where the value ends up, and it round-trips through the accounts JSON file that lncli's createwatchonly and lndinit both read. A wallet that somehow has no birthday at all reports zero rather than the nonsense a uint64 cast of a pre-epoch time would produce, since zero is already what InitWallet reads as "unknown". --- lnrpc/walletrpc/walletkit.pb.go | 28 +++++++++--- lnrpc/walletrpc/walletkit.proto | 11 +++++ lnrpc/walletrpc/walletkit.swagger.json | 5 +++ lnrpc/walletrpc/walletkit_server.go | 23 +++++++++- lnrpc/walletrpc/walletkit_server_test.go | 55 ++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 7 deletions(-) diff --git a/lnrpc/walletrpc/walletkit.pb.go b/lnrpc/walletrpc/walletkit.pb.go index 40e9506e3f..9bc5e0317f 100644 --- a/lnrpc/walletrpc/walletkit.pb.go +++ b/lnrpc/walletrpc/walletkit.pb.go @@ -1262,10 +1262,18 @@ func (x *ListAccountsRequest) GetAddressType() AddressType { } type ListAccountsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Accounts []*Account `protobuf:"bytes,1,rep,name=accounts,proto3" json:"accounts,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Accounts []*Account `protobuf:"bytes,1,rep,name=accounts,proto3" json:"accounts,omitempty"` + // The birthday of the wallet's master key, expressed in seconds since the unix + // epoch. This is the earliest time any of the wallet's keys could have been + // used, so it is where a rescan of the chain has to start. The accounts above + // are extended public keys, which carry no notion of when they were created, + // so this value needs to be transported alongside them when importing them + // into a watch-only wallet. It corresponds to the + // WatchOnly.master_key_birthday_timestamp field of the InitWallet request. + MasterKeyBirthdayTimestamp uint64 `protobuf:"varint,2,opt,name=master_key_birthday_timestamp,json=masterKeyBirthdayTimestamp,proto3" json:"master_key_birthday_timestamp,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListAccountsResponse) Reset() { @@ -1305,6 +1313,13 @@ func (x *ListAccountsResponse) GetAccounts() []*Account { return nil } +func (x *ListAccountsResponse) GetMasterKeyBirthdayTimestamp() uint64 { + if x != nil { + return x.MasterKeyBirthdayTimestamp + } + return 0 +} + type RequiredReserveRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The number of additional channels the user would like to open. @@ -4767,9 +4782,10 @@ const file_walletrpc_walletkit_proto_rawDesc = "" + "\taddresses\x18\x04 \x03(\v2\x1a.walletrpc.AddressPropertyR\taddresses\"d\n" + "\x13ListAccountsRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x129\n" + - "\faddress_type\x18\x02 \x01(\x0e2\x16.walletrpc.AddressTypeR\vaddressType\"F\n" + + "\faddress_type\x18\x02 \x01(\x0e2\x16.walletrpc.AddressTypeR\vaddressType\"\x89\x01\n" + "\x14ListAccountsResponse\x12.\n" + - "\baccounts\x18\x01 \x03(\v2\x12.walletrpc.AccountR\baccounts\"V\n" + + "\baccounts\x18\x01 \x03(\v2\x12.walletrpc.AccountR\baccounts\x12A\n" + + "\x1dmaster_key_birthday_timestamp\x18\x02 \x01(\x04R\x1amasterKeyBirthdayTimestamp\"V\n" + "\x16RequiredReserveRequest\x12<\n" + "\x1aadditional_public_channels\x18\x01 \x01(\rR\x18additionalPublicChannels\"D\n" + "\x17RequiredReserveResponse\x12)\n" + diff --git a/lnrpc/walletrpc/walletkit.proto b/lnrpc/walletrpc/walletkit.proto index 540c571d36..e877b19455 100644 --- a/lnrpc/walletrpc/walletkit.proto +++ b/lnrpc/walletrpc/walletkit.proto @@ -585,6 +585,17 @@ message ListAccountsRequest { message ListAccountsResponse { repeated Account accounts = 1; + + /* + The birthday of the wallet's master key, expressed in seconds since the unix + epoch. This is the earliest time any of the wallet's keys could have been + used, so it is where a rescan of the chain has to start. The accounts above + are extended public keys, which carry no notion of when they were created, + so this value needs to be transported alongside them when importing them + into a watch-only wallet. It corresponds to the + WatchOnly.master_key_birthday_timestamp field of the InitWallet request. + */ + uint64 master_key_birthday_timestamp = 2; } message RequiredReserveRequest { diff --git a/lnrpc/walletrpc/walletkit.swagger.json b/lnrpc/walletrpc/walletkit.swagger.json index 90365c3040..521b2354fd 100644 --- a/lnrpc/walletrpc/walletkit.swagger.json +++ b/lnrpc/walletrpc/walletkit.swagger.json @@ -1882,6 +1882,11 @@ "type": "object", "$ref": "#/definitions/walletrpcAccount" } + }, + "master_key_birthday_timestamp": { + "type": "string", + "format": "uint64", + "description": "The birthday of the wallet's master key, expressed in seconds since the unix\nepoch. This is the earliest time any of the wallet's keys could have been\nused, so it is where a rescan of the chain has to start. The accounts above\nare extended public keys, which carry no notion of when they were created,\nso this value needs to be transported alongside them when importing them\ninto a watch-only wallet. It corresponds to the\nWatchOnly.master_key_birthday_timestamp field of the InitWallet request." } } }, diff --git a/lnrpc/walletrpc/walletkit_server.go b/lnrpc/walletrpc/walletkit_server.go index 8480f62d07..fe0665f740 100644 --- a/lnrpc/walletrpc/walletkit_server.go +++ b/lnrpc/walletrpc/walletkit_server.go @@ -2689,7 +2689,28 @@ func (w *WalletKit) ListAccounts(ctx context.Context, rpcAccounts = append(rpcAccounts, rpcAccount) } - return &ListAccountsResponse{Accounts: rpcAccounts}, nil + // The accounts above are extended public keys, which say nothing about + // when they were created. We return the wallet's birthday along with + // them so that a watch-only wallet importing this response doesn't have + // to rescan the chain from lnd's default birthday. + // + // A wallet that somehow has no birthday at all reports zero rather than + // the nonsense a uint64 cast of a pre-epoch time would produce, since + // zero is what the InitWallet request already reads as "unknown". + // + // The comparison has to stay a positivity check and not become an + // IsZero one: a non-zero time before 1970 has a negative Unix value, + // which is exactly what the cast below would turn into a timestamp + // somewhere past the year 292 billion. + var birthdayTimestamp uint64 + if birthday := w.cfg.Wallet.Birthday(); birthday.Unix() > 0 { + birthdayTimestamp = uint64(birthday.Unix()) + } + + return &ListAccountsResponse{ + Accounts: rpcAccounts, + MasterKeyBirthdayTimestamp: birthdayTimestamp, + }, nil } // RequiredReserve returns the minimum amount of satoshis that should be diff --git a/lnrpc/walletrpc/walletkit_server_test.go b/lnrpc/walletrpc/walletkit_server_test.go index 803c4d1ba7..c0b94216fa 100644 --- a/lnrpc/walletrpc/walletkit_server_test.go +++ b/lnrpc/walletrpc/walletkit_server_test.go @@ -5,13 +5,16 @@ package walletrpc import ( "bytes" + "context" "fmt" "strings" "testing" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/psbt/v2" "github.com/btcsuite/btcd/txscript/v2" @@ -720,3 +723,55 @@ func TestFundPsbtCoinSelect(t *testing.T) { }) } } + +// TestListAccountsBirthday makes sure ListAccounts reports the birthday of the +// wallet's master key. The exported accounts are extended public keys, which +// carry no creation time of their own, so this timestamp is the only thing that +// lets an importing watch-only wallet start its rescan at the right height +// instead of at lnd's default birthday. +func TestListAccountsBirthday(t *testing.T) { + t.Parallel() + + birthday := time.Unix(1719792000, 0) + + testCases := []struct { + name string + birthday time.Time + expected uint64 + }{{ + name: "birthday is reported in unix seconds", + birthday: birthday, + expected: uint64(birthday.Unix()), + }, { + // A wallet without a birthday must report zero, which is what + // InitWallet already reads as "unknown". Casting a pre-epoch + // time to uint64 would instead produce a nonsense timestamp + // somewhere in the year 292 billion. + name: "wallet without a birthday reports zero", + birthday: time.Time{}, + expected: 0, + }} + + for _, tc := range testCases { + t.Run(tc.name, func(tt *testing.T) { + tt.Parallel() + + rpcServer, _, err := New(&Config{ + Wallet: &mock.WalletController{ + WalletBirthday: tc.birthday, + }, + ChainParams: &chaincfg.RegressionNetParams, + }) + require.NoError(tt, err) + + resp, err := rpcServer.ListAccounts( + context.Background(), &ListAccountsRequest{}, + ) + require.NoError(tt, err) + require.Equal( + tt, tc.expected, + resp.MasterKeyBirthdayTimestamp, + ) + }) + } +} From f366509cbed7ff9fda85548089ee4ad84ebab2b2 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 24 Jul 2026 16:44:07 -0700 Subject: [PATCH 3/4] lncli: default the watch-only birthday from the accounts file In this commit, we have createwatchonly offer the birthday it found in the accounts file as the default for its birthday prompt, so an accounts file exported by a recent enough lnd carries the right value through on its own. The prompt stays, since the operator may know better than the file does, and an older accounts file without the field leaves the default at zero, which is exactly how the prompt behaved before. --- cmd/commands/cmd_walletunlocker.go | 38 +++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/cmd/commands/cmd_walletunlocker.go b/cmd/commands/cmd_walletunlocker.go index 0c04ce25c0..d2370d2457 100644 --- a/cmd/commands/cmd_walletunlocker.go +++ b/cmd/commands/cmd_walletunlocker.go @@ -11,6 +11,7 @@ import ( "os" "strconv" "strings" + "time" "github.com/lightningnetwork/lnd/lncfg" "github.com/lightningnetwork/lnd/lnrpc" @@ -332,7 +333,9 @@ mnemonicCheck: } extendedRootKey = strings.TrimSpace(extendedRootKey) - extendedRootKeyBirthday, err = askBirthdayTimestamp() + // An extended master key carries no birthday, so there's nothing + // to offer as a default here. + extendedRootKeyBirthday, err = askBirthdayTimestamp(0) if err != nil { return err } @@ -939,7 +942,14 @@ func createWatchOnly(ctx *cli.Context) error { return err } - extendedRootKeyBirthday, err := askBirthdayTimestamp() + // A wallet exported by a recent enough lnd tells us the birthday of the + // master key its accounts were derived from, which is more accurate than + // anything the operator is likely to type in, so we offer it as the + // default. An older export leaves this at zero and the prompt behaves as + // it always did. + extendedRootKeyBirthday, err := askBirthdayTimestamp( + jsonAccts.MasterKeyBirthdayTimestamp, + ) if err != nil { return err } @@ -1061,11 +1071,27 @@ func askRecoveryWindow() (int32, error) { } } -func askBirthdayTimestamp() (uint64, error) { +// askBirthdayTimestamp prompts for the wallet's birthday as a unix timestamp in +// seconds. The given default is what an empty answer resolves to, which lets a +// caller that already knows the birthday offer it up for confirmation, instead +// of making the user dig it out by hand. +func askBirthdayTimestamp(defaultTimestamp uint64) (uint64, error) { + // Spell a non-zero default out as a date as well. An empty answer + // accepts it, and it decides where the rescan starts, so the operator + // should be able to sanity check it without converting a bare unix + // timestamp in their head. + defaultHint := fmt.Sprintf("%d", defaultTimestamp) + if defaultTimestamp != 0 { + birthday := time.Unix(int64(defaultTimestamp), 0).UTC() + defaultHint = fmt.Sprintf("%d, which is %s", defaultTimestamp, + birthday.Format(time.RFC3339)) + } + for { fmt.Println() - fmt.Printf("Input an optional wallet birthday unix timestamp " + - "of first block to start scanning from (default 0): ") + fmt.Printf("Input an optional wallet birthday unix timestamp "+ + "of first block to start scanning from (default %s): ", + defaultHint) reader := bufio.NewReader(os.Stdin) answer, err := reader.ReadString('\n') @@ -1078,7 +1104,7 @@ func askBirthdayTimestamp() (uint64, error) { answer = strings.TrimSpace(answer) if len(answer) == 0 { - return 0, nil + return defaultTimestamp, nil } birthdayTimestamp, err := strconv.ParseUint(answer, 10, 64) From a9b0948462bd6262c905445e1fb0d32cc2866595 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 24 Jul 2026 16:44:07 -0700 Subject: [PATCH 4/4] docs: add release notes for the ListAccounts birthday In this commit, we note the new ListAccounts field and the createwatchonly default under RPC and lncli updates. --- docs/release-notes/release-notes-0.22.0.md | 15 +++++++++++++++ 1 file changed, 15 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..1ae6f6fa17 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -90,8 +90,22 @@ ## RPC Updates +* [`walletrpc.ListAccounts` now also returns the birthday of the wallet's master + key](https://github.com/lightningnetwork/lnd/pull/10993) in the new + `master_key_birthday_timestamp` field. The accounts themselves are extended + public keys, which say nothing about when they were created, so a watch-only + wallet importing them had no birthday to work with and had to rescan the chain + from lnd's default of 2017-08-24. On mainnet that's hundreds of thousands of + blocks and several hours, even for a node with no history at all. + ## lncli Updates +* [`createwatchonly` now offers the birthday from the accounts + file](https://github.com/lightningnetwork/lnd/pull/10993) as the default for + its birthday prompt, so an accounts file exported by a recent enough lnd + carries the right value through without the operator having to know it. An + older file leaves the field unset and the prompt behaves as before. + ## Breaking Changes ## Performance Improvements @@ -159,3 +173,4 @@ * Boris Nagaev * Erick Cestari * Jared Tobin +* Olaoluwa Osuntokun