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
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,9 +324,30 @@ $ lndinit -v init-wallet \
--init-rpc.server=localhost:10009 \
--init-rpc.tls-cert-path=$HOME/.lnd/tls.cert \
--init-rpc.watch-only \
--init-rpc.accounts-file=/tmp/accounts.json
--init-rpc.accounts-file=/tmp/accounts.json \
--init-rpc.watch-only-birthday=2024-07-01
```

**NOTE**: The accounts JSON file only contains the account xpubs, not the
birthday of the master key they were derived from. If
`--init-rpc.watch-only-birthday` isn't specified, `lnd` has to assume the aezeed
epoch (`2017-08-24`) as the wallet's birthday and rescans the chain from there,
which on mainnet means walking hundreds of thousands of blocks and can take
multiple hours. Pointing the flag at the actual birthday of the seed on the
remote signer avoids that. The value can be given as a Unix timestamp in
seconds, as an RFC3339 timestamp (`2024-07-01T00:00:00Z`) or as a plain
`YYYY-MM-DD` date, which is interpreted as midnight UTC. When in doubt, pick a
date slightly _before_ the seed was created, since a birthday that is too late
makes `lnd` skip the blocks the wallet's funds are in.

A wallet that already has history needs one more flag,
`--init-rpc.recovery-window`, which sets the address look-ahead `lnd` uses to
scan for keys that have been used. It defaults to zero, meaning no addresses are
recovered, which is the right answer for a brand new node. When re-creating a
wallet that has been in use, set it the way `lncli createwatchonly` does (it
prompts with a default of `2500`), otherwise the rescan starts at the right
height but never derives far enough to find the wallet's addresses.

#### 5. Store the wallet password in a file

Because we now only have the wallet password as a value in a k8s secret, we need
Expand Down Expand Up @@ -389,7 +410,9 @@ exist). This can make it hard to follow exactly what is happening when debugging
the initialization. To assist with debugging, the following two flags can be
used:

- `--verbose (-v)`: Log debug information to `stderr`.
- `--verbose (-v)`: Log progress information to `stderr`, equivalent to
`--debuglevel=info`. Use `--debuglevel=debug` or `--debuglevel=trace` for more
detail.
- `--error-on-existing (-e)`: Exit with a non-zero return code (128) if the
result of an operation already exists. See example below.

Expand Down
154 changes: 148 additions & 6 deletions cmd_init_wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"math"
"os"
"path/filepath"
"strconv"
"strings"
"time"

Expand All @@ -31,6 +32,24 @@ const (

typeFile = "file"
typeRpc = "rpc"

// birthdayDateFormat is the calendar date notation a watch-only
// wallet's birthday can be expressed in, next to a raw Unix timestamp
// and a full RFC3339 timestamp.
birthdayDateFormat = "2006-01-02"

// maxBirthdayDrift is how far into the future a watch-only wallet's
// birthday is allowed to be. We tolerate some slack to account for
// clock skew between the machine that produced the timestamp and the
// one running lndinit. Anything beyond that is rejected, since a
// birthday far enough in the future makes lnd start scanning at the
// chain tip, where it would miss any funds the wallet already holds.
//
// The window is a day rather than something tighter because btcwallet
// rewinds the birthday by 48 hours before it looks for the birthday
// block, so a value inside this window still starts the scan at least a
// day before the wallet could have been created.
maxBirthdayDrift = 24 * time.Hour
)

var (
Expand Down Expand Up @@ -58,10 +77,12 @@ type initTypeFile struct {
}

type initTypeRpc struct {
Server string `long:"server" description:"The host:port of the RPC server to connect to"`
TLSCertPath string `long:"tls-cert-path" description:"The full path to the RPC server's TLS certificate"`
WatchOnly bool `long:"watch-only" description:"Don't require a seed to be set, initialize the wallet as watch-only; requires the accounts-file flag to be specified"`
AccountsFile string `long:"accounts-file" description:"The JSON file that contains all accounts xpubs for initializing a watch-only wallet"`
Server string `long:"server" description:"The host:port of the RPC server to connect to"`
TLSCertPath string `long:"tls-cert-path" description:"The full path to the RPC server's TLS certificate"`
WatchOnly bool `long:"watch-only" description:"Don't require a seed to be set, initialize the wallet as watch-only; requires the accounts-file flag to be specified"`
AccountsFile string `long:"accounts-file" description:"The JSON file that contains all accounts xpubs for initializing a watch-only wallet"`
WatchOnlyBirthday string `long:"watch-only-birthday" description:"The birthday of the watch-only wallet's master key, either as a Unix timestamp in seconds, an RFC3339 timestamp or a YYYY-MM-DD date; if unset, lnd assumes the aezeed epoch (2017-08-24) and rescans the chain from there, which can take hours; requires the watch-only flag to be specified"`
RecoveryWindow int32 `long:"recovery-window" description:"The address look-ahead used to scan for used keys when the wallet being initialized already has history; a value of zero, the default, means no addresses are recovered, which is what a brand new wallet wants"`
}

type initWalletCommand struct {
Expand Down Expand Up @@ -102,6 +123,27 @@ func (x *initWalletCommand) Register(parser *flags.Parser) error {
}

func (x *initWalletCommand) Execute(_ []string) error {
// A birthday can only be given for a watch-only wallet created through
// RPC. Any other wallet is created from a seed that carries its birthday
// with it, so silently ignoring the flag there would be misleading.
birthdayApplies := x.InitType == typeRpc && x.InitRpc.WatchOnly
if x.InitRpc.WatchOnlyBirthday != "" && !birthdayApplies {
return fmt.Errorf("--init-rpc.watch-only-birthday can only " +
"be used in combination with --init-type=rpc and " +
"--init-rpc.watch-only")
}

// The recovery window applies to any wallet created through RPC, but
// there's nothing sensible we could do with a negative look-ahead.
if x.InitRpc.RecoveryWindow < 0 {
return fmt.Errorf("invalid recovery window %d, must not be "+
"negative", x.InitRpc.RecoveryWindow)
}
if x.InitRpc.RecoveryWindow != 0 && x.InitType != typeRpc {
return fmt.Errorf("--init-rpc.recovery-window can only be " +
"used in combination with --init-type=rpc")
}

// Do we require a seed? We don't if we do an RPC based, watch-only
// initialization.
requireSeed := (x.InitType == typeFile) ||
Expand Down Expand Up @@ -157,6 +199,32 @@ func (x *initWalletCommand) Execute(_ []string) error {
// seed to be present but instead want to read an accounts JSON
// file that contains all the wallet's xpubs.
if x.InitRpc.WatchOnly {
// The accounts JSON file doesn't carry the birthday of
// the master key the accounts were derived from, so the
// operator has to tell us what it is. Without it lnd
// rescans the chain from the aezeed epoch, which on
// mainnet means walking hundreds of thousands of
// blocks.
birthday, err := parseWatchOnlyBirthday(
x.InitRpc.WatchOnlyBirthday, x.Network,
)
if err != nil {
return err
}

if birthday == 0 {
logger.Warn("No wallet birthday specified, " +
"lnd will rescan the chain from the " +
"aezeed epoch (2017-08-24) which can " +
"take multiple hours; use " +
"--init-rpc.watch-only-birthday to " +
"start the rescan at the wallet's " +
"actual birthday instead")
} else {
logger.Infof("Using wallet birthday %s",
formatBirthday(birthday))
}

// For initializing a watch-only wallet we need the
// accounts JSON file.
logger.Info("Reading accounts from file")
Expand Down Expand Up @@ -186,14 +254,20 @@ func (x *initWalletCommand) Execute(_ []string) error {
}

watchOnly = &lnrpc.WatchOnly{
MasterKeyBirthdayTimestamp: 0,
MasterKeyBirthdayTimestamp: birthday,
Accounts: rpcAccounts,
}
}

if x.InitRpc.RecoveryWindow != 0 {
logger.Infof("Using address look-ahead of %d for "+
"recovery", x.InitRpc.RecoveryWindow)
}

return createWalletRpc(
seedWords, seedPassPhrase, walletPassword,
x.InitRpc.Server, x.InitRpc.TLSCertPath, watchOnly,
x.InitRpc.RecoveryWindow,
)

default:
Expand Down Expand Up @@ -397,7 +471,8 @@ func validateWallet(walletDir string, walletPassword []byte,
}

func createWalletRpc(seedWords []string, seedPassword, walletPassword,
rpcServer, tlsPath string, watchOnly *lnrpc.WatchOnly) error {
rpcServer, tlsPath string, watchOnly *lnrpc.WatchOnly,
recoveryWindow int32) error {

// Since this will potentially run for a while (we need to wait for
// compaction), make sure we catch any interrupt signals.
Expand Down Expand Up @@ -433,6 +508,7 @@ func createWalletRpc(seedWords []string, seedPassword, walletPassword,
AezeedPassphrase: []byte(seedPassword),
WalletPassword: []byte(walletPassword),
WatchOnly: watchOnly,
RecoveryWindow: recoveryWindow,
})
return err
}
Expand All @@ -456,6 +532,72 @@ func checkSeed(seed, seedPassPhrase string) (*aezeed.CipherSeed, error) {
return cipherSeed, nil
}

// parseWatchOnlyBirthday turns the operator provided birthday of a watch-only
// wallet's master key into a Unix timestamp in seconds, as expected by lnd's
// InitWallet RPC. An empty value means the birthday is unknown and results in a
// zero timestamp, which makes lnd fall back to its own default.
func parseWatchOnlyBirthday(birthday, network string) (uint64, error) {
if birthday == "" {
return 0, nil
}

birthdayTime, err := parseTimestampOrDate(birthday)
if err != nil {
return 0, fmt.Errorf("error parsing wallet birthday: %v", err)
}

// A birthday from before the chain itself existed can only be a
// mistake, and would make lnd rescan all the way from the genesis
// block.
netParams, err := getNetworkParams(network)
if err != nil {
return 0, err
}
genesisTime := netParams.GenesisBlock.Header.Timestamp
if birthdayTime.Before(genesisTime) {
return 0, fmt.Errorf("invalid wallet birthday %s, is before "+
"the %s genesis block time %s",
birthdayTime.Format(time.RFC3339), network,
genesisTime.UTC().Format(time.RFC3339))
}

if birthdayTime.After(time.Now().Add(maxBirthdayDrift)) {
return 0, fmt.Errorf("invalid wallet birthday %s, is more "+
"than %v in the future (are the units seconds?)",
birthdayTime.Format(time.RFC3339), maxBirthdayDrift)
}

return uint64(birthdayTime.Unix()), nil
}

// parseTimestampOrDate parses a point in time that is either given as a Unix
// timestamp in seconds, as an RFC3339 timestamp or as a plain calendar date.
// Dates without a time of day are interpreted as midnight UTC.
func parseTimestampOrDate(value string) (time.Time, error) {
// A bare number is a Unix timestamp in seconds.
seconds, err := strconv.ParseInt(value, 10, 64)
if err == nil {
return time.Unix(seconds, 0).UTC(), nil
}

for _, layout := range []string{time.RFC3339, birthdayDateFormat} {
parsed, err := time.Parse(layout, value)
if err == nil {
return parsed.UTC(), nil
}
}

return time.Time{}, fmt.Errorf("value %q is neither a Unix timestamp "+
"in seconds, an RFC3339 timestamp nor a %s date", value,
birthdayDateFormat)
}

// formatBirthday renders a Unix timestamp in seconds as a human readable UTC
// timestamp for logging.
func formatBirthday(birthday uint64) string {
return time.Unix(int64(birthday), 0).UTC().Format(time.RFC3339)
}

func getNetworkParams(network string) (*chaincfg.Params, error) {
switch strings.ToLower(network) {
case "mainnet":
Expand Down
Loading