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.

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"))
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
22 changes: 22 additions & 0 deletions docs/release-notes/release-notes-0.21.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,29 @@

## RPC Additions

* 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

* 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 Expand Up @@ -75,5 +96,6 @@

# Contributors (Alphabetical Order)

* Elle Mouton
* Yong Yu
* Ziggie
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"
"github.com/btcsuite/btcd/wire"
"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
// 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,
}).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(
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