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
38 changes: 32 additions & 6 deletions cmd/commands/cmd_walletunlocker.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"os"
"strconv"
"strings"
"time"

"github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/lnrpc"
Expand Down Expand Up @@ -332,7 +333,9 @@
}
extendedRootKey = strings.TrimSpace(extendedRootKey)

extendedRootKeyBirthday, err = askBirthdayTimestamp()
// An extended master key carries no birthday, so there's nothing

Check failure on line 336 in cmd/commands/cmd_walletunlocker.go

View workflow job for this annotation

GitHub Actions / Lint code

the line is 81 characters long, which exceeds the maximum of 80 characters. (ll)
// to offer as a default here.
extendedRootKeyBirthday, err = askBirthdayTimestamp(0)
if err != nil {
return err
}
Expand Down Expand Up @@ -939,7 +942,14 @@
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

Check failure on line 946 in cmd/commands/cmd_walletunlocker.go

View workflow job for this annotation

GitHub Actions / Lint code

the line is 81 characters long, which exceeds the maximum of 80 characters. (ll)
// 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

Check failure on line 948 in cmd/commands/cmd_walletunlocker.go

View workflow job for this annotation

GitHub Actions / Lint code

the line is 81 characters long, which exceeds the maximum of 80 characters. (ll)
// it always did.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 F2 (Minor) — createwatchonly now auto-accepts a file-sourced birthday for the rescan start

createWatchOnly now feeds jsonAccts.MasterKeyBirthdayTimestamp from the operator-supplied accounts file into askBirthdayTimestamp as the default, and a bare Enter accepts it; that value flows into WatchOnly.MasterKeyBirthdayTimestamp and sets the watch-only rescan start height. This is a change in posture — previously the empty answer resolved to 0 (lnd's default 2017-08-24), now it resolves to whatever the file carries. In the normal flow this is strictly better (accurate and faster), and the fund-loss exposure is limited: a genuine master-key birthday cannot predate the keys it derives, so a correct value can never skip a block holding this wallet's UTXOs; the accounts file is already the fully-trusted root defining the wallet (it holds the xpubs), so tampering with the birthday is strictly weaker than tampering already possible; and a wrong (stale/buggy) value only costs a recoverable manual rescan. Still, because the prompt now pre-fills a fund-recovery-relevant parameter and treats Enter as consent, consider printing the resolved calendar date next to the default so the operator can sanity-check it before accepting.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Took the suggestion. askBirthdayTimestamp now spells a non-zero default out as a date next to the raw value, so the thing an empty answer accepts is legible:

Input an optional wallet birthday unix timestamp of first block to start scanning from (default 1767225600, which is 2026-01-01T00:00:00Z):

The zero default (the create path, importing an extended master key, where no birthday exists to offer) still prints a bare 0 rather than 1970.

Agreed on the rest of the analysis: a real master-key birthday cannot predate the keys derived from it, so an accurate value can never skip a block holding this wallet's outputs, and the accounts file is already the trusted root that defines the wallet.

extendedRootKeyBirthday, err := askBirthdayTimestamp(
jsonAccts.MasterKeyBirthdayTimestamp,
)
if err != nil {
return err
}
Expand Down Expand Up @@ -1061,11 +1071,27 @@
}
}

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')
Expand All @@ -1078,7 +1104,7 @@
answer = strings.TrimSpace(answer)

if len(answer) == 0 {
return 0, nil
return defaultTimestamp, nil
}

birthdayTimestamp, err := strconv.ParseUint(answer, 10, 64)
Expand Down
15 changes: 15 additions & 0 deletions docs/release-notes/release-notes-0.22.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -159,3 +173,4 @@
* Boris Nagaev
* Erick Cestari
* Jared Tobin
* Olaoluwa Osuntokun
28 changes: 22 additions & 6 deletions lnrpc/walletrpc/walletkit.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions lnrpc/walletrpc/walletkit.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions lnrpc/walletrpc/walletkit.swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down
23 changes: 22 additions & 1 deletion lnrpc/walletrpc/walletkit_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 F3 (Minor) — The Unix() > 0 guard is load-bearing; do not refactor to IsZero()

