Skip to content
Merged
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
99 changes: 99 additions & 0 deletions cmd/commands/walletrpc_active.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"math"
"os"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -49,6 +50,7 @@ var (
Usage: "Interact with wallet accounts.",
Subcommands: []cli.Command{
listAccountsCommand,
createAccountCommand,
importAccountCommand,
importPubKeyCommand,
},
Expand Down Expand Up @@ -1873,6 +1875,103 @@ func listLeases(ctx *cli.Context) error {
return nil
}

var createAccountCommand = cli.Command{
Name: "create",
Usage: "Create a new on-chain wallet account (experimental).",
ArgsUsage: "name",
Description: `
Creates a new named account within the wallet, deriving the account's
keys from the wallet's master key.

This wraps the experimental XCreateAccount RPC: the X prefix marks it
as an API that may change or be removed without the usual deprecation
period, and it is gated as described below until recovery handles
these accounts.

Unlike 'accounts import', which registers a watch-only account from an
extended public key, the account created here is fully owned by the
wallet: it derives its own addresses and can sign for its own outputs.
Coin selection, change, balance and address derivation can then all be
scoped to the account by passing its name, which makes it usable as an
isolated pocket of funds inside a single wallet.

The address type permanently fixes the key scope the account lives in,
and therefore the address type of both its receive and its change
outputs. It defaults to taproot and cannot be changed afterwards.

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.

The description covers the permanence of the address type but says nothing about the recovery caveat, which is the part that can actually cost someone money. I do not think this needs an interactive y/N — that gets in the way of scripted operator setup and people click through prompts anyway — but the caveat should at least appear here, and ideally be echoed to stderr after a successful create, along the lines of: funds in this account are not found by a seed-only restore; record the account's key scope and index and see before depositing.

That also makes the lncli output the place the operator learns the index, which per the note on walletkit.proto is the thing they actually need to have written down. printRespJSON(resp) already returns the Account with its derivation_path, so it is right there — just worth pointing at.


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.
`,
Flags: []cli.Flag{
cli.StringFlag{
Name: "address_type",
Usage: "(optional) the address type the " +
"account holds, one of: p2wkh, " +
"np2wkh-p2wkh, p2tr; defaults to p2tr",
},
cli.BoolFlag{
Name: "i_know_what_i_am_doing",
Usage: "required on a release build, " +
"confirming you accept that a seed-only " +
"restore will not rediscover this " +
"account's funds",
},
},
Action: actionDecorator(createAccount),
}

func createAccount(ctx *cli.Context) error {
ctxc := getContext()

// Display the command's help message if we do not have the expected
// number of arguments/flags.
if ctx.NArg() != 1 || ctx.NumFlags() > 2 {
return cli.ShowCommandHelp(ctx, "create")
}

addrType, err := parseAddrType(ctx.String("address_type"))

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.

parseAddrType maps np2wkh to NESTED_WITNESS_PUBKEY_HASH, which the server unconditionally rejects. The flag's own usage string already omits it, so the CLI accepts a value it knows will fail.

Rejecting it locally would give the user the better "use np2wkh-p2wkh instead" message without the round trip.

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.

Done — lncli now rejects np2wkh locally with the "use np2wkh-p2wkh instead" message, before dialling.

if err != nil {
return err
}

// The server always refuses this one, since a wallet-derived account
// carries no address schema and would silently behave as the hybrid
// scheme. Say so here rather than spending a round trip on it.
if addrType == walletrpc.AddressType_NESTED_WITNESS_PUBKEY_HASH {
return errors.New("np2wkh accounts cannot be created; a " +
"wallet-derived account of that key scope provides " +
"the hybrid scheme, so use np2wkh-p2wkh instead")
}

walletClient, cleanUp := getWalletClient(ctx)
defer cleanUp()

req := &walletrpc.XCreateAccountRequest{
Name: ctx.Args().First(),
AddressType: addrType,
IKnowWhatIAmDoing: ctx.Bool("i_know_what_i_am_doing"),
}
resp, err := walletClient.XCreateAccount(ctxc, req)
if err != nil {
return err
}

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.
_, _ = 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")

return nil
}

var listAccountsCommand = cli.Command{
Name: "list",
Usage: "Retrieve information of existing on-chain wallet accounts.",
Expand Down
21 changes: 21 additions & 0 deletions docs/release-notes/release-notes-0.22.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,23 @@
the chain backend via bitcoind's `submitpackage`, allowing a zero-fee v3/TRUC
parent to be accepted together with a fee-paying CPFP child.

* A new [`walletrpc.XCreateAccount`](https://github.com/lightningnetwork/lnd/pull/11065)
RPC creates a named wallet account whose keys are derived from the wallet's
master key. Unlike `ImportAccount`, which registers a watch-only account from
an extended public key, the resulting account can sign for its own outputs, so
a single wallet can be partitioned into isolated pockets of funds: coin
selection, change, balance and address derivation can all be scoped to an
account by name.

The RPC is **experimental**, which the `X` prefix marks: it may change or be
removed without the usual deprecation period. It is additionally gated on
release builds, where a caller must set `i_know_what_i_am_doing`, following
the same pattern as `AbandonChannel`. A seed-only restore does not rediscover
funds held in an account created this way, because the recovery scan only
rederives addresses for the default account, and reconstructing one by hand
requires reproducing its key scope, its account index and the addresses it
had issued. Both gates come off once recovery handles these accounts.

## lncli Additions

* The `estimateroutefee` command now supports [restricting fee estimates to
Expand All @@ -84,6 +101,10 @@
command submits a package of hex-encoded transactions via the new
`SubmitPackage` RPC.

* A new [`wallet accounts create`](https://github.com/lightningnetwork/lnd/pull/11065)
command creates a wallet-owned named account via the new `XCreateAccount`
RPC.

# Improvements

## Functional Updates
Expand Down
8 changes: 8 additions & 0 deletions itest/lnd_wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ import (
// walletTestCases defines a set of tests aiming at asserting functionalities
// provided by the wallerpc.
var walletTestCases = []*lntest.TestCase{
{
Name: "xcreate account",
TestFunc: testXCreateAccount,
},
{
Name: "xcreate account rejections",
TestFunc: testXCreateAccountRejections,
},
{
Name: "listunspent P2WPKH",
TestFunc: func(ht *lntest.HarnessTest) {
Expand Down
190 changes: 190 additions & 0 deletions itest/lnd_wallet_xcreate_account.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package itest

import (
"github.com/btcsuite/btcd/btcutil/v2"
"github.com/btcsuite/btcd/wire/v2"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/lntest"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/stretchr/testify/require"
)

const (
// createAccountName is the account these tests create and spend from.
createAccountName = "custom"

// defaultCreateAccountFeeRate is the sat/vB rate the miner uses when
// funding the account under test.
defaultCreateAccountFeeRate = btcutil.Amount(10)

// maxCreateAccountSpendFee bounds what the account's own spend may
// cost. The transaction is one input and two outputs at 5 sat/vB, so
// a few thousand sats is a generous ceiling; the point is only to
// distinguish "paid a fee" from "the money went somewhere else".
maxCreateAccountSpendFee = btcutil.Amount(10_000)
)

// testXCreateAccount asserts the end-to-end behaviour of an account created

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.

Since the documented manual reconstruction is currently the only recovery mitigation, could we cover that procedure end to end as well? I am not suggesting that this PR must implement automatic seed-only discovery—the X/acknowledgement gates already make that limitation explicit—but the claim that the funds remain manually recoverable is important enough to verify before users rely on it.

The useful regression test would create a preceding account so the target has a non-trivial index, fund an external address, then spend through FundPsbt so value remains on an internal/change address. Record the target xpub plus both branch counts; restore the same seed into a fresh wallet DB; recreate accounts until the same scope/index is reached and assert the xpub matches; replay NextAddr(change=false) and NextAddr(change=true) using their respective counts; rescan with --reset-wallet-transactions; and finally assert that both balances are found and can be spent. Leaving real value on the internal branch is the part that would catch the recovery issue above.

// from the wallet's own master key: it is not watch-only, its funds are
// reported against it rather than the default account, and — the property
// that distinguishes it from an imported account — the wallet can sign for
// it.
func testXCreateAccount(ht *lntest.HarnessTest) {
alice := ht.NewNode("Alice", nil)

account := alice.RPC.XCreateAccount(&walletrpc.XCreateAccountRequest{
Name: createAccountName,
AddressType: walletrpc.AddressType_TAPROOT_PUBKEY,

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.

Ideally we could cover different address types too.

}).GetAccount()

require.Equal(ht, createAccountName, account.GetName())
require.Equal(
ht, walletrpc.AddressType_TAPROOT_PUBKEY,
account.GetAddressType(),
)

// The whole point of this RPC: unlike an imported account, the wallet
// holds the keys, so it can spend what the account receives.
require.False(ht, account.GetWatchOnly(), "account must be spendable")

// It shows up in ListAccounts under the scope it was created in.
listed := alice.RPC.ListAccounts(&walletrpc.ListAccountsRequest{
Name: createAccountName,
AddressType: walletrpc.AddressType_TAPROOT_PUBKEY,
}).GetAccounts()
require.Len(ht, listed, 1)
require.Equal(ht, account.GetExtendedPublicKey(),
listed[0].GetExtendedPublicKey())

// Fund an address belonging to the new account. The address type has
// to match the one the account was created with: lnd resolves a custom
// account name inside the key scope the requested type implies.
addr := 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(addr)),
}}, defaultCreateAccountFeeRate,
)
ht.MineBlocksAndAssertNumTxes(1, 1)

// The balance lands in the new account, and nowhere else. Both halves
// matter: the account must see its own coins, and the default account
// must not see them.
ht.AssertWalletAccountBalance(
alice, createAccountName, int64(fundAmt), 0,
)
ht.AssertWalletAccountBalance(
alice, lnwallet.DefaultAccountName, 0, 0,
)

// Now prove the wallet can actually spend it. An imported (watch-only)
// account gets this far too — funding a PSBT only needs public data —
// but finalizing is where it fails, because the wallet has no private
// key for it and silently signs nothing.
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,
})
require.NotEmpty(ht, finalized.GetRawFinalTx(),
"wallet produced no signed transaction for its own account")

alice.RPC.PublishTransaction(&walletrpc.Transaction{
TxHex: finalized.GetRawFinalTx(),
})
ht.MineBlocksAndAssertNumTxes(1, 1)

// The spend confirmed, and both halves of where the money went matter.
// The account still holds its funds minus fees, which is what shows
// the inputs were spent from it and the change came back to it rather
// than leaking elsewhere; and the default account is still empty,
// which shows "elsewhere" was not it.
accounts := alice.RPC.WalletBalance().GetAccountBalance()
after := btcutil.Amount(
accounts[createAccountName].GetConfirmedBalance(),
)
require.Less(ht, after, fundAmt, "the spend should have paid a fee")
require.Greater(ht, after, fundAmt-maxCreateAccountSpendFee,
"the account should still hold its funds minus fees")

ht.AssertWalletAccountBalance(

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.

The comment says "the account still holds its funds minus fees", but only the default account is asserted here — the custom account's post-spend balance never gets checked.

That's the strongest post-condition in the test: it's what proves the spend came out of the account and the change went back into it rather than leaking to the default account. Worth an AssertWalletAccountBalance on createAccountName too (or trimming the claim from the comment).

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.

Added rather than trimmed, since you're right that it's the strongest post-condition. The test now reads the account's own confirmed balance back from WalletBalance's per-account map and asserts it is below the funded amount (a fee was paid) but above funded-minus-a-fee-ceiling — which is what shows the inputs came from the account and the change returned to it, not to default.

alice, lnwallet.DefaultAccountName, 0, 0,
)
}

// testXCreateAccountRejections asserts the requests lnd refuses, each of which
// would otherwise leave the caller with an account that does not behave the
// way it asked for.
func testXCreateAccountRejections(ht *lntest.HarnessTest) {
alice := ht.NewNode("Alice", nil)

alice.RPC.XCreateAccount(&walletrpc.XCreateAccountRequest{
Name: createAccountName,
AddressType: walletrpc.AddressType_TAPROOT_PUBKEY,
})

// The same name a second time, even under a different address type.
// Coin selection resolves a custom account name to whichever key scope
// matches first, so a duplicate would make later funding ambiguous.
err := alice.RPC.XCreateAccountAssertErr(
&walletrpc.XCreateAccountRequest{
Name: createAccountName,
AddressType: walletrpc.AddressType_WITNESS_PUBKEY_HASH,
},
)
require.ErrorContains(ht, err, "already exists")

// The wallet's own reserved account names.
err = alice.RPC.XCreateAccountAssertErr(
&walletrpc.XCreateAccountRequest{
Name: lnwallet.DefaultAccountName,
AddressType: walletrpc.AddressType_TAPROOT_PUBKEY,
},
)
require.ErrorContains(ht, err, "reserved")

// An empty name.
err = alice.RPC.XCreateAccountAssertErr(
&walletrpc.XCreateAccountRequest{
AddressType: walletrpc.AddressType_TAPROOT_PUBKEY,
},
)
require.ErrorContains(ht, err, "account name is required")

// The strict nested-witness scheme, which a wallet-derived account
// cannot provide: it stores no address schema, so it would silently
// behave as the hybrid scheme instead.
err = alice.RPC.XCreateAccountAssertErr(
&walletrpc.XCreateAccountRequest{
Name: "nested",
AddressType: walletrpc.
AddressType_NESTED_WITNESS_PUBKEY_HASH,
},
)
require.ErrorContains(ht, err, "cannot be created")
}
Loading
Loading