The if birthday := w.cfg.Wallet.Birthday(); birthday.Unix() > 0 guard does double duty: it filters the zero-value sentinel (time.Time{}.Unix() is a large negative) and it guarantees positivity before uint64(birthday.Unix()). A well-meaning refactor to !birthday.IsZero() would let a non-zero pre-1970 birthday through as a negative int64, which the cast turns into a ~1.8e19 nonsense timestamp — the exact failure the code is avoiding. The current form is correct (aezeed genesis 2017-08-24 is well positive), so this is a note to keep the invariant explicit, not a defect. One small inaccuracy: the comment says the guard only catches "a wallet that somehow has no birthday at all," but an exact-epoch birthday (Unix() == 0) is also mapped to 0 — practically unreachable, but the comment could say so.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch on the refactor hazard, took that half. The comment now names why the comparison has to stay a positivity check:

	// 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.

Leaving the exact-epoch nuance out. A wallet whose birthday is precisely 1970-01-01T00:00:00Z maps to 0 and is therefore read as "unknown", which is the correct outcome anyway, and spelling out a case that cannot arise on a Bitcoin wallet costs more attention than it returns.

if birthday := w.cfg.Wallet.Birthday(); birthday.Unix() > 0 {
birthdayTimestamp = uint64(birthday.Unix())
}

return &ListAccountsResponse{
Accounts: rpcAccounts,
MasterKeyBirthdayTimestamp: birthdayTimestamp,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Dismissing this one. The birthday adds nothing an onchain:read holder cannot already derive, and often less precisely: the same scope covers ListUnspent and ListAddresses (walletkit_server.go:142,146), and lnrpc.GetTransactions sits behind onchain:read too. Anyone holding that credential can already enumerate the wallet's UTXOs, its addresses and its full on-chain transaction history, which pins the wallet's activity window to actual block heights. A creation timestamp is a strictly weaker signal than the set of blocks its outputs are actually in.

Gating the field is also self-defeating: the whole point is that the accounts export, which is fetched with a macaroon, carries the birthday to the importing side. Gating it behind a stronger scope would break the flow this PR exists to fix.

That leaves documenting "onchain:read now exposes the wallet birthday" in the field comment, and I would rather not: it cannot be turned off, so it does not inform any decision an operator can act on, and it invites the reading that the birthday was ever confidential from a credential that already reveals the chain history it summarizes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

/gateway dismiss

}, nil
}

// RequiredReserve returns the minimum amount of satoshis that should be
Expand Down
55 changes: 55 additions & 0 deletions lnrpc/walletrpc/walletkit_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@

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"
Expand Down Expand Up @@ -720,3 +723,55 @@
})
}
}

// 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{},

Check failure on line 768 in lnrpc/walletrpc/walletkit_server_test.go

View workflow job for this annotation

GitHub Actions / Lint code

context.Background() could be replaced by t.Context() in TestListAccountsBirthday (usetesting)
)
require.NoError(tt, err)
require.Equal(
tt, tc.expected,
resp.MasterKeyBirthdayTimestamp,
)
})
}
}
7 changes: 7 additions & 0 deletions lntest/mock/walletcontroller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions lnwallet/btcwallet/btcwallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions lnwallet/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Verified rather than changed. WalletController has exactly four implementers in tree, all satisfied: BtcWallet (this PR), the two mocks in lnwallet/mock.go and lntest/mock/walletcontroller.go (this PR), and rpcwallet.RPCKeyRing, which embeds *btcwallet.BtcWallet and so inherits it.

$ grep -rn "WalletController = " --include="*.go" .
lntest/mock/walletcontroller.go:42:var _ lnwallet.WalletController = (*WalletController)(nil)
lnwallet/mock.go:48:var _ WalletController = (*mockWalletController)(nil)
lnwallet/rpcwallet/rpcwallet.go:73:var _ lnwallet.WalletController = (*RPCKeyRing)(nil)
lnwallet/btcwallet/{btcwallet,blockchain}.go:  var _ lnwallet.WalletController = (*BtcWallet)(nil)

go build ./... and go vet ./... are clean under the full RPC tag set plus dev integration, which covers the itest doubles too.

Skipping the release-note suggestion: these release notes are written for node operators, and every implementer of this interface is in this repo, so a Go-interface note would be noise for the audience that reads them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

/gateway dismiss

// 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
Expand Down
7 changes: 7 additions & 0 deletions lnwallet/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading