From 31e341cab1a58577520760cbd8fea6e86465b6e2 Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Thu, 13 Aug 2026 13:05:08 +0200 Subject: [PATCH 1/2] sweep: add descriptor-backed sweep service --- cmd/commands/walletrpc_active.go | 250 ++++ cmd/commands/walletrpc_active_test.go | 90 ++ docs/release-notes/release-notes-0.22.0.md | 6 + go.mod | 5 + go.sum | 2 + itest/list_on_test.go | 4 + itest/lnd_descriptor_sweep_test.go | 241 ++++ lnrpc/walletrpc/config_active.go | 12 + lnrpc/walletrpc/driver.go | 12 + lnrpc/walletrpc/walletkit.pb.go | 1185 +++++++++++++++--- lnrpc/walletrpc/walletkit.pb.gw.go | 291 +++++ lnrpc/walletrpc/walletkit.pb.json.go | 75 ++ lnrpc/walletrpc/walletkit.proto | 222 ++++ lnrpc/walletrpc/walletkit.swagger.json | 328 ++++- lnrpc/walletrpc/walletkit.yaml | 8 + lnrpc/walletrpc/walletkit_grpc.pb.go | 144 +++ lnrpc/walletrpc/walletkit_server.go | 257 +++- lnrpc/walletrpc/walletkit_server_test.go | 6 +- lntest/rpc/wallet_kit.go | 39 + rpcserver.go | 3 +- server.go | 9 +- subrpcserver_config.go | 13 +- sweep/descriptorsweep/retry.go | 209 ++++ sweep/descriptorsweep/service.go | 1162 ++++++++++++++++++ sweep/descriptorsweep/service_test.go | 1265 ++++++++++++++++++++ sweep/descriptorsweep/store.go | 84 ++ sweep/descriptorsweep/types.go | 655 ++++++++++ 27 files changed, 6388 insertions(+), 189 deletions(-) create mode 100644 cmd/commands/walletrpc_active_test.go create mode 100644 itest/lnd_descriptor_sweep_test.go create mode 100644 sweep/descriptorsweep/retry.go create mode 100644 sweep/descriptorsweep/service.go create mode 100644 sweep/descriptorsweep/service_test.go create mode 100644 sweep/descriptorsweep/store.go create mode 100644 sweep/descriptorsweep/types.go diff --git a/cmd/commands/walletrpc_active.go b/cmd/commands/walletrpc_active.go index 126a1340efa..ffc67ae2b01 100644 --- a/cmd/commands/walletrpc_active.go +++ b/cmd/commands/walletrpc_active.go @@ -21,6 +21,7 @@ import ( "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/lightningnetwork/lnd" + "github.com/lightningnetwork/lnd/lnrpc/signrpc" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwallet/chanfunding" @@ -81,6 +82,9 @@ func walletCommands() []cli.Command { Subcommands: []cli.Command{ estimateFeeRateCommand, pendingSweepsCommand, + registerSweepDescriptorCommand, + addSweepDescriptorDataCommand, + listSweepDescriptorsCommand, bumpFeeCommand, bumpCloseFeeCommand, bumpForceCloseFeeCommand, @@ -232,6 +236,252 @@ func pendingSweeps(ctx *cli.Context) error { return nil } +var registerSweepDescriptorCommand = cli.Command{ + Name: "registersweepdescriptor", + Usage: "Register a fixed output descriptor for automatic sweeping.", + ArgsUsage: "descriptor", + Description: ` + Register a public fixed-index P2WSH output descriptor. lnd watches for the + corresponding output and automatically offers it to the batching sweeper + once a Miniscript branch is satisfiable. + + Each key that lnd should sign for must be bound with a key_binding flag in + the form '=:'. The descriptor key is + the exact key expression used in the descriptor.`, + Flags: []cli.Flag{ + cli.StringSliceFlag{ + Name: "key_binding", + Usage: "Bind a descriptor key to an lnd key locator; may " + + "be repeated.", + }, + cli.UintFlag{ + Name: "derivation_index", + Usage: "Descriptor derivation index (currently must be zero).", + }, + cli.UintFlag{ + Name: "height_hint", + Usage: "Required non-zero earliest block height at which the " + + "output may appear.", + }, + cli.UintFlag{ + Name: "min_confs", + Value: 1, + Usage: "Confirmations required before sweeping the output.", + }, + cli.Uint64Flag{ + Name: "expected_value_sat", + Usage: "Required exact value of the watched output in " + + "satoshis.", + }, + cli.Uint64Flag{ + Name: "budget_sat", + Usage: "Required non-zero maximum sweep fee budget in " + + "satoshis.", + }, + cli.UintFlag{ + Name: "deadline_delta", + Usage: "Blocks from offering a satisfiable output to the " + + "sweeper by which it should confirm.", + }, + cli.BoolFlag{ + Name: "immediate", + Usage: "Sweep immediately when a branch becomes satisfiable.", + }, + cli.StringFlag{ + Name: "label", + Usage: "Optional human-readable registration label.", + }, + }, + Action: actionDecorator(registerSweepDescriptor), +} + +func registerSweepDescriptor(ctx *cli.Context) error { + if ctx.NArg() != 1 { + return cli.ShowCommandHelp(ctx, "registersweepdescriptor") + } + if ctx.Uint("height_hint") == 0 { + return errors.New("height_hint must be non-zero") + } + if ctx.Uint64("budget_sat") == 0 { + return errors.New("budget_sat must be non-zero") + } + if ctx.Uint64("expected_value_sat") == 0 { + return errors.New("expected_value_sat must be non-zero") + } + + bindings := make([]*walletrpc.SweepDescriptorKeyBinding, 0, + len(ctx.StringSlice("key_binding"))) + for _, binding := range ctx.StringSlice("key_binding") { + keyBinding, err := parseSweepDescriptorKeyBinding(binding) + if err != nil { + return err + } + + bindings = append(bindings, keyBinding) + } + + client, cleanUp := getWalletClient(ctx) + defer cleanUp() + + resp, err := client.RegisterSweepDescriptor( + getContext(), &walletrpc.RegisterSweepDescriptorRequest{ + OutputDescriptor: ctx.Args().First(), + DerivationIndex: uint32(ctx.Uint("derivation_index")), + HeightHint: uint32(ctx.Uint("height_hint")), + MinConfs: uint32(ctx.Uint("min_confs")), + ExpectedValueSat: ctx.Uint64("expected_value_sat"), + KeyBindings: bindings, + BudgetSat: ctx.Uint64("budget_sat"), + DeadlineDelta: uint32(ctx.Uint("deadline_delta")), + Immediate: ctx.Bool("immediate"), + Label: ctx.String("label"), + }, + ) + if err != nil { + return err + } + + printRespJSON(resp) + return nil +} + +func parseSweepDescriptorKeyBinding( + binding string) (*walletrpc.SweepDescriptorKeyBinding, error) { + + parts := strings.SplitN(binding, "=", 2) + if len(parts) != 2 || parts[0] == "" { + return nil, fmt.Errorf("invalid key binding %q: expected "+ + "=:", binding) + } + + locator := strings.Split(parts[1], ":") + if len(locator) != 2 { + return nil, fmt.Errorf("invalid key binding %q: expected "+ + "=:", binding) + } + + family, err := strconv.ParseInt(locator[0], 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid key family in %q: %w", binding, err) + } + if family < 0 { + return nil, fmt.Errorf("invalid negative key family in %q", binding) + } + index, err := strconv.ParseInt(locator[1], 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid key index in %q: %w", binding, err) + } + if index < 0 { + return nil, fmt.Errorf("invalid negative key index in %q", binding) + } + + return &walletrpc.SweepDescriptorKeyBinding{ + DescriptorKey: parts[0], + KeyLocator: &signrpc.KeyLocator{ + KeyFamily: int32(family), + KeyIndex: int32(index), + }, + }, nil +} + +var addSweepDescriptorDataCommand = cli.Command{ + Name: "addsweepdescriptordata", + Usage: "Add late satisfaction data to a sweep descriptor.", + ArgsUsage: "registration_id", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "preimage", + Usage: "32-byte preimage encoded as hexadecimal.", + }, + }, + Action: actionDecorator(addSweepDescriptorData), +} + +func addSweepDescriptorData(ctx *cli.Context) error { + if ctx.NArg() != 1 || !ctx.IsSet("preimage") { + return cli.ShowCommandHelp(ctx, "addsweepdescriptordata") + } + + id, err := decodeSweepDescriptorHex("registration ID", ctx.Args().First()) + if err != nil { + return err + } + preimage, err := decodeSweepDescriptorHex("preimage", ctx.String("preimage")) + if err != nil { + return err + } + + client, cleanUp := getWalletClient(ctx) + defer cleanUp() + + resp, err := client.AddSweepDescriptorData( + getContext(), &walletrpc.AddSweepDescriptorDataRequest{ + RegistrationId: id, + Data: &walletrpc.AddSweepDescriptorDataRequest_Preimage{ + Preimage: preimage, + }, + }, + ) + if err != nil { + return err + } + + printRespJSON(resp) + return nil +} + +var listSweepDescriptorsCommand = cli.Command{ + Name: "listsweepdescriptors", + Usage: "List descriptor sweep registrations and their status.", + ArgsUsage: "[registration_id]", + Action: actionDecorator(listSweepDescriptors), +} + +func listSweepDescriptors(ctx *cli.Context) error { + if ctx.NArg() > 1 { + return cli.ShowCommandHelp(ctx, "listsweepdescriptors") + } + + var id []byte + if ctx.NArg() == 1 { + var err error + id, err = decodeSweepDescriptorHex( + "registration ID", ctx.Args().First(), + ) + if err != nil { + return err + } + } + + client, cleanUp := getWalletClient(ctx) + defer cleanUp() + + resp, err := client.ListSweepDescriptors( + getContext(), &walletrpc.ListSweepDescriptorsRequest{ + RegistrationId: id, + }, + ) + if err != nil { + return err + } + + printRespJSON(resp) + return nil +} + +func decodeSweepDescriptorHex(name, value string) ([]byte, error) { + decoded, err := hex.DecodeString(value) + if err != nil { + return nil, fmt.Errorf("invalid %s: %w", name, err) + } + if len(decoded) != 32 { + return nil, fmt.Errorf("invalid %s: expected 32 bytes, got %d", + name, len(decoded)) + } + + return decoded, nil +} + var bumpFeeCommand = cli.Command{ Name: "bumpfee", Usage: "Bumps the fee of an arbitrary input/transaction.", diff --git a/cmd/commands/walletrpc_active_test.go b/cmd/commands/walletrpc_active_test.go new file mode 100644 index 00000000000..422cfc2abe2 --- /dev/null +++ b/cmd/commands/walletrpc_active_test.go @@ -0,0 +1,90 @@ +//go:build walletrpc + +package commands + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseSweepDescriptorKeyBinding(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + binding string + wantKey string + wantFamily int32 + wantIndex int32 + wantErrText string + }{ + { + name: "valid", + binding: "02abcdef=9100:42", + wantKey: "02abcdef", + wantFamily: 9100, + wantIndex: 42, + }, + { + name: "missing key", + binding: "=1:2", + wantErrText: "expected", + }, + { + name: "missing index", + binding: "key=1", + wantErrText: "expected", + }, + { + name: "invalid family", + binding: "key=family:2", + wantErrText: "key family", + }, + { + name: "negative family", + binding: "key=-1:2", + wantErrText: "negative key family", + }, + { + name: "negative index", + binding: "key=1:-1", + wantErrText: "negative key index", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + binding, err := parseSweepDescriptorKeyBinding(test.binding) + if test.wantErrText != "" { + require.ErrorContains(t, err, test.wantErrText) + return + } + + require.NoError(t, err) + require.Equal(t, test.wantKey, binding.DescriptorKey) + require.Equal(t, test.wantFamily, + binding.KeyLocator.KeyFamily) + require.Equal(t, test.wantIndex, binding.KeyLocator.KeyIndex) + }) + } +} + +func TestDecodeSweepDescriptorHex(t *testing.T) { + t.Parallel() + + value := strings.Repeat("42", 32) + decoded, err := decodeSweepDescriptorHex("preimage", value) + require.NoError(t, err) + require.Len(t, decoded, 32) + + _, err = decodeSweepDescriptorHex("preimage", "not-hex") + require.ErrorContains(t, err, "invalid preimage") + + _, err = decodeSweepDescriptorHex("preimage", "42") + require.ErrorContains(t, err, "expected 32 bytes") +} diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index 750a20069cd..8ffea64d469 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -59,6 +59,11 @@ ## Functional Enhancements +* WalletKit can now watch a fixed P2WSH output descriptor and submit its output + to the existing sweeper when a Miniscript branch becomes satisfiable. A + client can add an SHA256 preimage after registration, or let a height-based + CLTV or block-based CSV path mature automatically. + ## RPC Additions * The `routerrpc.EstimateRouteFee` RPC now supports [restricting fee estimates @@ -159,3 +164,4 @@ * Boris Nagaev * Erick Cestari * Jared Tobin +* sputn1ck diff --git a/go.mod b/go.mod index 16e0922c6ee..82a3062ee38 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/btcsuite/btcd/btcutil/v2 v2.0.0 github.com/btcsuite/btcd/chaincfg/v2 v2.0.0 github.com/btcsuite/btcd/chainhash/v2 v2.0.0 + github.com/btcsuite/btcd/descriptors v0.0.0-20260813124334-2b2f1d4858bf github.com/btcsuite/btcd/psbt/v2 v2.0.0 github.com/btcsuite/btcd/txscript/v2 v2.0.0 github.com/btcsuite/btcd/wire/v2 v2.0.0 @@ -209,6 +210,10 @@ require ( // TODO(elle): remove once the gossip V2 sqldb changes have been made. replace github.com/lightningnetwork/lnd/sqldb => ./sqldb +// TODO: Remove this replacement once the descriptor extensions required by +// the descriptor sweep service land in btcsuite/btcd. +replace github.com/btcsuite/btcd/descriptors => github.com/sputn1ck/btcd/descriptors v0.0.0-20260813124334-2b2f1d4858bf + // We want to format raw bytes as hex instead of base64. The forked version // allows us to specify that as an option. replace google.golang.org/protobuf => github.com/lightninglabs/protobuf-go-hex-display v1.36.11-hex-display diff --git a/go.sum b/go.sum index b0772e0a9ce..3710e1c3fe6 100644 --- a/go.sum +++ b/go.sum @@ -371,6 +371,8 @@ github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/sputn1ck/btcd/descriptors v0.0.0-20260813124334-2b2f1d4858bf h1:qcz8ikY7Y6qZjCampmcr8zk+aeDe4WKWQaP9izTZkj8= +github.com/sputn1ck/btcd/descriptors v0.0.0-20260813124334-2b2f1d4858bf/go.mod h1:XWHEMoYNwp6EZmKFffecNBLYPthgRIL0krbb15ItbkA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= diff --git a/itest/list_on_test.go b/itest/list_on_test.go index 1301a266e30..f03f335957f 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -491,6 +491,10 @@ var allTestCases = []*lntest.TestCase{ Name: "nonstd sweep", TestFunc: testNonstdSweep, }, + { + Name: "descriptor sweep", + TestFunc: testDescriptorSweep, + }, { Name: "multiple channel creation and update subscription", TestFunc: testBasicChannelCreationAndUpdates, diff --git a/itest/lnd_descriptor_sweep_test.go b/itest/lnd_descriptor_sweep_test.go new file mode 100644 index 00000000000..acb1b368d88 --- /dev/null +++ b/itest/lnd_descriptor_sweep_test.go @@ -0,0 +1,241 @@ +package itest + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/signrpc" + "github.com/lightningnetwork/lnd/lnrpc/walletrpc" + "github.com/lightningnetwork/lnd/lntest" + lntestrpc "github.com/lightningnetwork/lnd/lntest/rpc" + "github.com/stretchr/testify/require" +) + +const ( + descriptorSweepSuccessKeyFamily = 9100 + descriptorSweepTimeoutKeyFamily = 9101 + descriptorSweepValue = btcutil.Amount(1_000_000) +) + +// testDescriptorSweep exercises both branches of a toy P2WSH HTLC descriptor: +// +// (key_success && sha256(preimage)) || +// (key_timeout && after(cltv_height)) +// +// The success case registers the descriptor before funding it and supplies the +// preimage only after the output confirms. The timeout case never supplies a +// preimage and verifies that reaching the absolute lock height triggers the +// sweep automatically. +func testDescriptorSweep(ht *lntest.HarnessTest) { + tests := []struct { + name string + providePreimage bool + keyFamilyOffset int32 + }{ + { + name: "preimage branch", + providePreimage: true, + keyFamilyOffset: 0, + }, + { + name: "cltv branch", + providePreimage: false, + keyFamilyOffset: 10, + }, + } + + for _, test := range tests { + test := test + if !ht.Run(test.name, func(t *testing.T) { + st := ht.Subtest(t) + testDescriptorSweepBranch( + st, test.providePreimage, test.keyFamilyOffset, + ) + }) { + break + } + } +} + +func testDescriptorSweepBranch(ht *lntest.HarnessTest, providePreimage bool, + keyFamilyOffset int32) { + + branchName := "preimage" + if !providePreimage { + branchName = "cltv" + } + alice := ht.NewNodeWithCoins("descriptor-sweeper-"+branchName, nil) + + successKey := alice.RPC.DeriveNextKey(&walletrpc.KeyReq{ + KeyFamily: descriptorSweepSuccessKeyFamily + keyFamilyOffset, + }) + timeoutKey := alice.RPC.DeriveNextKey(&walletrpc.KeyReq{ + KeyFamily: descriptorSweepTimeoutKeyFamily + keyFamilyOffset, + }) + + preimage := bytes.Repeat([]byte{byte(keyFamilyOffset + 1)}, 32) + paymentHash := sha256.Sum256(preimage) + cltvHeight := ht.CurrentHeight() + 6 + descriptor := fmt.Sprintf( + "wsh(or_i(and_v(v:pk(%s),sha256(%x)),"+ + "and_v(v:pk(%s),after(%d))))", + hex.EncodeToString(successKey.RawKeyBytes), paymentHash, + hex.EncodeToString(timeoutKey.RawKeyBytes), cltvHeight, + ) + + registerResp := alice.RPC.RegisterSweepDescriptor( + &walletrpc.RegisterSweepDescriptorRequest{ + OutputDescriptor: descriptor, + HeightHint: ht.CurrentHeight(), + MinConfs: 1, + ExpectedValueSat: uint64(descriptorSweepValue), + KeyBindings: []*walletrpc.SweepDescriptorKeyBinding{ + descriptorSweepKeyBinding(successKey), + descriptorSweepKeyBinding(timeoutKey), + }, + BudgetSat: 100_000, + DeadlineDelta: 10, + Immediate: true, + Label: "itest toy htlc", + }, + ) + require.Len(ht, registerResp.RegistrationId, 32) + require.NotEmpty(ht, registerResp.Address) + require.NotEmpty(ht, registerResp.PkScript) + + // Fund the descriptor only after the chain watch has been installed. + fundResp := alice.RPC.SendCoins(&lnrpc.SendCoinsRequest{ + Addr: registerResp.Address, + Amount: int64(descriptorSweepValue), + SatPerVbyte: 2, + }) + fundHash, err := chainhash.NewHashFromStr(fundResp.Txid) + require.NoError(ht, err) + fundingTx := ht.AssertTxInMempool(*fundHash) + fundingOutpoint := findDescriptorOutput( + ht, fundingTx, registerResp.PkScript, + ) + ht.MineBlockWithTx(fundingTx) + + waitSweepDescriptorState( + ht, alice.RPC, registerResp.RegistrationId, + walletrpc.SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_WAITING, + ) + + if providePreimage { + alice.RPC.AddSweepDescriptorData( + &walletrpc.AddSweepDescriptorDataRequest{ + RegistrationId: registerResp.RegistrationId, + Data: &walletrpc.AddSweepDescriptorDataRequest_Preimage{ + Preimage: preimage, + }, + }, + ) + } else { + // No data is added for this branch. Advancing the chain to the + // descriptor's absolute lock height must make it satisfiable. + require.Less(ht, ht.CurrentHeight(), cltvHeight) + ht.MineEmptyBlocks(int(cltvHeight - ht.CurrentHeight())) + } + + sweepTx := ht.GetNumTxsFromMempool(1)[0] + descriptorInput := findDescriptorInput(ht, sweepTx, fundingOutpoint) + pendingSweep := ht.AssertNumPendingSweeps(alice, 1)[0] + require.Equal(ht, fundingOutpoint.Hash.String(), + pendingSweep.Outpoint.TxidStr) + require.Equal(ht, fundingOutpoint.Index, + pendingSweep.Outpoint.OutputIndex) + require.Equal(ht, walletrpc.WitnessType_DESCRIPTOR_WSH, + pendingSweep.WitnessType) + if providePreimage { + // lnd uses the current height as the default transaction + // locktime. What matters here is that the success branch does + // not inherit the future CLTV from the timeout branch. + require.Less(ht, sweepTx.LockTime, cltvHeight) + require.True(ht, witnessContains(descriptorInput.Witness, preimage), + "success witness does not contain the supplied preimage") + } else { + require.Equal(ht, cltvHeight, sweepTx.LockTime) + require.False(ht, witnessContains(descriptorInput.Witness, preimage), + "timeout witness unexpectedly contains the preimage") + } + + ht.MineBlockWithTx(sweepTx) + waitSweepDescriptorState( + ht, alice.RPC, registerResp.RegistrationId, + walletrpc.SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_SWEPT, + ) +} + +func descriptorSweepKeyBinding( + key *signrpc.KeyDescriptor) *walletrpc.SweepDescriptorKeyBinding { + + return &walletrpc.SweepDescriptorKeyBinding{ + DescriptorKey: hex.EncodeToString(key.RawKeyBytes), + KeyLocator: key.KeyLoc, + } +} + +func findDescriptorOutput(ht *lntest.HarnessTest, tx *wire.MsgTx, + pkScript []byte) wire.OutPoint { + + for index, txOut := range tx.TxOut { + if bytes.Equal(txOut.PkScript, pkScript) { + return wire.OutPoint{ + Hash: tx.TxHash(), + Index: uint32(index), + } + } + } + + require.Fail(ht, "descriptor output not found in funding transaction") + return wire.OutPoint{} +} + +func findDescriptorInput(ht *lntest.HarnessTest, tx *wire.MsgTx, + want wire.OutPoint) *wire.TxIn { + + for _, txIn := range tx.TxIn { + if txIn.PreviousOutPoint == want { + return txIn + } + } + + require.Failf(ht, "descriptor output not swept", "transaction %v does not "+ + "spend %v", tx.TxHash(), want) + return nil +} + +func witnessContains(witness wire.TxWitness, want []byte) bool { + for _, element := range witness { + if bytes.Equal(element, want) { + return true + } + } + + return false +} + +func waitSweepDescriptorState(ht *lntest.HarnessTest, + rpc *lntestrpc.HarnessRPC, registrationID []byte, + want walletrpc.SweepDescriptorState) { + + require.Eventually(ht, func() bool { + resp := rpc.ListSweepDescriptors( + &walletrpc.ListSweepDescriptorsRequest{ + RegistrationId: registrationID, + }, + ) + return len(resp.Descriptors) == 1 && + resp.Descriptors[0].State == want + }, lntest.DefaultTimeout, 100*time.Millisecond, + "descriptor sweep did not reach state %v", want) +} diff --git a/lnrpc/walletrpc/config_active.go b/lnrpc/walletrpc/config_active.go index 33917b1c95b..980f9cc9281 100644 --- a/lnrpc/walletrpc/config_active.go +++ b/lnrpc/walletrpc/config_active.go @@ -6,8 +6,10 @@ package walletrpc import ( "github.com/btcsuite/btcd/chaincfg/v2" "github.com/btcsuite/btcwallet/wallet" + "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/chanstate" "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/kvdb" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/macaroons" @@ -63,6 +65,16 @@ type Config struct { // sweeping inputs in batches back into the wallet. Sweeper *sweep.UtxoSweeper + // ChainNotifier watches exact descriptor scripts and block epochs. + ChainNotifier chainntnfs.ChainNotifier + + // DescriptorSweepDB durably stores descriptor registrations and assets. + DescriptorSweepDB kvdb.Backend + + // DescriptorSweepReady is closed after the chain notifier and UTXO + // sweeper have both started. + DescriptorSweepReady <-chan struct{} + // Chain is an interface that the WalletKit will use to determine state // about the backing chain of the wallet. Chain lnwallet.BlockChainIO diff --git a/lnrpc/walletrpc/driver.go b/lnrpc/walletrpc/driver.go index 7446470f1fb..d06069858a3 100644 --- a/lnrpc/walletrpc/driver.go +++ b/lnrpc/walletrpc/driver.go @@ -59,6 +59,18 @@ func createNewSubServer(configRegistry lnrpc.SubServerConfigDispatcher) ( return nil, nil, fmt.Errorf("Sweeper must be set to create " + "WalletKit RPC server") + case config.ChainNotifier == nil: + return nil, nil, fmt.Errorf("ChainNotifier must be set to create " + + "WalletKit RPC server") + + case config.DescriptorSweepDB == nil: + return nil, nil, fmt.Errorf("DescriptorSweepDB must be set to " + + "create WalletKit RPC server") + + case config.DescriptorSweepReady == nil: + return nil, nil, fmt.Errorf("DescriptorSweepReady must be set to " + + "create WalletKit RPC server") + case config.Chain == nil: return nil, nil, fmt.Errorf("Chain must be set to create " + "WalletKit RPC server") diff --git a/lnrpc/walletrpc/walletkit.pb.go b/lnrpc/walletrpc/walletkit.pb.go index 40e9506e3f3..087aaa42d55 100644 --- a/lnrpc/walletrpc/walletkit.pb.go +++ b/lnrpc/walletrpc/walletkit.pb.go @@ -247,6 +247,9 @@ const ( // counterparty's who broadcasts a revoked production taproot commitment // transaction. WitnessType_TAPROOT_COMMITMENT_REVOKE_FINAL WitnessType = 42 + // A witness constructed from a registered native P2WSH output descriptor and + // its selected Miniscript satisfaction plan. + WitnessType_DESCRIPTOR_WSH WitnessType = 43 ) // Enum value maps for WitnessType. @@ -295,6 +298,7 @@ var ( 40: "TAPROOT_HTLC_OFFERED_REMOTE_TIMEOUT_FINAL", 41: "TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS_FINAL", 42: "TAPROOT_COMMITMENT_REVOKE_FINAL", + 43: "DESCRIPTOR_WSH", } WitnessType_value = map[string]int32{ "UNKNOWN_WITNESS": 0, @@ -340,6 +344,7 @@ var ( "TAPROOT_HTLC_OFFERED_REMOTE_TIMEOUT_FINAL": 40, "TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS_FINAL": 41, "TAPROOT_COMMITMENT_REVOKE_FINAL": 42, + "DESCRIPTOR_WSH": 43, } ) @@ -427,6 +432,79 @@ func (ChangeAddressType) EnumDescriptor() ([]byte, []int) { return file_walletrpc_walletkit_proto_rawDescGZIP(), []int{2} } +// SweepDescriptorState describes the lifecycle of a descriptor sweep. +type SweepDescriptorState int32 + +const ( + // The state is unknown or was not set. + SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_UNKNOWN SweepDescriptorState = 0 + // The registration was accepted and is being initialized. + SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_REGISTERED SweepDescriptorState = 1 + // lnd is watching the chain for an output matching the descriptor. + SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_WATCHING SweepDescriptorState = 2 + // A matching output was found. + SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_FOUND SweepDescriptorState = 3 + // The output is waiting for a satisfiable Miniscript branch. + SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_WAITING SweepDescriptorState = 4 + // The output has been handed to lnd's batching sweeper. + SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_SWEEPING SweepDescriptorState = 5 + // The sweep transaction was confirmed. + SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_SWEPT SweepDescriptorState = 6 + // The registration failed and will not make further progress. + SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_FAILED SweepDescriptorState = 7 +) + +// Enum value maps for SweepDescriptorState. +var ( + SweepDescriptorState_name = map[int32]string{ + 0: "SWEEP_DESCRIPTOR_STATE_UNKNOWN", + 1: "SWEEP_DESCRIPTOR_STATE_REGISTERED", + 2: "SWEEP_DESCRIPTOR_STATE_WATCHING", + 3: "SWEEP_DESCRIPTOR_STATE_FOUND", + 4: "SWEEP_DESCRIPTOR_STATE_WAITING", + 5: "SWEEP_DESCRIPTOR_STATE_SWEEPING", + 6: "SWEEP_DESCRIPTOR_STATE_SWEPT", + 7: "SWEEP_DESCRIPTOR_STATE_FAILED", + } + SweepDescriptorState_value = map[string]int32{ + "SWEEP_DESCRIPTOR_STATE_UNKNOWN": 0, + "SWEEP_DESCRIPTOR_STATE_REGISTERED": 1, + "SWEEP_DESCRIPTOR_STATE_WATCHING": 2, + "SWEEP_DESCRIPTOR_STATE_FOUND": 3, + "SWEEP_DESCRIPTOR_STATE_WAITING": 4, + "SWEEP_DESCRIPTOR_STATE_SWEEPING": 5, + "SWEEP_DESCRIPTOR_STATE_SWEPT": 6, + "SWEEP_DESCRIPTOR_STATE_FAILED": 7, + } +) + +func (x SweepDescriptorState) Enum() *SweepDescriptorState { + p := new(SweepDescriptorState) + *p = x + return p +} + +func (x SweepDescriptorState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SweepDescriptorState) Descriptor() protoreflect.EnumDescriptor { + return file_walletrpc_walletkit_proto_enumTypes[3].Descriptor() +} + +func (SweepDescriptorState) Type() protoreflect.EnumType { + return &file_walletrpc_walletkit_proto_enumTypes[3] +} + +func (x SweepDescriptorState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SweepDescriptorState.Descriptor instead. +func (SweepDescriptorState) EnumDescriptor() ([]byte, []int) { + return file_walletrpc_walletkit_proto_rawDescGZIP(), []int{3} +} + type ListUnspentRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The minimum number of confirmations to be included. @@ -4660,6 +4738,663 @@ func (x *ListLeasesResponse) GetLockedUtxos() []*UtxoLease { return nil } +// SweepDescriptorKeyBinding binds a key expression used in a descriptor to a key +// controlled by lnd. The descriptor_key must exactly match the key expression in +// the canonical descriptor and the derived public keys must agree. +type SweepDescriptorKeyBinding struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The key expression as it appears in the descriptor. + DescriptorKey string `protobuf:"bytes,1,opt,name=descriptor_key,json=descriptorKey,proto3" json:"descriptor_key,omitempty"` + // The key locator of the corresponding key in lnd's wallet. + KeyLocator *signrpc.KeyLocator `protobuf:"bytes,2,opt,name=key_locator,json=keyLocator,proto3" json:"key_locator,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SweepDescriptorKeyBinding) Reset() { + *x = SweepDescriptorKeyBinding{} + mi := &file_walletrpc_walletkit_proto_msgTypes[64] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SweepDescriptorKeyBinding) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SweepDescriptorKeyBinding) ProtoMessage() {} + +func (x *SweepDescriptorKeyBinding) ProtoReflect() protoreflect.Message { + mi := &file_walletrpc_walletkit_proto_msgTypes[64] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SweepDescriptorKeyBinding.ProtoReflect.Descriptor instead. +func (*SweepDescriptorKeyBinding) Descriptor() ([]byte, []int) { + return file_walletrpc_walletkit_proto_rawDescGZIP(), []int{64} +} + +func (x *SweepDescriptorKeyBinding) GetDescriptorKey() string { + if x != nil { + return x.DescriptorKey + } + return "" +} + +func (x *SweepDescriptorKeyBinding) GetKeyLocator() *signrpc.KeyLocator { + if x != nil { + return x.KeyLocator + } + return nil +} + +type RegisterSweepDescriptorRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The output descriptor to watch and sweep. Private descriptors (WIF or xprv) + // are rejected. The initial implementation accepts fixed-index wsh() + // descriptors only. + OutputDescriptor string `protobuf:"bytes,1,opt,name=output_descriptor,json=outputDescriptor,proto3" json:"output_descriptor,omitempty"` + // The derivation index to use for wildcard descriptors. This must be zero in + // the initial fixed-index implementation. + DerivationIndex uint32 `protobuf:"varint,2,opt,name=derivation_index,json=derivationIndex,proto3" json:"derivation_index,omitempty"` + // Required. The non-zero earliest block height at which the output may + // have been created. + HeightHint uint32 `protobuf:"varint,3,opt,name=height_hint,json=heightHint,proto3" json:"height_hint,omitempty"` + // The number of confirmations required before the output may be swept. This + // is the registration's source-finality policy: the initial implementation + // does not roll an offered input back after a deeper funding reorganization. + MinConfs uint32 `protobuf:"varint,4,opt,name=min_confs,json=minConfs,proto3" json:"min_confs,omitempty"` + // Keys in the descriptor for which lnd should produce signatures. + KeyBindings []*SweepDescriptorKeyBinding `protobuf:"bytes,5,rep,name=key_bindings,json=keyBindings,proto3" json:"key_bindings,omitempty"` + // Required. The non-zero maximum number of satoshis the sweep may spend on + // fees. + BudgetSat uint64 `protobuf:"varint,6,opt,name=budget_sat,json=budgetSat,proto3" json:"budget_sat,omitempty"` + // The number of blocks from offering a satisfiable output to the sweeper by + // which the sweep should confirm. A value of zero uses the sweeper's default + // deadline. + DeadlineDelta uint32 `protobuf:"varint,7,opt,name=deadline_delta,json=deadlineDelta,proto3" json:"deadline_delta,omitempty"` + // Whether to offer a satisfiable output to the sweeper immediately. + Immediate bool `protobuf:"varint,8,opt,name=immediate,proto3" json:"immediate,omitempty"` + // An optional human-readable label for this registration. + Label string `protobuf:"bytes,9,opt,name=label,proto3" json:"label,omitempty"` + // Required. The exact value in satoshis of the output to watch. An output is + // accepted only when both its script and value match this registration. + ExpectedValueSat uint64 `protobuf:"varint,10,opt,name=expected_value_sat,json=expectedValueSat,proto3" json:"expected_value_sat,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterSweepDescriptorRequest) Reset() { + *x = RegisterSweepDescriptorRequest{} + mi := &file_walletrpc_walletkit_proto_msgTypes[65] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterSweepDescriptorRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterSweepDescriptorRequest) ProtoMessage() {} + +func (x *RegisterSweepDescriptorRequest) ProtoReflect() protoreflect.Message { + mi := &file_walletrpc_walletkit_proto_msgTypes[65] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterSweepDescriptorRequest.ProtoReflect.Descriptor instead. +func (*RegisterSweepDescriptorRequest) Descriptor() ([]byte, []int) { + return file_walletrpc_walletkit_proto_rawDescGZIP(), []int{65} +} + +func (x *RegisterSweepDescriptorRequest) GetOutputDescriptor() string { + if x != nil { + return x.OutputDescriptor + } + return "" +} + +func (x *RegisterSweepDescriptorRequest) GetDerivationIndex() uint32 { + if x != nil { + return x.DerivationIndex + } + return 0 +} + +func (x *RegisterSweepDescriptorRequest) GetHeightHint() uint32 { + if x != nil { + return x.HeightHint + } + return 0 +} + +func (x *RegisterSweepDescriptorRequest) GetMinConfs() uint32 { + if x != nil { + return x.MinConfs + } + return 0 +} + +func (x *RegisterSweepDescriptorRequest) GetKeyBindings() []*SweepDescriptorKeyBinding { + if x != nil { + return x.KeyBindings + } + return nil +} + +func (x *RegisterSweepDescriptorRequest) GetBudgetSat() uint64 { + if x != nil { + return x.BudgetSat + } + return 0 +} + +func (x *RegisterSweepDescriptorRequest) GetDeadlineDelta() uint32 { + if x != nil { + return x.DeadlineDelta + } + return 0 +} + +func (x *RegisterSweepDescriptorRequest) GetImmediate() bool { + if x != nil { + return x.Immediate + } + return false +} + +func (x *RegisterSweepDescriptorRequest) GetLabel() string { + if x != nil { + return x.Label + } + return "" +} + +func (x *RegisterSweepDescriptorRequest) GetExpectedValueSat() uint64 { + if x != nil { + return x.ExpectedValueSat + } + return 0 +} + +type RegisterSweepDescriptorResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The stable ID of the registration. + RegistrationId []byte `protobuf:"bytes,1,opt,name=registration_id,json=registrationId,proto3" json:"registration_id,omitempty"` + // The parsed descriptor serialized in canonical form. + OutputDescriptor string `protobuf:"bytes,2,opt,name=output_descriptor,json=outputDescriptor,proto3" json:"output_descriptor,omitempty"` + // The address derived from the descriptor. + Address string `protobuf:"bytes,3,opt,name=address,proto3" json:"address,omitempty"` + // The exact output script watched by lnd. + PkScript []byte `protobuf:"bytes,4,opt,name=pk_script,json=pkScript,proto3" json:"pk_script,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterSweepDescriptorResponse) Reset() { + *x = RegisterSweepDescriptorResponse{} + mi := &file_walletrpc_walletkit_proto_msgTypes[66] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterSweepDescriptorResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterSweepDescriptorResponse) ProtoMessage() {} + +func (x *RegisterSweepDescriptorResponse) ProtoReflect() protoreflect.Message { + mi := &file_walletrpc_walletkit_proto_msgTypes[66] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterSweepDescriptorResponse.ProtoReflect.Descriptor instead. +func (*RegisterSweepDescriptorResponse) Descriptor() ([]byte, []int) { + return file_walletrpc_walletkit_proto_rawDescGZIP(), []int{66} +} + +func (x *RegisterSweepDescriptorResponse) GetRegistrationId() []byte { + if x != nil { + return x.RegistrationId + } + return nil +} + +func (x *RegisterSweepDescriptorResponse) GetOutputDescriptor() string { + if x != nil { + return x.OutputDescriptor + } + return "" +} + +func (x *RegisterSweepDescriptorResponse) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +func (x *RegisterSweepDescriptorResponse) GetPkScript() []byte { + if x != nil { + return x.PkScript + } + return nil +} + +type AddSweepDescriptorDataRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The stable ID returned by RegisterSweepDescriptor. + RegistrationId []byte `protobuf:"bytes,1,opt,name=registration_id,json=registrationId,proto3" json:"registration_id,omitempty"` + // Types that are valid to be assigned to Data: + // + // *AddSweepDescriptorDataRequest_Preimage + Data isAddSweepDescriptorDataRequest_Data `protobuf_oneof:"data"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddSweepDescriptorDataRequest) Reset() { + *x = AddSweepDescriptorDataRequest{} + mi := &file_walletrpc_walletkit_proto_msgTypes[67] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddSweepDescriptorDataRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddSweepDescriptorDataRequest) ProtoMessage() {} + +func (x *AddSweepDescriptorDataRequest) ProtoReflect() protoreflect.Message { + mi := &file_walletrpc_walletkit_proto_msgTypes[67] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddSweepDescriptorDataRequest.ProtoReflect.Descriptor instead. +func (*AddSweepDescriptorDataRequest) Descriptor() ([]byte, []int) { + return file_walletrpc_walletkit_proto_rawDescGZIP(), []int{67} +} + +func (x *AddSweepDescriptorDataRequest) GetRegistrationId() []byte { + if x != nil { + return x.RegistrationId + } + return nil +} + +func (x *AddSweepDescriptorDataRequest) GetData() isAddSweepDescriptorDataRequest_Data { + if x != nil { + return x.Data + } + return nil +} + +func (x *AddSweepDescriptorDataRequest) GetPreimage() []byte { + if x != nil { + if x, ok := x.Data.(*AddSweepDescriptorDataRequest_Preimage); ok { + return x.Preimage + } + } + return nil +} + +type isAddSweepDescriptorDataRequest_Data interface { + isAddSweepDescriptorDataRequest_Data() +} + +type AddSweepDescriptorDataRequest_Preimage struct { + // A 32-byte preimage. lnd derives its SHA256 digest and makes the preimage + // available only to matching hash fragments in the descriptor. + Preimage []byte `protobuf:"bytes,2,opt,name=preimage,proto3,oneof"` +} + +func (*AddSweepDescriptorDataRequest_Preimage) isAddSweepDescriptorDataRequest_Data() {} + +type AddSweepDescriptorDataResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The updated state of the registration. + Status *SweepDescriptorStatus `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddSweepDescriptorDataResponse) Reset() { + *x = AddSweepDescriptorDataResponse{} + mi := &file_walletrpc_walletkit_proto_msgTypes[68] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddSweepDescriptorDataResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddSweepDescriptorDataResponse) ProtoMessage() {} + +func (x *AddSweepDescriptorDataResponse) ProtoReflect() protoreflect.Message { + mi := &file_walletrpc_walletkit_proto_msgTypes[68] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddSweepDescriptorDataResponse.ProtoReflect.Descriptor instead. +func (*AddSweepDescriptorDataResponse) Descriptor() ([]byte, []int) { + return file_walletrpc_walletkit_proto_rawDescGZIP(), []int{68} +} + +func (x *AddSweepDescriptorDataResponse) GetStatus() *SweepDescriptorStatus { + if x != nil { + return x.Status + } + return nil +} + +type ListSweepDescriptorsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // An optional registration ID. When omitted, all registrations are returned. + RegistrationId []byte `protobuf:"bytes,1,opt,name=registration_id,json=registrationId,proto3" json:"registration_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSweepDescriptorsRequest) Reset() { + *x = ListSweepDescriptorsRequest{} + mi := &file_walletrpc_walletkit_proto_msgTypes[69] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSweepDescriptorsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSweepDescriptorsRequest) ProtoMessage() {} + +func (x *ListSweepDescriptorsRequest) ProtoReflect() protoreflect.Message { + mi := &file_walletrpc_walletkit_proto_msgTypes[69] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSweepDescriptorsRequest.ProtoReflect.Descriptor instead. +func (*ListSweepDescriptorsRequest) Descriptor() ([]byte, []int) { + return file_walletrpc_walletkit_proto_rawDescGZIP(), []int{69} +} + +func (x *ListSweepDescriptorsRequest) GetRegistrationId() []byte { + if x != nil { + return x.RegistrationId + } + return nil +} + +type ListSweepDescriptorsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The matching descriptor sweep registrations. + Descriptors []*SweepDescriptorStatus `protobuf:"bytes,1,rep,name=descriptors,proto3" json:"descriptors,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSweepDescriptorsResponse) Reset() { + *x = ListSweepDescriptorsResponse{} + mi := &file_walletrpc_walletkit_proto_msgTypes[70] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSweepDescriptorsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSweepDescriptorsResponse) ProtoMessage() {} + +func (x *ListSweepDescriptorsResponse) ProtoReflect() protoreflect.Message { + mi := &file_walletrpc_walletkit_proto_msgTypes[70] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSweepDescriptorsResponse.ProtoReflect.Descriptor instead. +func (*ListSweepDescriptorsResponse) Descriptor() ([]byte, []int) { + return file_walletrpc_walletkit_proto_rawDescGZIP(), []int{70} +} + +func (x *ListSweepDescriptorsResponse) GetDescriptors() []*SweepDescriptorStatus { + if x != nil { + return x.Descriptors + } + return nil +} + +type SweepDescriptorStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The stable ID of the registration. + RegistrationId []byte `protobuf:"bytes,1,opt,name=registration_id,json=registrationId,proto3" json:"registration_id,omitempty"` + // The descriptor serialized in canonical form. + OutputDescriptor string `protobuf:"bytes,2,opt,name=output_descriptor,json=outputDescriptor,proto3" json:"output_descriptor,omitempty"` + // The address derived from the descriptor. + Address string `protobuf:"bytes,3,opt,name=address,proto3" json:"address,omitempty"` + // The exact output script watched by lnd. + PkScript []byte `protobuf:"bytes,4,opt,name=pk_script,json=pkScript,proto3" json:"pk_script,omitempty"` + // The current lifecycle state. + State SweepDescriptorState `protobuf:"varint,5,opt,name=state,proto3,enum=walletrpc.SweepDescriptorState" json:"state,omitempty"` + // The matching outpoint, once an output has been found. + Outpoint *lnrpc.OutPoint `protobuf:"bytes,6,opt,name=outpoint,proto3" json:"outpoint,omitempty"` + // The transaction ID of the confirmed sweep transaction. + SweepTxid []byte `protobuf:"bytes,7,opt,name=sweep_txid,json=sweepTxid,proto3" json:"sweep_txid,omitempty"` + // The terminal error when state is SWEEP_DESCRIPTOR_STATE_FAILED. + FailureReason string `protobuf:"bytes,8,opt,name=failure_reason,json=failureReason,proto3" json:"failure_reason,omitempty"` + // The earliest block height watched by this registration. + HeightHint uint32 `protobuf:"varint,9,opt,name=height_hint,json=heightHint,proto3" json:"height_hint,omitempty"` + // The requested confirmation depth of a matching output. + MinConfs uint32 `protobuf:"varint,10,opt,name=min_confs,json=minConfs,proto3" json:"min_confs,omitempty"` + // Keys in the descriptor that are bound to lnd wallet keys. + KeyBindings []*SweepDescriptorKeyBinding `protobuf:"bytes,11,rep,name=key_bindings,json=keyBindings,proto3" json:"key_bindings,omitempty"` + // The maximum number of satoshis the sweep may spend on fees. + BudgetSat uint64 `protobuf:"varint,12,opt,name=budget_sat,json=budgetSat,proto3" json:"budget_sat,omitempty"` + // The requested sweep deadline delta. + DeadlineDelta uint32 `protobuf:"varint,13,opt,name=deadline_delta,json=deadlineDelta,proto3" json:"deadline_delta,omitempty"` + // Whether the sweep is offered to the sweeper immediately. + Immediate bool `protobuf:"varint,14,opt,name=immediate,proto3" json:"immediate,omitempty"` + // The optional human-readable registration label. + Label string `protobuf:"bytes,15,opt,name=label,proto3" json:"label,omitempty"` + // The exact output value required by this registration. + ExpectedValueSat uint64 `protobuf:"varint,16,opt,name=expected_value_sat,json=expectedValueSat,proto3" json:"expected_value_sat,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SweepDescriptorStatus) Reset() { + *x = SweepDescriptorStatus{} + mi := &file_walletrpc_walletkit_proto_msgTypes[71] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SweepDescriptorStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SweepDescriptorStatus) ProtoMessage() {} + +func (x *SweepDescriptorStatus) ProtoReflect() protoreflect.Message { + mi := &file_walletrpc_walletkit_proto_msgTypes[71] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SweepDescriptorStatus.ProtoReflect.Descriptor instead. +func (*SweepDescriptorStatus) Descriptor() ([]byte, []int) { + return file_walletrpc_walletkit_proto_rawDescGZIP(), []int{71} +} + +func (x *SweepDescriptorStatus) GetRegistrationId() []byte { + if x != nil { + return x.RegistrationId + } + return nil +} + +func (x *SweepDescriptorStatus) GetOutputDescriptor() string { + if x != nil { + return x.OutputDescriptor + } + return "" +} + +func (x *SweepDescriptorStatus) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +func (x *SweepDescriptorStatus) GetPkScript() []byte { + if x != nil { + return x.PkScript + } + return nil +} + +func (x *SweepDescriptorStatus) GetState() SweepDescriptorState { + if x != nil { + return x.State + } + return SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_UNKNOWN +} + +func (x *SweepDescriptorStatus) GetOutpoint() *lnrpc.OutPoint { + if x != nil { + return x.Outpoint + } + return nil +} + +func (x *SweepDescriptorStatus) GetSweepTxid() []byte { + if x != nil { + return x.SweepTxid + } + return nil +} + +func (x *SweepDescriptorStatus) GetFailureReason() string { + if x != nil { + return x.FailureReason + } + return "" +} + +func (x *SweepDescriptorStatus) GetHeightHint() uint32 { + if x != nil { + return x.HeightHint + } + return 0 +} + +func (x *SweepDescriptorStatus) GetMinConfs() uint32 { + if x != nil { + return x.MinConfs + } + return 0 +} + +func (x *SweepDescriptorStatus) GetKeyBindings() []*SweepDescriptorKeyBinding { + if x != nil { + return x.KeyBindings + } + return nil +} + +func (x *SweepDescriptorStatus) GetBudgetSat() uint64 { + if x != nil { + return x.BudgetSat + } + return 0 +} + +func (x *SweepDescriptorStatus) GetDeadlineDelta() uint32 { + if x != nil { + return x.DeadlineDelta + } + return 0 +} + +func (x *SweepDescriptorStatus) GetImmediate() bool { + if x != nil { + return x.Immediate + } + return false +} + +func (x *SweepDescriptorStatus) GetLabel() string { + if x != nil { + return x.Label + } + return "" +} + +func (x *SweepDescriptorStatus) GetExpectedValueSat() uint64 { + if x != nil { + return x.ExpectedValueSat + } + return 0 +} + type ListSweepsResponse_TransactionIDs struct { state protoimpl.MessageState `protogen:"open.v1"` // Reversed, hex-encoded string representing the transaction ids of the @@ -4672,7 +5407,7 @@ type ListSweepsResponse_TransactionIDs struct { func (x *ListSweepsResponse_TransactionIDs) Reset() { *x = ListSweepsResponse_TransactionIDs{} - mi := &file_walletrpc_walletkit_proto_msgTypes[65] + mi := &file_walletrpc_walletkit_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4684,7 +5419,7 @@ func (x *ListSweepsResponse_TransactionIDs) String() string { func (*ListSweepsResponse_TransactionIDs) ProtoMessage() {} func (x *ListSweepsResponse_TransactionIDs) ProtoReflect() protoreflect.Message { - mi := &file_walletrpc_walletkit_proto_msgTypes[65] + mi := &file_walletrpc_walletkit_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4996,13 +5731,67 @@ const file_walletrpc_walletkit_proto_rawDesc = "" + "rawFinalTx\"\x13\n" + "\x11ListLeasesRequest\"M\n" + "\x12ListLeasesResponse\x127\n" + - "\flocked_utxos\x18\x01 \x03(\v2\x14.walletrpc.UtxoLeaseR\vlockedUtxos*\x8e\x01\n" + + "\flocked_utxos\x18\x01 \x03(\v2\x14.walletrpc.UtxoLeaseR\vlockedUtxos\"x\n" + + "\x19SweepDescriptorKeyBinding\x12%\n" + + "\x0edescriptor_key\x18\x01 \x01(\tR\rdescriptorKey\x124\n" + + "\vkey_locator\x18\x02 \x01(\v2\x13.signrpc.KeyLocatorR\n" + + "keyLocator\"\xa7\x03\n" + + "\x1eRegisterSweepDescriptorRequest\x12+\n" + + "\x11output_descriptor\x18\x01 \x01(\tR\x10outputDescriptor\x12)\n" + + "\x10derivation_index\x18\x02 \x01(\rR\x0fderivationIndex\x12\x1f\n" + + "\vheight_hint\x18\x03 \x01(\rR\n" + + "heightHint\x12\x1b\n" + + "\tmin_confs\x18\x04 \x01(\rR\bminConfs\x12G\n" + + "\fkey_bindings\x18\x05 \x03(\v2$.walletrpc.SweepDescriptorKeyBindingR\vkeyBindings\x12\x1d\n" + + "\n" + + "budget_sat\x18\x06 \x01(\x04R\tbudgetSat\x12%\n" + + "\x0edeadline_delta\x18\a \x01(\rR\rdeadlineDelta\x12\x1c\n" + + "\timmediate\x18\b \x01(\bR\timmediate\x12\x14\n" + + "\x05label\x18\t \x01(\tR\x05label\x12,\n" + + "\x12expected_value_sat\x18\n" + + " \x01(\x04R\x10expectedValueSat\"\xae\x01\n" + + "\x1fRegisterSweepDescriptorResponse\x12'\n" + + "\x0fregistration_id\x18\x01 \x01(\fR\x0eregistrationId\x12+\n" + + "\x11output_descriptor\x18\x02 \x01(\tR\x10outputDescriptor\x12\x18\n" + + "\aaddress\x18\x03 \x01(\tR\aaddress\x12\x1b\n" + + "\tpk_script\x18\x04 \x01(\fR\bpkScript\"n\n" + + "\x1dAddSweepDescriptorDataRequest\x12'\n" + + "\x0fregistration_id\x18\x01 \x01(\fR\x0eregistrationId\x12\x1c\n" + + "\bpreimage\x18\x02 \x01(\fH\x00R\bpreimageB\x06\n" + + "\x04data\"Z\n" + + "\x1eAddSweepDescriptorDataResponse\x128\n" + + "\x06status\x18\x01 \x01(\v2 .walletrpc.SweepDescriptorStatusR\x06status\"F\n" + + "\x1bListSweepDescriptorsRequest\x12'\n" + + "\x0fregistration_id\x18\x01 \x01(\fR\x0eregistrationId\"b\n" + + "\x1cListSweepDescriptorsResponse\x12B\n" + + "\vdescriptors\x18\x01 \x03(\v2 .walletrpc.SweepDescriptorStatusR\vdescriptors\"\xfd\x04\n" + + "\x15SweepDescriptorStatus\x12'\n" + + "\x0fregistration_id\x18\x01 \x01(\fR\x0eregistrationId\x12+\n" + + "\x11output_descriptor\x18\x02 \x01(\tR\x10outputDescriptor\x12\x18\n" + + "\aaddress\x18\x03 \x01(\tR\aaddress\x12\x1b\n" + + "\tpk_script\x18\x04 \x01(\fR\bpkScript\x125\n" + + "\x05state\x18\x05 \x01(\x0e2\x1f.walletrpc.SweepDescriptorStateR\x05state\x12+\n" + + "\boutpoint\x18\x06 \x01(\v2\x0f.lnrpc.OutPointR\boutpoint\x12\x1d\n" + + "\n" + + "sweep_txid\x18\a \x01(\fR\tsweepTxid\x12%\n" + + "\x0efailure_reason\x18\b \x01(\tR\rfailureReason\x12\x1f\n" + + "\vheight_hint\x18\t \x01(\rR\n" + + "heightHint\x12\x1b\n" + + "\tmin_confs\x18\n" + + " \x01(\rR\bminConfs\x12G\n" + + "\fkey_bindings\x18\v \x03(\v2$.walletrpc.SweepDescriptorKeyBindingR\vkeyBindings\x12\x1d\n" + + "\n" + + "budget_sat\x18\f \x01(\x04R\tbudgetSat\x12%\n" + + "\x0edeadline_delta\x18\r \x01(\rR\rdeadlineDelta\x12\x1c\n" + + "\timmediate\x18\x0e \x01(\bR\timmediate\x12\x14\n" + + "\x05label\x18\x0f \x01(\tR\x05label\x12,\n" + + "\x12expected_value_sat\x18\x10 \x01(\x04R\x10expectedValueSat*\x8e\x01\n" + "\vAddressType\x12\v\n" + "\aUNKNOWN\x10\x00\x12\x17\n" + "\x13WITNESS_PUBKEY_HASH\x10\x01\x12\x1e\n" + "\x1aNESTED_WITNESS_PUBKEY_HASH\x10\x02\x12%\n" + "!HYBRID_NESTED_WITNESS_PUBKEY_HASH\x10\x03\x12\x12\n" + - "\x0eTAPROOT_PUBKEY\x10\x04*\xb7\f\n" + + "\x0eTAPROOT_PUBKEY\x10\x04*\xcb\f\n" + "\vWitnessType\x12\x13\n" + "\x0fUNKNOWN_WITNESS\x10\x00\x12\x18\n" + "\x14COMMITMENT_TIME_LOCK\x10\x01\x12\x17\n" + @@ -5047,10 +5836,20 @@ const file_walletrpc_walletkit_proto_rawDesc = "" + "0TAPROOT_HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL_FINAL\x10'\x12-\n" + ")TAPROOT_HTLC_OFFERED_REMOTE_TIMEOUT_FINAL\x10(\x12.\n" + "*TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS_FINAL\x10)\x12#\n" + - "\x1fTAPROOT_COMMITMENT_REVOKE_FINAL\x10**V\n" + + "\x1fTAPROOT_COMMITMENT_REVOKE_FINAL\x10*\x12\x12\n" + + "\x0eDESCRIPTOR_WSH\x10+*V\n" + "\x11ChangeAddressType\x12#\n" + "\x1fCHANGE_ADDRESS_TYPE_UNSPECIFIED\x10\x00\x12\x1c\n" + - "\x18CHANGE_ADDRESS_TYPE_P2TR\x10\x012\xaa\x12\n" + + "\x18CHANGE_ADDRESS_TYPE_P2TR\x10\x01*\xb6\x02\n" + + "\x14SweepDescriptorState\x12\"\n" + + "\x1eSWEEP_DESCRIPTOR_STATE_UNKNOWN\x10\x00\x12%\n" + + "!SWEEP_DESCRIPTOR_STATE_REGISTERED\x10\x01\x12#\n" + + "\x1fSWEEP_DESCRIPTOR_STATE_WATCHING\x10\x02\x12 \n" + + "\x1cSWEEP_DESCRIPTOR_STATE_FOUND\x10\x03\x12\"\n" + + "\x1eSWEEP_DESCRIPTOR_STATE_WAITING\x10\x04\x12#\n" + + "\x1fSWEEP_DESCRIPTOR_STATE_SWEEPING\x10\x05\x12 \n" + + "\x1cSWEEP_DESCRIPTOR_STATE_SWEPT\x10\x06\x12!\n" + + "\x1dSWEEP_DESCRIPTOR_STATE_FAILED\x10\a2\xf4\x14\n" + "\tWalletKit\x12L\n" + "\vListUnspent\x12\x1d.walletrpc.ListUnspentRequest\x1a\x1e.walletrpc.ListUnspentResponse\x12L\n" + "\vLeaseOutput\x12\x1d.walletrpc.LeaseOutputRequest\x1a\x1e.walletrpc.LeaseOutputResponse\x12R\n" + @@ -5074,7 +5873,10 @@ const file_walletrpc_walletkit_proto_rawDesc = "" + "\x11RemoveTransaction\x12 .walletrpc.GetTransactionRequest\x1a$.walletrpc.RemoveTransactionResponse\x12L\n" + "\vSendOutputs\x12\x1d.walletrpc.SendOutputsRequest\x1a\x1e.walletrpc.SendOutputsResponse\x12L\n" + "\vEstimateFee\x12\x1d.walletrpc.EstimateFeeRequest\x1a\x1e.walletrpc.EstimateFeeResponse\x12R\n" + - "\rPendingSweeps\x12\x1f.walletrpc.PendingSweepsRequest\x1a .walletrpc.PendingSweepsResponse\x12@\n" + + "\rPendingSweeps\x12\x1f.walletrpc.PendingSweepsRequest\x1a .walletrpc.PendingSweepsResponse\x12p\n" + + "\x17RegisterSweepDescriptor\x12).walletrpc.RegisterSweepDescriptorRequest\x1a*.walletrpc.RegisterSweepDescriptorResponse\x12m\n" + + "\x16AddSweepDescriptorData\x12(.walletrpc.AddSweepDescriptorDataRequest\x1a).walletrpc.AddSweepDescriptorDataResponse\x12g\n" + + "\x14ListSweepDescriptors\x12&.walletrpc.ListSweepDescriptorsRequest\x1a'.walletrpc.ListSweepDescriptorsResponse\x12@\n" + "\aBumpFee\x12\x19.walletrpc.BumpFeeRequest\x1a\x1a.walletrpc.BumpFeeResponse\x12^\n" + "\x11BumpForceCloseFee\x12#.walletrpc.BumpForceCloseFeeRequest\x1a$.walletrpc.BumpForceCloseFeeResponse\x12I\n" + "\n" + @@ -5096,190 +5898,212 @@ func file_walletrpc_walletkit_proto_rawDescGZIP() []byte { return file_walletrpc_walletkit_proto_rawDescData } -var file_walletrpc_walletkit_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_walletrpc_walletkit_proto_msgTypes = make([]protoimpl.MessageInfo, 67) +var file_walletrpc_walletkit_proto_enumTypes = make([]protoimpl.EnumInfo, 4) +var file_walletrpc_walletkit_proto_msgTypes = make([]protoimpl.MessageInfo, 75) var file_walletrpc_walletkit_proto_goTypes = []any{ (AddressType)(0), // 0: walletrpc.AddressType (WitnessType)(0), // 1: walletrpc.WitnessType (ChangeAddressType)(0), // 2: walletrpc.ChangeAddressType - (*ListUnspentRequest)(nil), // 3: walletrpc.ListUnspentRequest - (*ListUnspentResponse)(nil), // 4: walletrpc.ListUnspentResponse - (*LeaseOutputRequest)(nil), // 5: walletrpc.LeaseOutputRequest - (*LeaseOutputResponse)(nil), // 6: walletrpc.LeaseOutputResponse - (*ReleaseOutputRequest)(nil), // 7: walletrpc.ReleaseOutputRequest - (*ReleaseOutputResponse)(nil), // 8: walletrpc.ReleaseOutputResponse - (*KeyReq)(nil), // 9: walletrpc.KeyReq - (*AddrRequest)(nil), // 10: walletrpc.AddrRequest - (*AddrResponse)(nil), // 11: walletrpc.AddrResponse - (*Account)(nil), // 12: walletrpc.Account - (*AddressProperty)(nil), // 13: walletrpc.AddressProperty - (*AccountWithAddresses)(nil), // 14: walletrpc.AccountWithAddresses - (*ListAccountsRequest)(nil), // 15: walletrpc.ListAccountsRequest - (*ListAccountsResponse)(nil), // 16: walletrpc.ListAccountsResponse - (*RequiredReserveRequest)(nil), // 17: walletrpc.RequiredReserveRequest - (*RequiredReserveResponse)(nil), // 18: walletrpc.RequiredReserveResponse - (*ListAddressesRequest)(nil), // 19: walletrpc.ListAddressesRequest - (*ListAddressesResponse)(nil), // 20: walletrpc.ListAddressesResponse - (*GetTransactionRequest)(nil), // 21: walletrpc.GetTransactionRequest - (*SignMessageWithAddrRequest)(nil), // 22: walletrpc.SignMessageWithAddrRequest - (*SignMessageWithAddrResponse)(nil), // 23: walletrpc.SignMessageWithAddrResponse - (*VerifyMessageWithAddrRequest)(nil), // 24: walletrpc.VerifyMessageWithAddrRequest - (*VerifyMessageWithAddrResponse)(nil), // 25: walletrpc.VerifyMessageWithAddrResponse - (*ImportAccountRequest)(nil), // 26: walletrpc.ImportAccountRequest - (*ImportAccountResponse)(nil), // 27: walletrpc.ImportAccountResponse - (*ImportPublicKeyRequest)(nil), // 28: walletrpc.ImportPublicKeyRequest - (*ImportPublicKeyResponse)(nil), // 29: walletrpc.ImportPublicKeyResponse - (*ImportTapscriptRequest)(nil), // 30: walletrpc.ImportTapscriptRequest - (*TapscriptFullTree)(nil), // 31: walletrpc.TapscriptFullTree - (*TapLeaf)(nil), // 32: walletrpc.TapLeaf - (*TapscriptPartialReveal)(nil), // 33: walletrpc.TapscriptPartialReveal - (*ImportTapscriptResponse)(nil), // 34: walletrpc.ImportTapscriptResponse - (*Transaction)(nil), // 35: walletrpc.Transaction - (*PublishResponse)(nil), // 36: walletrpc.PublishResponse - (*SubmitPackageRequest)(nil), // 37: walletrpc.SubmitPackageRequest - (*SubmitPackageTxResult)(nil), // 38: walletrpc.SubmitPackageTxResult - (*SubmitPackageResponse)(nil), // 39: walletrpc.SubmitPackageResponse - (*RemoveTransactionResponse)(nil), // 40: walletrpc.RemoveTransactionResponse - (*SendOutputsRequest)(nil), // 41: walletrpc.SendOutputsRequest - (*SendOutputsResponse)(nil), // 42: walletrpc.SendOutputsResponse - (*EstimateFeeRequest)(nil), // 43: walletrpc.EstimateFeeRequest - (*EstimateFeeResponse)(nil), // 44: walletrpc.EstimateFeeResponse - (*PendingSweep)(nil), // 45: walletrpc.PendingSweep - (*PendingSweepsRequest)(nil), // 46: walletrpc.PendingSweepsRequest - (*PendingSweepsResponse)(nil), // 47: walletrpc.PendingSweepsResponse - (*BumpFeeRequest)(nil), // 48: walletrpc.BumpFeeRequest - (*BumpFeeResponse)(nil), // 49: walletrpc.BumpFeeResponse - (*BumpForceCloseFeeRequest)(nil), // 50: walletrpc.BumpForceCloseFeeRequest - (*BumpForceCloseFeeResponse)(nil), // 51: walletrpc.BumpForceCloseFeeResponse - (*ListSweepsRequest)(nil), // 52: walletrpc.ListSweepsRequest - (*ListSweepsResponse)(nil), // 53: walletrpc.ListSweepsResponse - (*LabelTransactionRequest)(nil), // 54: walletrpc.LabelTransactionRequest - (*LabelTransactionResponse)(nil), // 55: walletrpc.LabelTransactionResponse - (*FundPsbtRequest)(nil), // 56: walletrpc.FundPsbtRequest - (*FundPsbtResponse)(nil), // 57: walletrpc.FundPsbtResponse - (*TxTemplate)(nil), // 58: walletrpc.TxTemplate - (*PsbtCoinSelect)(nil), // 59: walletrpc.PsbtCoinSelect - (*UtxoLease)(nil), // 60: walletrpc.UtxoLease - (*SignPsbtRequest)(nil), // 61: walletrpc.SignPsbtRequest - (*SignPsbtResponse)(nil), // 62: walletrpc.SignPsbtResponse - (*FinalizePsbtRequest)(nil), // 63: walletrpc.FinalizePsbtRequest - (*FinalizePsbtResponse)(nil), // 64: walletrpc.FinalizePsbtResponse - (*ListLeasesRequest)(nil), // 65: walletrpc.ListLeasesRequest - (*ListLeasesResponse)(nil), // 66: walletrpc.ListLeasesResponse - nil, // 67: walletrpc.SubmitPackageResponse.TxResultsEntry - (*ListSweepsResponse_TransactionIDs)(nil), // 68: walletrpc.ListSweepsResponse.TransactionIDs - nil, // 69: walletrpc.TxTemplate.OutputsEntry - (*lnrpc.Utxo)(nil), // 70: lnrpc.Utxo - (*lnrpc.OutPoint)(nil), // 71: lnrpc.OutPoint - (*signrpc.TxOut)(nil), // 72: signrpc.TxOut - (lnrpc.CoinSelectionStrategy)(0), // 73: lnrpc.CoinSelectionStrategy - (*lnrpc.ChannelPoint)(nil), // 74: lnrpc.ChannelPoint - (*lnrpc.TransactionDetails)(nil), // 75: lnrpc.TransactionDetails - (*signrpc.KeyLocator)(nil), // 76: signrpc.KeyLocator - (*signrpc.KeyDescriptor)(nil), // 77: signrpc.KeyDescriptor - (*lnrpc.Transaction)(nil), // 78: lnrpc.Transaction + (SweepDescriptorState)(0), // 3: walletrpc.SweepDescriptorState + (*ListUnspentRequest)(nil), // 4: walletrpc.ListUnspentRequest + (*ListUnspentResponse)(nil), // 5: walletrpc.ListUnspentResponse + (*LeaseOutputRequest)(nil), // 6: walletrpc.LeaseOutputRequest + (*LeaseOutputResponse)(nil), // 7: walletrpc.LeaseOutputResponse + (*ReleaseOutputRequest)(nil), // 8: walletrpc.ReleaseOutputRequest + (*ReleaseOutputResponse)(nil), // 9: walletrpc.ReleaseOutputResponse + (*KeyReq)(nil), // 10: walletrpc.KeyReq + (*AddrRequest)(nil), // 11: walletrpc.AddrRequest + (*AddrResponse)(nil), // 12: walletrpc.AddrResponse + (*Account)(nil), // 13: walletrpc.Account + (*AddressProperty)(nil), // 14: walletrpc.AddressProperty + (*AccountWithAddresses)(nil), // 15: walletrpc.AccountWithAddresses + (*ListAccountsRequest)(nil), // 16: walletrpc.ListAccountsRequest + (*ListAccountsResponse)(nil), // 17: walletrpc.ListAccountsResponse + (*RequiredReserveRequest)(nil), // 18: walletrpc.RequiredReserveRequest + (*RequiredReserveResponse)(nil), // 19: walletrpc.RequiredReserveResponse + (*ListAddressesRequest)(nil), // 20: walletrpc.ListAddressesRequest + (*ListAddressesResponse)(nil), // 21: walletrpc.ListAddressesResponse + (*GetTransactionRequest)(nil), // 22: walletrpc.GetTransactionRequest + (*SignMessageWithAddrRequest)(nil), // 23: walletrpc.SignMessageWithAddrRequest + (*SignMessageWithAddrResponse)(nil), // 24: walletrpc.SignMessageWithAddrResponse + (*VerifyMessageWithAddrRequest)(nil), // 25: walletrpc.VerifyMessageWithAddrRequest + (*VerifyMessageWithAddrResponse)(nil), // 26: walletrpc.VerifyMessageWithAddrResponse + (*ImportAccountRequest)(nil), // 27: walletrpc.ImportAccountRequest + (*ImportAccountResponse)(nil), // 28: walletrpc.ImportAccountResponse + (*ImportPublicKeyRequest)(nil), // 29: walletrpc.ImportPublicKeyRequest + (*ImportPublicKeyResponse)(nil), // 30: walletrpc.ImportPublicKeyResponse + (*ImportTapscriptRequest)(nil), // 31: walletrpc.ImportTapscriptRequest + (*TapscriptFullTree)(nil), // 32: walletrpc.TapscriptFullTree + (*TapLeaf)(nil), // 33: walletrpc.TapLeaf + (*TapscriptPartialReveal)(nil), // 34: walletrpc.TapscriptPartialReveal + (*ImportTapscriptResponse)(nil), // 35: walletrpc.ImportTapscriptResponse + (*Transaction)(nil), // 36: walletrpc.Transaction + (*PublishResponse)(nil), // 37: walletrpc.PublishResponse + (*SubmitPackageRequest)(nil), // 38: walletrpc.SubmitPackageRequest + (*SubmitPackageTxResult)(nil), // 39: walletrpc.SubmitPackageTxResult + (*SubmitPackageResponse)(nil), // 40: walletrpc.SubmitPackageResponse + (*RemoveTransactionResponse)(nil), // 41: walletrpc.RemoveTransactionResponse + (*SendOutputsRequest)(nil), // 42: walletrpc.SendOutputsRequest + (*SendOutputsResponse)(nil), // 43: walletrpc.SendOutputsResponse + (*EstimateFeeRequest)(nil), // 44: walletrpc.EstimateFeeRequest + (*EstimateFeeResponse)(nil), // 45: walletrpc.EstimateFeeResponse + (*PendingSweep)(nil), // 46: walletrpc.PendingSweep + (*PendingSweepsRequest)(nil), // 47: walletrpc.PendingSweepsRequest + (*PendingSweepsResponse)(nil), // 48: walletrpc.PendingSweepsResponse + (*BumpFeeRequest)(nil), // 49: walletrpc.BumpFeeRequest + (*BumpFeeResponse)(nil), // 50: walletrpc.BumpFeeResponse + (*BumpForceCloseFeeRequest)(nil), // 51: walletrpc.BumpForceCloseFeeRequest + (*BumpForceCloseFeeResponse)(nil), // 52: walletrpc.BumpForceCloseFeeResponse + (*ListSweepsRequest)(nil), // 53: walletrpc.ListSweepsRequest + (*ListSweepsResponse)(nil), // 54: walletrpc.ListSweepsResponse + (*LabelTransactionRequest)(nil), // 55: walletrpc.LabelTransactionRequest + (*LabelTransactionResponse)(nil), // 56: walletrpc.LabelTransactionResponse + (*FundPsbtRequest)(nil), // 57: walletrpc.FundPsbtRequest + (*FundPsbtResponse)(nil), // 58: walletrpc.FundPsbtResponse + (*TxTemplate)(nil), // 59: walletrpc.TxTemplate + (*PsbtCoinSelect)(nil), // 60: walletrpc.PsbtCoinSelect + (*UtxoLease)(nil), // 61: walletrpc.UtxoLease + (*SignPsbtRequest)(nil), // 62: walletrpc.SignPsbtRequest + (*SignPsbtResponse)(nil), // 63: walletrpc.SignPsbtResponse + (*FinalizePsbtRequest)(nil), // 64: walletrpc.FinalizePsbtRequest + (*FinalizePsbtResponse)(nil), // 65: walletrpc.FinalizePsbtResponse + (*ListLeasesRequest)(nil), // 66: walletrpc.ListLeasesRequest + (*ListLeasesResponse)(nil), // 67: walletrpc.ListLeasesResponse + (*SweepDescriptorKeyBinding)(nil), // 68: walletrpc.SweepDescriptorKeyBinding + (*RegisterSweepDescriptorRequest)(nil), // 69: walletrpc.RegisterSweepDescriptorRequest + (*RegisterSweepDescriptorResponse)(nil), // 70: walletrpc.RegisterSweepDescriptorResponse + (*AddSweepDescriptorDataRequest)(nil), // 71: walletrpc.AddSweepDescriptorDataRequest + (*AddSweepDescriptorDataResponse)(nil), // 72: walletrpc.AddSweepDescriptorDataResponse + (*ListSweepDescriptorsRequest)(nil), // 73: walletrpc.ListSweepDescriptorsRequest + (*ListSweepDescriptorsResponse)(nil), // 74: walletrpc.ListSweepDescriptorsResponse + (*SweepDescriptorStatus)(nil), // 75: walletrpc.SweepDescriptorStatus + nil, // 76: walletrpc.SubmitPackageResponse.TxResultsEntry + (*ListSweepsResponse_TransactionIDs)(nil), // 77: walletrpc.ListSweepsResponse.TransactionIDs + nil, // 78: walletrpc.TxTemplate.OutputsEntry + (*lnrpc.Utxo)(nil), // 79: lnrpc.Utxo + (*lnrpc.OutPoint)(nil), // 80: lnrpc.OutPoint + (*signrpc.TxOut)(nil), // 81: signrpc.TxOut + (lnrpc.CoinSelectionStrategy)(0), // 82: lnrpc.CoinSelectionStrategy + (*lnrpc.ChannelPoint)(nil), // 83: lnrpc.ChannelPoint + (*lnrpc.TransactionDetails)(nil), // 84: lnrpc.TransactionDetails + (*signrpc.KeyLocator)(nil), // 85: signrpc.KeyLocator + (*signrpc.KeyDescriptor)(nil), // 86: signrpc.KeyDescriptor + (*lnrpc.Transaction)(nil), // 87: lnrpc.Transaction } var file_walletrpc_walletkit_proto_depIdxs = []int32{ - 70, // 0: walletrpc.ListUnspentResponse.utxos:type_name -> lnrpc.Utxo - 71, // 1: walletrpc.LeaseOutputRequest.outpoint:type_name -> lnrpc.OutPoint - 71, // 2: walletrpc.ReleaseOutputRequest.outpoint:type_name -> lnrpc.OutPoint + 79, // 0: walletrpc.ListUnspentResponse.utxos:type_name -> lnrpc.Utxo + 80, // 1: walletrpc.LeaseOutputRequest.outpoint:type_name -> lnrpc.OutPoint + 80, // 2: walletrpc.ReleaseOutputRequest.outpoint:type_name -> lnrpc.OutPoint 0, // 3: walletrpc.AddrRequest.type:type_name -> walletrpc.AddressType 0, // 4: walletrpc.Account.address_type:type_name -> walletrpc.AddressType 0, // 5: walletrpc.AccountWithAddresses.address_type:type_name -> walletrpc.AddressType - 13, // 6: walletrpc.AccountWithAddresses.addresses:type_name -> walletrpc.AddressProperty + 14, // 6: walletrpc.AccountWithAddresses.addresses:type_name -> walletrpc.AddressProperty 0, // 7: walletrpc.ListAccountsRequest.address_type:type_name -> walletrpc.AddressType - 12, // 8: walletrpc.ListAccountsResponse.accounts:type_name -> walletrpc.Account - 14, // 9: walletrpc.ListAddressesResponse.account_with_addresses:type_name -> walletrpc.AccountWithAddresses + 13, // 8: walletrpc.ListAccountsResponse.accounts:type_name -> walletrpc.Account + 15, // 9: walletrpc.ListAddressesResponse.account_with_addresses:type_name -> walletrpc.AccountWithAddresses 0, // 10: walletrpc.ImportAccountRequest.address_type:type_name -> walletrpc.AddressType - 12, // 11: walletrpc.ImportAccountResponse.account:type_name -> walletrpc.Account + 13, // 11: walletrpc.ImportAccountResponse.account:type_name -> walletrpc.Account 0, // 12: walletrpc.ImportPublicKeyRequest.address_type:type_name -> walletrpc.AddressType - 31, // 13: walletrpc.ImportTapscriptRequest.full_tree:type_name -> walletrpc.TapscriptFullTree - 33, // 14: walletrpc.ImportTapscriptRequest.partial_reveal:type_name -> walletrpc.TapscriptPartialReveal - 32, // 15: walletrpc.TapscriptFullTree.all_leaves:type_name -> walletrpc.TapLeaf - 32, // 16: walletrpc.TapscriptPartialReveal.revealed_leaf:type_name -> walletrpc.TapLeaf - 67, // 17: walletrpc.SubmitPackageResponse.tx_results:type_name -> walletrpc.SubmitPackageResponse.TxResultsEntry - 72, // 18: walletrpc.SendOutputsRequest.outputs:type_name -> signrpc.TxOut - 73, // 19: walletrpc.SendOutputsRequest.coin_selection_strategy:type_name -> lnrpc.CoinSelectionStrategy - 71, // 20: walletrpc.PendingSweep.outpoint:type_name -> lnrpc.OutPoint + 32, // 13: walletrpc.ImportTapscriptRequest.full_tree:type_name -> walletrpc.TapscriptFullTree + 34, // 14: walletrpc.ImportTapscriptRequest.partial_reveal:type_name -> walletrpc.TapscriptPartialReveal + 33, // 15: walletrpc.TapscriptFullTree.all_leaves:type_name -> walletrpc.TapLeaf + 33, // 16: walletrpc.TapscriptPartialReveal.revealed_leaf:type_name -> walletrpc.TapLeaf + 76, // 17: walletrpc.SubmitPackageResponse.tx_results:type_name -> walletrpc.SubmitPackageResponse.TxResultsEntry + 81, // 18: walletrpc.SendOutputsRequest.outputs:type_name -> signrpc.TxOut + 82, // 19: walletrpc.SendOutputsRequest.coin_selection_strategy:type_name -> lnrpc.CoinSelectionStrategy + 80, // 20: walletrpc.PendingSweep.outpoint:type_name -> lnrpc.OutPoint 1, // 21: walletrpc.PendingSweep.witness_type:type_name -> walletrpc.WitnessType - 45, // 22: walletrpc.PendingSweepsResponse.pending_sweeps:type_name -> walletrpc.PendingSweep - 71, // 23: walletrpc.BumpFeeRequest.outpoint:type_name -> lnrpc.OutPoint - 74, // 24: walletrpc.BumpForceCloseFeeRequest.chan_point:type_name -> lnrpc.ChannelPoint - 75, // 25: walletrpc.ListSweepsResponse.transaction_details:type_name -> lnrpc.TransactionDetails - 68, // 26: walletrpc.ListSweepsResponse.transaction_ids:type_name -> walletrpc.ListSweepsResponse.TransactionIDs - 58, // 27: walletrpc.FundPsbtRequest.raw:type_name -> walletrpc.TxTemplate - 59, // 28: walletrpc.FundPsbtRequest.coin_select:type_name -> walletrpc.PsbtCoinSelect + 46, // 22: walletrpc.PendingSweepsResponse.pending_sweeps:type_name -> walletrpc.PendingSweep + 80, // 23: walletrpc.BumpFeeRequest.outpoint:type_name -> lnrpc.OutPoint + 83, // 24: walletrpc.BumpForceCloseFeeRequest.chan_point:type_name -> lnrpc.ChannelPoint + 84, // 25: walletrpc.ListSweepsResponse.transaction_details:type_name -> lnrpc.TransactionDetails + 77, // 26: walletrpc.ListSweepsResponse.transaction_ids:type_name -> walletrpc.ListSweepsResponse.TransactionIDs + 59, // 27: walletrpc.FundPsbtRequest.raw:type_name -> walletrpc.TxTemplate + 60, // 28: walletrpc.FundPsbtRequest.coin_select:type_name -> walletrpc.PsbtCoinSelect 2, // 29: walletrpc.FundPsbtRequest.change_type:type_name -> walletrpc.ChangeAddressType - 73, // 30: walletrpc.FundPsbtRequest.coin_selection_strategy:type_name -> lnrpc.CoinSelectionStrategy - 60, // 31: walletrpc.FundPsbtResponse.locked_utxos:type_name -> walletrpc.UtxoLease - 71, // 32: walletrpc.TxTemplate.inputs:type_name -> lnrpc.OutPoint - 69, // 33: walletrpc.TxTemplate.outputs:type_name -> walletrpc.TxTemplate.OutputsEntry - 71, // 34: walletrpc.UtxoLease.outpoint:type_name -> lnrpc.OutPoint - 60, // 35: walletrpc.ListLeasesResponse.locked_utxos:type_name -> walletrpc.UtxoLease - 38, // 36: walletrpc.SubmitPackageResponse.TxResultsEntry.value:type_name -> walletrpc.SubmitPackageTxResult - 3, // 37: walletrpc.WalletKit.ListUnspent:input_type -> walletrpc.ListUnspentRequest - 5, // 38: walletrpc.WalletKit.LeaseOutput:input_type -> walletrpc.LeaseOutputRequest - 7, // 39: walletrpc.WalletKit.ReleaseOutput:input_type -> walletrpc.ReleaseOutputRequest - 65, // 40: walletrpc.WalletKit.ListLeases:input_type -> walletrpc.ListLeasesRequest - 9, // 41: walletrpc.WalletKit.DeriveNextKey:input_type -> walletrpc.KeyReq - 76, // 42: walletrpc.WalletKit.DeriveKey:input_type -> signrpc.KeyLocator - 10, // 43: walletrpc.WalletKit.NextAddr:input_type -> walletrpc.AddrRequest - 21, // 44: walletrpc.WalletKit.GetTransaction:input_type -> walletrpc.GetTransactionRequest - 15, // 45: walletrpc.WalletKit.ListAccounts:input_type -> walletrpc.ListAccountsRequest - 17, // 46: walletrpc.WalletKit.RequiredReserve:input_type -> walletrpc.RequiredReserveRequest - 19, // 47: walletrpc.WalletKit.ListAddresses:input_type -> walletrpc.ListAddressesRequest - 22, // 48: walletrpc.WalletKit.SignMessageWithAddr:input_type -> walletrpc.SignMessageWithAddrRequest - 24, // 49: walletrpc.WalletKit.VerifyMessageWithAddr:input_type -> walletrpc.VerifyMessageWithAddrRequest - 26, // 50: walletrpc.WalletKit.ImportAccount:input_type -> walletrpc.ImportAccountRequest - 28, // 51: walletrpc.WalletKit.ImportPublicKey:input_type -> walletrpc.ImportPublicKeyRequest - 30, // 52: walletrpc.WalletKit.ImportTapscript:input_type -> walletrpc.ImportTapscriptRequest - 35, // 53: walletrpc.WalletKit.PublishTransaction:input_type -> walletrpc.Transaction - 37, // 54: walletrpc.WalletKit.SubmitPackage:input_type -> walletrpc.SubmitPackageRequest - 21, // 55: walletrpc.WalletKit.RemoveTransaction:input_type -> walletrpc.GetTransactionRequest - 41, // 56: walletrpc.WalletKit.SendOutputs:input_type -> walletrpc.SendOutputsRequest - 43, // 57: walletrpc.WalletKit.EstimateFee:input_type -> walletrpc.EstimateFeeRequest - 46, // 58: walletrpc.WalletKit.PendingSweeps:input_type -> walletrpc.PendingSweepsRequest - 48, // 59: walletrpc.WalletKit.BumpFee:input_type -> walletrpc.BumpFeeRequest - 50, // 60: walletrpc.WalletKit.BumpForceCloseFee:input_type -> walletrpc.BumpForceCloseFeeRequest - 52, // 61: walletrpc.WalletKit.ListSweeps:input_type -> walletrpc.ListSweepsRequest - 54, // 62: walletrpc.WalletKit.LabelTransaction:input_type -> walletrpc.LabelTransactionRequest - 56, // 63: walletrpc.WalletKit.FundPsbt:input_type -> walletrpc.FundPsbtRequest - 61, // 64: walletrpc.WalletKit.SignPsbt:input_type -> walletrpc.SignPsbtRequest - 63, // 65: walletrpc.WalletKit.FinalizePsbt:input_type -> walletrpc.FinalizePsbtRequest - 4, // 66: walletrpc.WalletKit.ListUnspent:output_type -> walletrpc.ListUnspentResponse - 6, // 67: walletrpc.WalletKit.LeaseOutput:output_type -> walletrpc.LeaseOutputResponse - 8, // 68: walletrpc.WalletKit.ReleaseOutput:output_type -> walletrpc.ReleaseOutputResponse - 66, // 69: walletrpc.WalletKit.ListLeases:output_type -> walletrpc.ListLeasesResponse - 77, // 70: walletrpc.WalletKit.DeriveNextKey:output_type -> signrpc.KeyDescriptor - 77, // 71: walletrpc.WalletKit.DeriveKey:output_type -> signrpc.KeyDescriptor - 11, // 72: walletrpc.WalletKit.NextAddr:output_type -> walletrpc.AddrResponse - 78, // 73: walletrpc.WalletKit.GetTransaction:output_type -> lnrpc.Transaction - 16, // 74: walletrpc.WalletKit.ListAccounts:output_type -> walletrpc.ListAccountsResponse - 18, // 75: walletrpc.WalletKit.RequiredReserve:output_type -> walletrpc.RequiredReserveResponse - 20, // 76: walletrpc.WalletKit.ListAddresses:output_type -> walletrpc.ListAddressesResponse - 23, // 77: walletrpc.WalletKit.SignMessageWithAddr:output_type -> walletrpc.SignMessageWithAddrResponse - 25, // 78: walletrpc.WalletKit.VerifyMessageWithAddr:output_type -> walletrpc.VerifyMessageWithAddrResponse - 27, // 79: walletrpc.WalletKit.ImportAccount:output_type -> walletrpc.ImportAccountResponse - 29, // 80: walletrpc.WalletKit.ImportPublicKey:output_type -> walletrpc.ImportPublicKeyResponse - 34, // 81: walletrpc.WalletKit.ImportTapscript:output_type -> walletrpc.ImportTapscriptResponse - 36, // 82: walletrpc.WalletKit.PublishTransaction:output_type -> walletrpc.PublishResponse - 39, // 83: walletrpc.WalletKit.SubmitPackage:output_type -> walletrpc.SubmitPackageResponse - 40, // 84: walletrpc.WalletKit.RemoveTransaction:output_type -> walletrpc.RemoveTransactionResponse - 42, // 85: walletrpc.WalletKit.SendOutputs:output_type -> walletrpc.SendOutputsResponse - 44, // 86: walletrpc.WalletKit.EstimateFee:output_type -> walletrpc.EstimateFeeResponse - 47, // 87: walletrpc.WalletKit.PendingSweeps:output_type -> walletrpc.PendingSweepsResponse - 49, // 88: walletrpc.WalletKit.BumpFee:output_type -> walletrpc.BumpFeeResponse - 51, // 89: walletrpc.WalletKit.BumpForceCloseFee:output_type -> walletrpc.BumpForceCloseFeeResponse - 53, // 90: walletrpc.WalletKit.ListSweeps:output_type -> walletrpc.ListSweepsResponse - 55, // 91: walletrpc.WalletKit.LabelTransaction:output_type -> walletrpc.LabelTransactionResponse - 57, // 92: walletrpc.WalletKit.FundPsbt:output_type -> walletrpc.FundPsbtResponse - 62, // 93: walletrpc.WalletKit.SignPsbt:output_type -> walletrpc.SignPsbtResponse - 64, // 94: walletrpc.WalletKit.FinalizePsbt:output_type -> walletrpc.FinalizePsbtResponse - 66, // [66:95] is the sub-list for method output_type - 37, // [37:66] is the sub-list for method input_type - 37, // [37:37] is the sub-list for extension type_name - 37, // [37:37] is the sub-list for extension extendee - 0, // [0:37] is the sub-list for field type_name + 82, // 30: walletrpc.FundPsbtRequest.coin_selection_strategy:type_name -> lnrpc.CoinSelectionStrategy + 61, // 31: walletrpc.FundPsbtResponse.locked_utxos:type_name -> walletrpc.UtxoLease + 80, // 32: walletrpc.TxTemplate.inputs:type_name -> lnrpc.OutPoint + 78, // 33: walletrpc.TxTemplate.outputs:type_name -> walletrpc.TxTemplate.OutputsEntry + 80, // 34: walletrpc.UtxoLease.outpoint:type_name -> lnrpc.OutPoint + 61, // 35: walletrpc.ListLeasesResponse.locked_utxos:type_name -> walletrpc.UtxoLease + 85, // 36: walletrpc.SweepDescriptorKeyBinding.key_locator:type_name -> signrpc.KeyLocator + 68, // 37: walletrpc.RegisterSweepDescriptorRequest.key_bindings:type_name -> walletrpc.SweepDescriptorKeyBinding + 75, // 38: walletrpc.AddSweepDescriptorDataResponse.status:type_name -> walletrpc.SweepDescriptorStatus + 75, // 39: walletrpc.ListSweepDescriptorsResponse.descriptors:type_name -> walletrpc.SweepDescriptorStatus + 3, // 40: walletrpc.SweepDescriptorStatus.state:type_name -> walletrpc.SweepDescriptorState + 80, // 41: walletrpc.SweepDescriptorStatus.outpoint:type_name -> lnrpc.OutPoint + 68, // 42: walletrpc.SweepDescriptorStatus.key_bindings:type_name -> walletrpc.SweepDescriptorKeyBinding + 39, // 43: walletrpc.SubmitPackageResponse.TxResultsEntry.value:type_name -> walletrpc.SubmitPackageTxResult + 4, // 44: walletrpc.WalletKit.ListUnspent:input_type -> walletrpc.ListUnspentRequest + 6, // 45: walletrpc.WalletKit.LeaseOutput:input_type -> walletrpc.LeaseOutputRequest + 8, // 46: walletrpc.WalletKit.ReleaseOutput:input_type -> walletrpc.ReleaseOutputRequest + 66, // 47: walletrpc.WalletKit.ListLeases:input_type -> walletrpc.ListLeasesRequest + 10, // 48: walletrpc.WalletKit.DeriveNextKey:input_type -> walletrpc.KeyReq + 85, // 49: walletrpc.WalletKit.DeriveKey:input_type -> signrpc.KeyLocator + 11, // 50: walletrpc.WalletKit.NextAddr:input_type -> walletrpc.AddrRequest + 22, // 51: walletrpc.WalletKit.GetTransaction:input_type -> walletrpc.GetTransactionRequest + 16, // 52: walletrpc.WalletKit.ListAccounts:input_type -> walletrpc.ListAccountsRequest + 18, // 53: walletrpc.WalletKit.RequiredReserve:input_type -> walletrpc.RequiredReserveRequest + 20, // 54: walletrpc.WalletKit.ListAddresses:input_type -> walletrpc.ListAddressesRequest + 23, // 55: walletrpc.WalletKit.SignMessageWithAddr:input_type -> walletrpc.SignMessageWithAddrRequest + 25, // 56: walletrpc.WalletKit.VerifyMessageWithAddr:input_type -> walletrpc.VerifyMessageWithAddrRequest + 27, // 57: walletrpc.WalletKit.ImportAccount:input_type -> walletrpc.ImportAccountRequest + 29, // 58: walletrpc.WalletKit.ImportPublicKey:input_type -> walletrpc.ImportPublicKeyRequest + 31, // 59: walletrpc.WalletKit.ImportTapscript:input_type -> walletrpc.ImportTapscriptRequest + 36, // 60: walletrpc.WalletKit.PublishTransaction:input_type -> walletrpc.Transaction + 38, // 61: walletrpc.WalletKit.SubmitPackage:input_type -> walletrpc.SubmitPackageRequest + 22, // 62: walletrpc.WalletKit.RemoveTransaction:input_type -> walletrpc.GetTransactionRequest + 42, // 63: walletrpc.WalletKit.SendOutputs:input_type -> walletrpc.SendOutputsRequest + 44, // 64: walletrpc.WalletKit.EstimateFee:input_type -> walletrpc.EstimateFeeRequest + 47, // 65: walletrpc.WalletKit.PendingSweeps:input_type -> walletrpc.PendingSweepsRequest + 69, // 66: walletrpc.WalletKit.RegisterSweepDescriptor:input_type -> walletrpc.RegisterSweepDescriptorRequest + 71, // 67: walletrpc.WalletKit.AddSweepDescriptorData:input_type -> walletrpc.AddSweepDescriptorDataRequest + 73, // 68: walletrpc.WalletKit.ListSweepDescriptors:input_type -> walletrpc.ListSweepDescriptorsRequest + 49, // 69: walletrpc.WalletKit.BumpFee:input_type -> walletrpc.BumpFeeRequest + 51, // 70: walletrpc.WalletKit.BumpForceCloseFee:input_type -> walletrpc.BumpForceCloseFeeRequest + 53, // 71: walletrpc.WalletKit.ListSweeps:input_type -> walletrpc.ListSweepsRequest + 55, // 72: walletrpc.WalletKit.LabelTransaction:input_type -> walletrpc.LabelTransactionRequest + 57, // 73: walletrpc.WalletKit.FundPsbt:input_type -> walletrpc.FundPsbtRequest + 62, // 74: walletrpc.WalletKit.SignPsbt:input_type -> walletrpc.SignPsbtRequest + 64, // 75: walletrpc.WalletKit.FinalizePsbt:input_type -> walletrpc.FinalizePsbtRequest + 5, // 76: walletrpc.WalletKit.ListUnspent:output_type -> walletrpc.ListUnspentResponse + 7, // 77: walletrpc.WalletKit.LeaseOutput:output_type -> walletrpc.LeaseOutputResponse + 9, // 78: walletrpc.WalletKit.ReleaseOutput:output_type -> walletrpc.ReleaseOutputResponse + 67, // 79: walletrpc.WalletKit.ListLeases:output_type -> walletrpc.ListLeasesResponse + 86, // 80: walletrpc.WalletKit.DeriveNextKey:output_type -> signrpc.KeyDescriptor + 86, // 81: walletrpc.WalletKit.DeriveKey:output_type -> signrpc.KeyDescriptor + 12, // 82: walletrpc.WalletKit.NextAddr:output_type -> walletrpc.AddrResponse + 87, // 83: walletrpc.WalletKit.GetTransaction:output_type -> lnrpc.Transaction + 17, // 84: walletrpc.WalletKit.ListAccounts:output_type -> walletrpc.ListAccountsResponse + 19, // 85: walletrpc.WalletKit.RequiredReserve:output_type -> walletrpc.RequiredReserveResponse + 21, // 86: walletrpc.WalletKit.ListAddresses:output_type -> walletrpc.ListAddressesResponse + 24, // 87: walletrpc.WalletKit.SignMessageWithAddr:output_type -> walletrpc.SignMessageWithAddrResponse + 26, // 88: walletrpc.WalletKit.VerifyMessageWithAddr:output_type -> walletrpc.VerifyMessageWithAddrResponse + 28, // 89: walletrpc.WalletKit.ImportAccount:output_type -> walletrpc.ImportAccountResponse + 30, // 90: walletrpc.WalletKit.ImportPublicKey:output_type -> walletrpc.ImportPublicKeyResponse + 35, // 91: walletrpc.WalletKit.ImportTapscript:output_type -> walletrpc.ImportTapscriptResponse + 37, // 92: walletrpc.WalletKit.PublishTransaction:output_type -> walletrpc.PublishResponse + 40, // 93: walletrpc.WalletKit.SubmitPackage:output_type -> walletrpc.SubmitPackageResponse + 41, // 94: walletrpc.WalletKit.RemoveTransaction:output_type -> walletrpc.RemoveTransactionResponse + 43, // 95: walletrpc.WalletKit.SendOutputs:output_type -> walletrpc.SendOutputsResponse + 45, // 96: walletrpc.WalletKit.EstimateFee:output_type -> walletrpc.EstimateFeeResponse + 48, // 97: walletrpc.WalletKit.PendingSweeps:output_type -> walletrpc.PendingSweepsResponse + 70, // 98: walletrpc.WalletKit.RegisterSweepDescriptor:output_type -> walletrpc.RegisterSweepDescriptorResponse + 72, // 99: walletrpc.WalletKit.AddSweepDescriptorData:output_type -> walletrpc.AddSweepDescriptorDataResponse + 74, // 100: walletrpc.WalletKit.ListSweepDescriptors:output_type -> walletrpc.ListSweepDescriptorsResponse + 50, // 101: walletrpc.WalletKit.BumpFee:output_type -> walletrpc.BumpFeeResponse + 52, // 102: walletrpc.WalletKit.BumpForceCloseFee:output_type -> walletrpc.BumpForceCloseFeeResponse + 54, // 103: walletrpc.WalletKit.ListSweeps:output_type -> walletrpc.ListSweepsResponse + 56, // 104: walletrpc.WalletKit.LabelTransaction:output_type -> walletrpc.LabelTransactionResponse + 58, // 105: walletrpc.WalletKit.FundPsbt:output_type -> walletrpc.FundPsbtResponse + 63, // 106: walletrpc.WalletKit.SignPsbt:output_type -> walletrpc.SignPsbtResponse + 65, // 107: walletrpc.WalletKit.FinalizePsbt:output_type -> walletrpc.FinalizePsbtResponse + 76, // [76:108] is the sub-list for method output_type + 44, // [44:76] is the sub-list for method input_type + 44, // [44:44] is the sub-list for extension type_name + 44, // [44:44] is the sub-list for extension extendee + 0, // [0:44] is the sub-list for field type_name } func init() { file_walletrpc_walletkit_proto_init() } @@ -5310,13 +6134,16 @@ func file_walletrpc_walletkit_proto_init() { (*PsbtCoinSelect_ExistingOutputIndex)(nil), (*PsbtCoinSelect_Add)(nil), } + file_walletrpc_walletkit_proto_msgTypes[67].OneofWrappers = []any{ + (*AddSweepDescriptorDataRequest_Preimage)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_walletrpc_walletkit_proto_rawDesc), len(file_walletrpc_walletkit_proto_rawDesc)), - NumEnums: 3, - NumMessages: 67, + NumEnums: 4, + NumMessages: 75, NumExtensions: 0, NumServices: 1, }, diff --git a/lnrpc/walletrpc/walletkit.pb.gw.go b/lnrpc/walletrpc/walletkit.pb.gw.go index 32cfdc35f9a..1770a3ec304 100644 --- a/lnrpc/walletrpc/walletkit.pb.gw.go +++ b/lnrpc/walletrpc/walletkit.pb.gw.go @@ -774,6 +774,144 @@ func local_request_WalletKit_PendingSweeps_0(ctx context.Context, marshaler runt } +func request_WalletKit_RegisterSweepDescriptor_0(ctx context.Context, marshaler runtime.Marshaler, client WalletKitClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq RegisterSweepDescriptorRequest + var metadata runtime.ServerMetadata + + newReader, berr := utilities.IOReaderFactory(req.Body) + if berr != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr) + } + if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.RegisterSweepDescriptor(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_WalletKit_RegisterSweepDescriptor_0(ctx context.Context, marshaler runtime.Marshaler, server WalletKitServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq RegisterSweepDescriptorRequest + var metadata runtime.ServerMetadata + + newReader, berr := utilities.IOReaderFactory(req.Body) + if berr != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr) + } + if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.RegisterSweepDescriptor(ctx, &protoReq) + return msg, metadata, err + +} + +func request_WalletKit_AddSweepDescriptorData_0(ctx context.Context, marshaler runtime.Marshaler, client WalletKitClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq AddSweepDescriptorDataRequest + var metadata runtime.ServerMetadata + + newReader, berr := utilities.IOReaderFactory(req.Body) + if berr != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr) + } + if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + var ( + val string + ok bool + err error + _ = err + ) + + val, ok = pathParams["registration_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "registration_id") + } + + protoReq.RegistrationId, err = runtime.Bytes(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "registration_id", err) + } + + msg, err := client.AddSweepDescriptorData(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_WalletKit_AddSweepDescriptorData_0(ctx context.Context, marshaler runtime.Marshaler, server WalletKitServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq AddSweepDescriptorDataRequest + var metadata runtime.ServerMetadata + + newReader, berr := utilities.IOReaderFactory(req.Body) + if berr != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr) + } + if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + var ( + val string + ok bool + err error + _ = err + ) + + val, ok = pathParams["registration_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "registration_id") + } + + protoReq.RegistrationId, err = runtime.Bytes(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "registration_id", err) + } + + msg, err := server.AddSweepDescriptorData(ctx, &protoReq) + return msg, metadata, err + +} + +var ( + filter_WalletKit_ListSweepDescriptors_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +) + +func request_WalletKit_ListSweepDescriptors_0(ctx context.Context, marshaler runtime.Marshaler, client WalletKitClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq ListSweepDescriptorsRequest + var metadata runtime.ServerMetadata + + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_WalletKit_ListSweepDescriptors_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.ListSweepDescriptors(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_WalletKit_ListSweepDescriptors_0(ctx context.Context, marshaler runtime.Marshaler, server WalletKitServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq ListSweepDescriptorsRequest + var metadata runtime.ServerMetadata + + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_WalletKit_ListSweepDescriptors_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.ListSweepDescriptors(ctx, &protoReq) + return msg, metadata, err + +} + func request_WalletKit_BumpFee_0(ctx context.Context, marshaler runtime.Marshaler, client WalletKitClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var protoReq BumpFeeRequest var metadata runtime.ServerMetadata @@ -1570,6 +1708,81 @@ func RegisterWalletKitHandlerServer(ctx context.Context, mux *runtime.ServeMux, }) + mux.Handle("POST", pattern_WalletKit_RegisterSweepDescriptor_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/walletrpc.WalletKit/RegisterSweepDescriptor", runtime.WithHTTPPathPattern("/v2/wallet/sweep-descriptors")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_WalletKit_RegisterSweepDescriptor_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_WalletKit_RegisterSweepDescriptor_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_WalletKit_AddSweepDescriptorData_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/walletrpc.WalletKit/AddSweepDescriptorData", runtime.WithHTTPPathPattern("/v2/wallet/sweep-descriptors/{registration_id}/data")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_WalletKit_AddSweepDescriptorData_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_WalletKit_AddSweepDescriptorData_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("GET", pattern_WalletKit_ListSweepDescriptors_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/walletrpc.WalletKit/ListSweepDescriptors", runtime.WithHTTPPathPattern("/v2/wallet/sweep-descriptors")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_WalletKit_ListSweepDescriptors_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_WalletKit_ListSweepDescriptors_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("POST", pattern_WalletKit_BumpFee_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -2270,6 +2483,72 @@ func RegisterWalletKitHandlerClient(ctx context.Context, mux *runtime.ServeMux, }) + mux.Handle("POST", pattern_WalletKit_RegisterSweepDescriptor_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/walletrpc.WalletKit/RegisterSweepDescriptor", runtime.WithHTTPPathPattern("/v2/wallet/sweep-descriptors")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_WalletKit_RegisterSweepDescriptor_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_WalletKit_RegisterSweepDescriptor_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_WalletKit_AddSweepDescriptorData_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/walletrpc.WalletKit/AddSweepDescriptorData", runtime.WithHTTPPathPattern("/v2/wallet/sweep-descriptors/{registration_id}/data")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_WalletKit_AddSweepDescriptorData_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_WalletKit_AddSweepDescriptorData_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("GET", pattern_WalletKit_ListSweepDescriptors_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/walletrpc.WalletKit/ListSweepDescriptors", runtime.WithHTTPPathPattern("/v2/wallet/sweep-descriptors")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_WalletKit_ListSweepDescriptors_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_WalletKit_ListSweepDescriptors_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("POST", pattern_WalletKit_BumpFee_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -2472,6 +2751,12 @@ var ( pattern_WalletKit_PendingSweeps_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"v2", "wallet", "sweeps", "pending"}, "")) + pattern_WalletKit_RegisterSweepDescriptor_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v2", "wallet", "sweep-descriptors"}, "")) + + pattern_WalletKit_AddSweepDescriptorData_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"v2", "wallet", "sweep-descriptors", "registration_id", "data"}, "")) + + pattern_WalletKit_ListSweepDescriptors_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v2", "wallet", "sweep-descriptors"}, "")) + pattern_WalletKit_BumpFee_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v2", "wallet", "bumpfee"}, "")) pattern_WalletKit_BumpForceCloseFee_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v2", "wallet", "BumpForceCloseFee"}, "")) @@ -2532,6 +2817,12 @@ var ( forward_WalletKit_PendingSweeps_0 = runtime.ForwardResponseMessage + forward_WalletKit_RegisterSweepDescriptor_0 = runtime.ForwardResponseMessage + + forward_WalletKit_AddSweepDescriptorData_0 = runtime.ForwardResponseMessage + + forward_WalletKit_ListSweepDescriptors_0 = runtime.ForwardResponseMessage + forward_WalletKit_BumpFee_0 = runtime.ForwardResponseMessage forward_WalletKit_BumpForceCloseFee_0 = runtime.ForwardResponseMessage diff --git a/lnrpc/walletrpc/walletkit.pb.json.go b/lnrpc/walletrpc/walletkit.pb.json.go index ba427b0e4f4..dda620269b4 100644 --- a/lnrpc/walletrpc/walletkit.pb.json.go +++ b/lnrpc/walletrpc/walletkit.pb.json.go @@ -572,6 +572,81 @@ func RegisterWalletKitJSONCallbacks(registry map[string]func(ctx context.Context callback(string(respBytes), nil) } + registry["walletrpc.WalletKit.RegisterSweepDescriptor"] = func(ctx context.Context, + conn *grpc.ClientConn, reqJSON string, callback func(string, error)) { + + req := &RegisterSweepDescriptorRequest{} + err := marshaler.Unmarshal([]byte(reqJSON), req) + if err != nil { + callback("", err) + return + } + + client := NewWalletKitClient(conn) + resp, err := client.RegisterSweepDescriptor(ctx, req) + if err != nil { + callback("", err) + return + } + + respBytes, err := marshaler.Marshal(resp) + if err != nil { + callback("", err) + return + } + callback(string(respBytes), nil) + } + + registry["walletrpc.WalletKit.AddSweepDescriptorData"] = func(ctx context.Context, + conn *grpc.ClientConn, reqJSON string, callback func(string, error)) { + + req := &AddSweepDescriptorDataRequest{} + err := marshaler.Unmarshal([]byte(reqJSON), req) + if err != nil { + callback("", err) + return + } + + client := NewWalletKitClient(conn) + resp, err := client.AddSweepDescriptorData(ctx, req) + if err != nil { + callback("", err) + return + } + + respBytes, err := marshaler.Marshal(resp) + if err != nil { + callback("", err) + return + } + callback(string(respBytes), nil) + } + + registry["walletrpc.WalletKit.ListSweepDescriptors"] = func(ctx context.Context, + conn *grpc.ClientConn, reqJSON string, callback func(string, error)) { + + req := &ListSweepDescriptorsRequest{} + err := marshaler.Unmarshal([]byte(reqJSON), req) + if err != nil { + callback("", err) + return + } + + client := NewWalletKitClient(conn) + resp, err := client.ListSweepDescriptors(ctx, req) + if err != nil { + callback("", err) + return + } + + respBytes, err := marshaler.Marshal(resp) + if err != nil { + callback("", err) + return + } + callback(string(respBytes), nil) + } + registry["walletrpc.WalletKit.BumpFee"] = func(ctx context.Context, conn *grpc.ClientConn, reqJSON string, callback func(string, error)) { diff --git a/lnrpc/walletrpc/walletkit.proto b/lnrpc/walletrpc/walletkit.proto index 540c571d36e..fab7cb31687 100644 --- a/lnrpc/walletrpc/walletkit.proto +++ b/lnrpc/walletrpc/walletkit.proto @@ -255,6 +255,36 @@ service WalletKit { */ rpc PendingSweeps (PendingSweepsRequest) returns (PendingSweepsResponse); + /* lncli: `wallet registersweepdescriptor` + RegisterSweepDescriptor registers a fixed output descriptor for on-chain + discovery and automatic sweeping. The descriptor must use only public key + material. Any keys that should be signed by lnd must be explicitly bound to + a wallet key locator. + + The first version of this RPC supports fixed-index P2WSH descriptors. The + registration remains active while lnd waits for the output and for a + satisfiable Miniscript branch. Additional satisfaction data, such as a + hash preimage, can be supplied through AddSweepDescriptorData. + */ + rpc RegisterSweepDescriptor (RegisterSweepDescriptorRequest) + returns (RegisterSweepDescriptorResponse); + + /* lncli: `wallet addsweepdescriptordata` + AddSweepDescriptorData supplies data that became available after a sweep + descriptor was registered. Supplying data may make a Miniscript branch + satisfiable and trigger an automatic sweep. + */ + rpc AddSweepDescriptorData (AddSweepDescriptorDataRequest) + returns (AddSweepDescriptorDataResponse); + + /* lncli: `wallet listsweepdescriptors` + ListSweepDescriptors lists descriptor sweep registrations and their current + lifecycle state. A registration ID can be supplied to select a single + registration. + */ + rpc ListSweepDescriptors (ListSweepDescriptorsRequest) + returns (ListSweepDescriptorsResponse); + /* lncli: `wallet bumpfee` BumpFee is an endpoint that allows users to interact with lnd's sweeper directly. It takes an outpoint from an unconfirmed transaction and sends it @@ -1204,6 +1234,12 @@ enum WitnessType { transaction. */ TAPROOT_COMMITMENT_REVOKE_FINAL = 42; + + /* + A witness constructed from a registered native P2WSH output descriptor and + its selected Miniscript satisfaction plan. + */ + DESCRIPTOR_WSH = 43; } message PendingSweep { @@ -1698,3 +1734,189 @@ message ListLeasesResponse { // The list of currently leased utxos. repeated UtxoLease locked_utxos = 1; } + +// SweepDescriptorState describes the lifecycle of a descriptor sweep. +enum SweepDescriptorState { + // The state is unknown or was not set. + SWEEP_DESCRIPTOR_STATE_UNKNOWN = 0; + + // The registration was accepted and is being initialized. + SWEEP_DESCRIPTOR_STATE_REGISTERED = 1; + + // lnd is watching the chain for an output matching the descriptor. + SWEEP_DESCRIPTOR_STATE_WATCHING = 2; + + // A matching output was found. + SWEEP_DESCRIPTOR_STATE_FOUND = 3; + + // The output is waiting for a satisfiable Miniscript branch. + SWEEP_DESCRIPTOR_STATE_WAITING = 4; + + // The output has been handed to lnd's batching sweeper. + SWEEP_DESCRIPTOR_STATE_SWEEPING = 5; + + // The sweep transaction was confirmed. + SWEEP_DESCRIPTOR_STATE_SWEPT = 6; + + // The registration failed and will not make further progress. + SWEEP_DESCRIPTOR_STATE_FAILED = 7; +} + +/* +SweepDescriptorKeyBinding binds a key expression used in a descriptor to a key +controlled by lnd. The descriptor_key must exactly match the key expression in +the canonical descriptor and the derived public keys must agree. +*/ +message SweepDescriptorKeyBinding { + // The key expression as it appears in the descriptor. + string descriptor_key = 1; + + // The key locator of the corresponding key in lnd's wallet. + signrpc.KeyLocator key_locator = 2; +} + +message RegisterSweepDescriptorRequest { + /* + The output descriptor to watch and sweep. Private descriptors (WIF or xprv) + are rejected. The initial implementation accepts fixed-index wsh() + descriptors only. + */ + string output_descriptor = 1; + + /* + The derivation index to use for wildcard descriptors. This must be zero in + the initial fixed-index implementation. + */ + uint32 derivation_index = 2; + + // Required. The non-zero earliest block height at which the output may + // have been created. + uint32 height_hint = 3; + + /* + The number of confirmations required before the output may be swept. This + is the registration's source-finality policy: the initial implementation + does not roll an offered input back after a deeper funding reorganization. + */ + uint32 min_confs = 4; + + // Keys in the descriptor for which lnd should produce signatures. + repeated SweepDescriptorKeyBinding key_bindings = 5; + + // Required. The non-zero maximum number of satoshis the sweep may spend on + // fees. + uint64 budget_sat = 6; + + /* + The number of blocks from offering a satisfiable output to the sweeper by + which the sweep should confirm. A value of zero uses the sweeper's default + deadline. + */ + uint32 deadline_delta = 7; + + // Whether to offer a satisfiable output to the sweeper immediately. + bool immediate = 8; + + // An optional human-readable label for this registration. + string label = 9; + + /* + Required. The exact value in satoshis of the output to watch. An output is + accepted only when both its script and value match this registration. + */ + uint64 expected_value_sat = 10; +} + +message RegisterSweepDescriptorResponse { + // The stable ID of the registration. + bytes registration_id = 1; + + // The parsed descriptor serialized in canonical form. + string output_descriptor = 2; + + // The address derived from the descriptor. + string address = 3; + + // The exact output script watched by lnd. + bytes pk_script = 4; +} + +message AddSweepDescriptorDataRequest { + // The stable ID returned by RegisterSweepDescriptor. + bytes registration_id = 1; + + oneof data { + /* + A 32-byte preimage. lnd derives its SHA256 digest and makes the preimage + available only to matching hash fragments in the descriptor. + */ + bytes preimage = 2; + } +} + +message AddSweepDescriptorDataResponse { + // The updated state of the registration. + SweepDescriptorStatus status = 1; +} + +message ListSweepDescriptorsRequest { + /* + An optional registration ID. When omitted, all registrations are returned. + */ + bytes registration_id = 1; +} + +message ListSweepDescriptorsResponse { + // The matching descriptor sweep registrations. + repeated SweepDescriptorStatus descriptors = 1; +} + +message SweepDescriptorStatus { + // The stable ID of the registration. + bytes registration_id = 1; + + // The descriptor serialized in canonical form. + string output_descriptor = 2; + + // The address derived from the descriptor. + string address = 3; + + // The exact output script watched by lnd. + bytes pk_script = 4; + + // The current lifecycle state. + SweepDescriptorState state = 5; + + // The matching outpoint, once an output has been found. + lnrpc.OutPoint outpoint = 6; + + // The transaction ID of the confirmed sweep transaction. + bytes sweep_txid = 7; + + // The terminal error when state is SWEEP_DESCRIPTOR_STATE_FAILED. + string failure_reason = 8; + + // The earliest block height watched by this registration. + uint32 height_hint = 9; + + // The requested confirmation depth of a matching output. + uint32 min_confs = 10; + + // Keys in the descriptor that are bound to lnd wallet keys. + repeated SweepDescriptorKeyBinding key_bindings = 11; + + // The maximum number of satoshis the sweep may spend on fees. + uint64 budget_sat = 12; + + // The requested sweep deadline delta. + uint32 deadline_delta = 13; + + // Whether the sweep is offered to the sweeper immediately. + bool immediate = 14; + + // The optional human-readable registration label. + string label = 15; + + // The exact output value required by this registration. + uint64 expected_value_sat = 16; +} diff --git a/lnrpc/walletrpc/walletkit.swagger.json b/lnrpc/walletrpc/walletkit.swagger.json index 90365c3040b..42f6c191c57 100644 --- a/lnrpc/walletrpc/walletkit.swagger.json +++ b/lnrpc/walletrpc/walletkit.swagger.json @@ -638,6 +638,119 @@ ] } }, + "/v2/wallet/sweep-descriptors": { + "get": { + "summary": "lncli: `wallet listsweepdescriptors`\nListSweepDescriptors lists descriptor sweep registrations and their current\nlifecycle state. A registration ID can be supplied to select a single\nregistration.", + "operationId": "WalletKit_ListSweepDescriptors", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/walletrpcListSweepDescriptorsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "registration_id", + "description": "An optional registration ID. When omitted, all registrations are returned.", + "in": "query", + "required": false, + "type": "string", + "format": "byte" + } + ], + "tags": [ + "WalletKit" + ] + }, + "post": { + "summary": "lncli: `wallet registersweepdescriptor`\nRegisterSweepDescriptor registers a fixed output descriptor for on-chain\ndiscovery and automatic sweeping. The descriptor must use only public key\nmaterial. Any keys that should be signed by lnd must be explicitly bound to\na wallet key locator.", + "description": "The first version of this RPC supports fixed-index P2WSH descriptors. The\nregistration remains active while lnd waits for the output and for a\nsatisfiable Miniscript branch. Additional satisfaction data, such as a\nhash preimage, can be supplied through AddSweepDescriptorData.", + "operationId": "WalletKit_RegisterSweepDescriptor", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/walletrpcRegisterSweepDescriptorResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/walletrpcRegisterSweepDescriptorRequest" + } + } + ], + "tags": [ + "WalletKit" + ] + } + }, + "/v2/wallet/sweep-descriptors/{registration_id}/data": { + "post": { + "summary": "lncli: `wallet addsweepdescriptordata`\nAddSweepDescriptorData supplies data that became available after a sweep\ndescriptor was registered. Supplying data may make a Miniscript branch\nsatisfiable and trigger an automatic sweep.", + "operationId": "WalletKit_AddSweepDescriptorData", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/walletrpcAddSweepDescriptorDataResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "registration_id", + "description": "The stable ID returned by RegisterSweepDescriptor.", + "in": "path", + "required": true, + "type": "string", + "format": "byte" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "preimage": { + "type": "string", + "format": "byte", + "description": "A 32-byte preimage. lnd derives its SHA256 digest and makes the preimage\navailable only to matching hash fragments in the descriptor." + } + } + } + } + ], + "tags": [ + "WalletKit" + ] + } + }, "/v2/wallet/sweeps": { "get": { "summary": "lncli: `wallet listsweeps`\nListSweeps returns a list of the sweep transactions our node has produced.\nNote that these sweeps may not be confirmed yet, as we record sweeps on\nbroadcast, not confirmation.", @@ -1383,6 +1496,15 @@ } } }, + "walletrpcAddSweepDescriptorDataResponse": { + "type": "object", + "properties": { + "status": { + "$ref": "#/definitions/walletrpcSweepDescriptorStatus", + "description": "The updated state of the registration." + } + } + }, "walletrpcAddrRequest": { "type": "object", "properties": { @@ -1911,6 +2033,19 @@ } } }, + "walletrpcListSweepDescriptorsResponse": { + "type": "object", + "properties": { + "descriptors": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/walletrpcSweepDescriptorStatus" + }, + "description": "The matching descriptor sweep registrations." + } + } + }, "walletrpcListSweepsResponse": { "type": "object", "properties": { @@ -2075,6 +2210,84 @@ } } }, + "walletrpcRegisterSweepDescriptorRequest": { + "type": "object", + "properties": { + "output_descriptor": { + "type": "string", + "description": "The output descriptor to watch and sweep. Private descriptors (WIF or xprv)\nare rejected. The initial implementation accepts fixed-index wsh()\ndescriptors only." + }, + "derivation_index": { + "type": "integer", + "format": "int64", + "description": "The derivation index to use for wildcard descriptors. This must be zero in\nthe initial fixed-index implementation." + }, + "height_hint": { + "type": "integer", + "format": "int64", + "description": "Required. The non-zero earliest block height at which the output may\nhave been created." + }, + "min_confs": { + "type": "integer", + "format": "int64", + "description": "The number of confirmations required before the output may be swept. This\nis the registration's source-finality policy: the initial implementation\ndoes not roll an offered input back after a deeper funding reorganization." + }, + "key_bindings": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/walletrpcSweepDescriptorKeyBinding" + }, + "description": "Keys in the descriptor for which lnd should produce signatures." + }, + "budget_sat": { + "type": "string", + "format": "uint64", + "description": "Required. The non-zero maximum number of satoshis the sweep may spend on\nfees." + }, + "deadline_delta": { + "type": "integer", + "format": "int64", + "description": "The number of blocks from offering a satisfiable output to the sweeper by\nwhich the sweep should confirm. A value of zero uses the sweeper's default\ndeadline." + }, + "immediate": { + "type": "boolean", + "description": "Whether to offer a satisfiable output to the sweeper immediately." + }, + "label": { + "type": "string", + "description": "An optional human-readable label for this registration." + }, + "expected_value_sat": { + "type": "string", + "format": "uint64", + "description": "Required. The exact value in satoshis of the output to watch. An output is\naccepted only when both its script and value match this registration." + } + } + }, + "walletrpcRegisterSweepDescriptorResponse": { + "type": "object", + "properties": { + "registration_id": { + "type": "string", + "format": "byte", + "description": "The stable ID of the registration." + }, + "output_descriptor": { + "type": "string", + "description": "The parsed descriptor serialized in canonical form." + }, + "address": { + "type": "string", + "description": "The address derived from the descriptor." + }, + "pk_script": { + "type": "string", + "format": "byte", + "description": "The exact output script watched by lnd." + } + } + }, "walletrpcReleaseOutputRequest": { "type": "object", "properties": { @@ -2271,6 +2484,116 @@ } } }, + "walletrpcSweepDescriptorKeyBinding": { + "type": "object", + "properties": { + "descriptor_key": { + "type": "string", + "description": "The key expression as it appears in the descriptor." + }, + "key_locator": { + "$ref": "#/definitions/signrpcKeyLocator", + "description": "The key locator of the corresponding key in lnd's wallet." + } + }, + "description": "SweepDescriptorKeyBinding binds a key expression used in a descriptor to a key\ncontrolled by lnd. The descriptor_key must exactly match the key expression in\nthe canonical descriptor and the derived public keys must agree." + }, + "walletrpcSweepDescriptorState": { + "type": "string", + "enum": [ + "SWEEP_DESCRIPTOR_STATE_UNKNOWN", + "SWEEP_DESCRIPTOR_STATE_REGISTERED", + "SWEEP_DESCRIPTOR_STATE_WATCHING", + "SWEEP_DESCRIPTOR_STATE_FOUND", + "SWEEP_DESCRIPTOR_STATE_WAITING", + "SWEEP_DESCRIPTOR_STATE_SWEEPING", + "SWEEP_DESCRIPTOR_STATE_SWEPT", + "SWEEP_DESCRIPTOR_STATE_FAILED" + ], + "default": "SWEEP_DESCRIPTOR_STATE_UNKNOWN", + "description": "SweepDescriptorState describes the lifecycle of a descriptor sweep.\n\n - SWEEP_DESCRIPTOR_STATE_UNKNOWN: The state is unknown or was not set.\n - SWEEP_DESCRIPTOR_STATE_REGISTERED: The registration was accepted and is being initialized.\n - SWEEP_DESCRIPTOR_STATE_WATCHING: lnd is watching the chain for an output matching the descriptor.\n - SWEEP_DESCRIPTOR_STATE_FOUND: A matching output was found.\n - SWEEP_DESCRIPTOR_STATE_WAITING: The output is waiting for a satisfiable Miniscript branch.\n - SWEEP_DESCRIPTOR_STATE_SWEEPING: The output has been handed to lnd's batching sweeper.\n - SWEEP_DESCRIPTOR_STATE_SWEPT: The sweep transaction was confirmed.\n - SWEEP_DESCRIPTOR_STATE_FAILED: The registration failed and will not make further progress." + }, + "walletrpcSweepDescriptorStatus": { + "type": "object", + "properties": { + "registration_id": { + "type": "string", + "format": "byte", + "description": "The stable ID of the registration." + }, + "output_descriptor": { + "type": "string", + "description": "The descriptor serialized in canonical form." + }, + "address": { + "type": "string", + "description": "The address derived from the descriptor." + }, + "pk_script": { + "type": "string", + "format": "byte", + "description": "The exact output script watched by lnd." + }, + "state": { + "$ref": "#/definitions/walletrpcSweepDescriptorState", + "description": "The current lifecycle state." + }, + "outpoint": { + "$ref": "#/definitions/lnrpcOutPoint", + "description": "The matching outpoint, once an output has been found." + }, + "sweep_txid": { + "type": "string", + "format": "byte", + "description": "The transaction ID of the confirmed sweep transaction." + }, + "failure_reason": { + "type": "string", + "description": "The terminal error when state is SWEEP_DESCRIPTOR_STATE_FAILED." + }, + "height_hint": { + "type": "integer", + "format": "int64", + "description": "The earliest block height watched by this registration." + }, + "min_confs": { + "type": "integer", + "format": "int64", + "description": "The requested confirmation depth of a matching output." + }, + "key_bindings": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/walletrpcSweepDescriptorKeyBinding" + }, + "description": "Keys in the descriptor that are bound to lnd wallet keys." + }, + "budget_sat": { + "type": "string", + "format": "uint64", + "description": "The maximum number of satoshis the sweep may spend on fees." + }, + "deadline_delta": { + "type": "integer", + "format": "int64", + "description": "The requested sweep deadline delta." + }, + "immediate": { + "type": "boolean", + "description": "Whether the sweep is offered to the sweeper immediately." + }, + "label": { + "type": "string", + "description": "The optional human-readable registration label." + }, + "expected_value_sat": { + "type": "string", + "format": "uint64", + "description": "The exact output value required by this registration." + } + } + }, "walletrpcTapLeaf": { "type": "object", "properties": { @@ -2454,10 +2777,11 @@ "TAPROOT_HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL_FINAL", "TAPROOT_HTLC_OFFERED_REMOTE_TIMEOUT_FINAL", "TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS_FINAL", - "TAPROOT_COMMITMENT_REVOKE_FINAL" + "TAPROOT_COMMITMENT_REVOKE_FINAL", + "DESCRIPTOR_WSH" ], "default": "UNKNOWN_WITNESS", - "description": " - COMMITMENT_TIME_LOCK: A witness that allows us to spend the output of a commitment transaction\nafter a relative lock-time lockout.\n - COMMITMENT_NO_DELAY: A witness that allows us to spend a settled no-delay output immediately on a\ncounterparty's commitment transaction.\n - COMMITMENT_REVOKE: A witness that allows us to sweep the settled output of a malicious\ncounterparty's who broadcasts a revoked commitment transaction.\n - HTLC_OFFERED_REVOKE: A witness that allows us to sweep an HTLC which we offered to the remote\nparty in the case that they broadcast a revoked commitment state.\n - HTLC_ACCEPTED_REVOKE: A witness that allows us to sweep an HTLC output sent to us in the case that\nthe remote party broadcasts a revoked commitment state.\n - HTLC_OFFERED_TIMEOUT_SECOND_LEVEL: A witness that allows us to sweep an HTLC output that we extended to a\nparty, but was never fulfilled. This HTLC output isn't directly on the\ncommitment transaction, but is the result of a confirmed second-level HTLC\ntransaction. As a result, we can only spend this after a CSV delay.\n - HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL: A witness that allows us to sweep an HTLC output that was offered to us, and\nfor which we have a payment preimage. This HTLC output isn't directly on our\ncommitment transaction, but is the result of confirmed second-level HTLC\ntransaction. As a result, we can only spend this after a CSV delay.\n - HTLC_OFFERED_REMOTE_TIMEOUT: A witness that allows us to sweep an HTLC that we offered to the remote\nparty which lies in the commitment transaction of the remote party. We can\nspend this output after the absolute CLTV timeout of the HTLC as passed.\n - HTLC_ACCEPTED_REMOTE_SUCCESS: A witness that allows us to sweep an HTLC that was offered to us by the\nremote party. We use this witness in the case that the remote party goes to\nchain, and we know the pre-image to the HTLC. We can sweep this without any\nadditional timeout.\n - HTLC_SECOND_LEVEL_REVOKE: A witness that allows us to sweep an HTLC from the remote party's commitment\ntransaction in the case that the broadcast a revoked commitment, but then\nalso immediately attempt to go to the second level to claim the HTLC.\n - WITNESS_KEY_HASH: A witness type that allows us to spend a regular p2wkh output that's sent to\nan output which is under complete control of the backing wallet.\n - NESTED_WITNESS_KEY_HASH: A witness type that allows us to sweep an output that sends to a nested P2SH\nscript that pays to a key solely under our control.\n - COMMITMENT_ANCHOR: A witness type that allows us to spend our anchor on the commitment\ntransaction.\n - COMMITMENT_NO_DELAY_TWEAKLESS: A witness type that is similar to the COMMITMENT_NO_DELAY type,\nbut it omits the tweak that randomizes the key we need to\nspend with a channel peer supplied set of randomness.\n - COMMITMENT_TO_REMOTE_CONFIRMED: A witness type that allows us to spend our output on the counterparty's\ncommitment transaction after a confirmation.\n - HTLC_OFFERED_TIMEOUT_SECOND_LEVEL_INPUT_CONFIRMED: A witness type that allows us to sweep an HTLC output that we extended\nto a party, but was never fulfilled. This _is_ the HTLC output directly\non our commitment transaction, and the input to the second-level HTLC\ntimeout transaction. It can only be spent after CLTV expiry, and\ncommitment confirmation.\n - HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL_INPUT_CONFIRMED: A witness type that allows us to sweep an HTLC output that was offered\nto us, and for which we have a payment preimage. This _is_ the HTLC\noutput directly on our commitment transaction, and the input to the\nsecond-level HTLC success transaction. It can only be spent after the\ncommitment has confirmed.\n - LEASE_COMMITMENT_TIME_LOCK: A witness type that allows us to spend our output on our local\ncommitment transaction after a relative and absolute lock-time lockout as\npart of the script enforced lease commitment type.\n - LEASE_COMMITMENT_TO_REMOTE_CONFIRMED: A witness type that allows us to spend our output on the counterparty's\ncommitment transaction after a confirmation and absolute locktime as part\nof the script enforced lease commitment type.\n - LEASE_HTLC_OFFERED_TIMEOUT_SECOND_LEVEL: A witness type that allows us to sweep an HTLC output that we extended\nto a party, but was never fulfilled. This HTLC output isn't directly on\nthe commitment transaction, but is the result of a confirmed second-level\nHTLC transaction. As a result, we can only spend this after a CSV delay\nand CLTV locktime as part of the script enforced lease commitment type.\n - LEASE_HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL: A witness type that allows us to sweep an HTLC output that was offered\nto us, and for which we have a payment preimage. This HTLC output isn't\ndirectly on our commitment transaction, but is the result of confirmed\nsecond-level HTLC transaction. As a result, we can only spend this after\na CSV delay and CLTV locktime as part of the script enforced lease\ncommitment type.\n - TAPROOT_PUB_KEY_SPEND: A witness type that allows us to spend a regular p2tr output that's sent\nto an output which is under complete control of the backing wallet.\n - TAPROOT_LOCAL_COMMIT_SPEND: A witness type that allows us to spend our settled local commitment after a\nCSV delay when we force close the channel.\n - TAPROOT_REMOTE_COMMIT_SPEND: A witness type that allows us to spend our settled local commitment after\na CSV delay when the remote party has force closed the channel.\n - TAPROOT_ANCHOR_SWEEP_SPEND: A witness type that we'll use for spending our own anchor output.\n - TAPROOT_HTLC_OFFERED_TIMEOUT_SECOND_LEVEL: A witness that allows us to timeout an HTLC we offered to the remote party\non our commitment transaction. We use this when we need to go on chain to\ntime out an HTLC.\n - TAPROOT_HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL: A witness type that allows us to sweep an HTLC we accepted on our commitment\ntransaction after we go to the second level on chain.\n - TAPROOT_HTLC_SECOND_LEVEL_REVOKE: A witness that allows us to sweep an HTLC on the revoked transaction of the\nremote party that goes to the second level.\n - TAPROOT_HTLC_ACCEPTED_REVOKE: A witness that allows us to sweep an HTLC sent to us by the remote party\nin the event that they broadcast a revoked state.\n - TAPROOT_HTLC_OFFERED_REVOKE: A witness that allows us to sweep an HTLC we offered to the remote party if\nthey broadcast a revoked commitment.\n - TAPROOT_HTLC_OFFERED_REMOTE_TIMEOUT: A witness that allows us to sweep an HTLC we offered to the remote party\nthat lies on the commitment transaction for the remote party. We can spend\nthis output after the absolute CLTV timeout of the HTLC as passed.\n - TAPROOT_HTLC_LOCAL_OFFERED_TIMEOUT: A witness type that allows us to sign the second level HTLC timeout\ntransaction when spending from an HTLC residing on our local commitment\ntransaction.\nThis is used by the sweeper to re-sign inputs if it needs to aggregate\nseveral second level HTLCs.\n - TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS: A witness that allows us to sweep an HTLC that was offered to us by the\nremote party for a taproot channels. We use this witness in the case that\nthe remote party goes to chain, and we know the pre-image to the HTLC. We\ncan sweep this without any additional timeout.\n - TAPROOT_HTLC_ACCEPTED_LOCAL_SUCCESS: A witness type that allows us to sweep the HTLC offered to us on our local\ncommitment transaction. We'll use this when we need to go on chain to sweep\nthe HTLC. In this case, this is the second level HTLC success transaction.\n - TAPROOT_COMMITMENT_REVOKE: A witness that allows us to sweep the settled output of a malicious\ncounterparty's who broadcasts a revoked taproot commitment transaction.\n - TAPROOT_LOCAL_COMMIT_SPEND_FINAL: A witness type that allows us to spend our settled local commitment after a\nCSV delay when we force close a production taproot channel.\n - TAPROOT_REMOTE_COMMIT_SPEND_FINAL: A witness type that allows us to spend our settled local commitment after\na CSV delay when the remote party has force closed a production taproot\nchannel.\n - TAPROOT_HTLC_OFFERED_TIMEOUT_SECOND_LEVEL_FINAL: A witness that allows us to timeout an HTLC we offered to the remote party\non our production taproot commitment transaction. We use this when we need\nto go on chain to time out an HTLC.\n - TAPROOT_HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL_FINAL: A witness type that allows us to sweep an HTLC we accepted on our\nproduction taproot commitment transaction after we go to the second level\non chain.\n - TAPROOT_HTLC_OFFERED_REMOTE_TIMEOUT_FINAL: A witness that allows us to sweep an HTLC we offered to the remote party\nthat lies on the production taproot commitment transaction for the remote\nparty. We can spend this output after the absolute CLTV timeout of the\nHTLC as passed.\n - TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS_FINAL: A witness that allows us to sweep an HTLC that was offered to us by the\nremote party for a production taproot channel. We use this witness in the\ncase that the remote party goes to chain, and we know the pre-image to the\nHTLC. We can sweep this without any additional timeout.\n - TAPROOT_COMMITMENT_REVOKE_FINAL: A witness type that allows us to sweep the settled output of a malicious\ncounterparty's who broadcasts a revoked production taproot commitment\ntransaction." + "description": " - COMMITMENT_TIME_LOCK: A witness that allows us to spend the output of a commitment transaction\nafter a relative lock-time lockout.\n - COMMITMENT_NO_DELAY: A witness that allows us to spend a settled no-delay output immediately on a\ncounterparty's commitment transaction.\n - COMMITMENT_REVOKE: A witness that allows us to sweep the settled output of a malicious\ncounterparty's who broadcasts a revoked commitment transaction.\n - HTLC_OFFERED_REVOKE: A witness that allows us to sweep an HTLC which we offered to the remote\nparty in the case that they broadcast a revoked commitment state.\n - HTLC_ACCEPTED_REVOKE: A witness that allows us to sweep an HTLC output sent to us in the case that\nthe remote party broadcasts a revoked commitment state.\n - HTLC_OFFERED_TIMEOUT_SECOND_LEVEL: A witness that allows us to sweep an HTLC output that we extended to a\nparty, but was never fulfilled. This HTLC output isn't directly on the\ncommitment transaction, but is the result of a confirmed second-level HTLC\ntransaction. As a result, we can only spend this after a CSV delay.\n - HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL: A witness that allows us to sweep an HTLC output that was offered to us, and\nfor which we have a payment preimage. This HTLC output isn't directly on our\ncommitment transaction, but is the result of confirmed second-level HTLC\ntransaction. As a result, we can only spend this after a CSV delay.\n - HTLC_OFFERED_REMOTE_TIMEOUT: A witness that allows us to sweep an HTLC that we offered to the remote\nparty which lies in the commitment transaction of the remote party. We can\nspend this output after the absolute CLTV timeout of the HTLC as passed.\n - HTLC_ACCEPTED_REMOTE_SUCCESS: A witness that allows us to sweep an HTLC that was offered to us by the\nremote party. We use this witness in the case that the remote party goes to\nchain, and we know the pre-image to the HTLC. We can sweep this without any\nadditional timeout.\n - HTLC_SECOND_LEVEL_REVOKE: A witness that allows us to sweep an HTLC from the remote party's commitment\ntransaction in the case that the broadcast a revoked commitment, but then\nalso immediately attempt to go to the second level to claim the HTLC.\n - WITNESS_KEY_HASH: A witness type that allows us to spend a regular p2wkh output that's sent to\nan output which is under complete control of the backing wallet.\n - NESTED_WITNESS_KEY_HASH: A witness type that allows us to sweep an output that sends to a nested P2SH\nscript that pays to a key solely under our control.\n - COMMITMENT_ANCHOR: A witness type that allows us to spend our anchor on the commitment\ntransaction.\n - COMMITMENT_NO_DELAY_TWEAKLESS: A witness type that is similar to the COMMITMENT_NO_DELAY type,\nbut it omits the tweak that randomizes the key we need to\nspend with a channel peer supplied set of randomness.\n - COMMITMENT_TO_REMOTE_CONFIRMED: A witness type that allows us to spend our output on the counterparty's\ncommitment transaction after a confirmation.\n - HTLC_OFFERED_TIMEOUT_SECOND_LEVEL_INPUT_CONFIRMED: A witness type that allows us to sweep an HTLC output that we extended\nto a party, but was never fulfilled. This _is_ the HTLC output directly\non our commitment transaction, and the input to the second-level HTLC\ntimeout transaction. It can only be spent after CLTV expiry, and\ncommitment confirmation.\n - HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL_INPUT_CONFIRMED: A witness type that allows us to sweep an HTLC output that was offered\nto us, and for which we have a payment preimage. This _is_ the HTLC\noutput directly on our commitment transaction, and the input to the\nsecond-level HTLC success transaction. It can only be spent after the\ncommitment has confirmed.\n - LEASE_COMMITMENT_TIME_LOCK: A witness type that allows us to spend our output on our local\ncommitment transaction after a relative and absolute lock-time lockout as\npart of the script enforced lease commitment type.\n - LEASE_COMMITMENT_TO_REMOTE_CONFIRMED: A witness type that allows us to spend our output on the counterparty's\ncommitment transaction after a confirmation and absolute locktime as part\nof the script enforced lease commitment type.\n - LEASE_HTLC_OFFERED_TIMEOUT_SECOND_LEVEL: A witness type that allows us to sweep an HTLC output that we extended\nto a party, but was never fulfilled. This HTLC output isn't directly on\nthe commitment transaction, but is the result of a confirmed second-level\nHTLC transaction. As a result, we can only spend this after a CSV delay\nand CLTV locktime as part of the script enforced lease commitment type.\n - LEASE_HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL: A witness type that allows us to sweep an HTLC output that was offered\nto us, and for which we have a payment preimage. This HTLC output isn't\ndirectly on our commitment transaction, but is the result of confirmed\nsecond-level HTLC transaction. As a result, we can only spend this after\na CSV delay and CLTV locktime as part of the script enforced lease\ncommitment type.\n - TAPROOT_PUB_KEY_SPEND: A witness type that allows us to spend a regular p2tr output that's sent\nto an output which is under complete control of the backing wallet.\n - TAPROOT_LOCAL_COMMIT_SPEND: A witness type that allows us to spend our settled local commitment after a\nCSV delay when we force close the channel.\n - TAPROOT_REMOTE_COMMIT_SPEND: A witness type that allows us to spend our settled local commitment after\na CSV delay when the remote party has force closed the channel.\n - TAPROOT_ANCHOR_SWEEP_SPEND: A witness type that we'll use for spending our own anchor output.\n - TAPROOT_HTLC_OFFERED_TIMEOUT_SECOND_LEVEL: A witness that allows us to timeout an HTLC we offered to the remote party\non our commitment transaction. We use this when we need to go on chain to\ntime out an HTLC.\n - TAPROOT_HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL: A witness type that allows us to sweep an HTLC we accepted on our commitment\ntransaction after we go to the second level on chain.\n - TAPROOT_HTLC_SECOND_LEVEL_REVOKE: A witness that allows us to sweep an HTLC on the revoked transaction of the\nremote party that goes to the second level.\n - TAPROOT_HTLC_ACCEPTED_REVOKE: A witness that allows us to sweep an HTLC sent to us by the remote party\nin the event that they broadcast a revoked state.\n - TAPROOT_HTLC_OFFERED_REVOKE: A witness that allows us to sweep an HTLC we offered to the remote party if\nthey broadcast a revoked commitment.\n - TAPROOT_HTLC_OFFERED_REMOTE_TIMEOUT: A witness that allows us to sweep an HTLC we offered to the remote party\nthat lies on the commitment transaction for the remote party. We can spend\nthis output after the absolute CLTV timeout of the HTLC as passed.\n - TAPROOT_HTLC_LOCAL_OFFERED_TIMEOUT: A witness type that allows us to sign the second level HTLC timeout\ntransaction when spending from an HTLC residing on our local commitment\ntransaction.\nThis is used by the sweeper to re-sign inputs if it needs to aggregate\nseveral second level HTLCs.\n - TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS: A witness that allows us to sweep an HTLC that was offered to us by the\nremote party for a taproot channels. We use this witness in the case that\nthe remote party goes to chain, and we know the pre-image to the HTLC. We\ncan sweep this without any additional timeout.\n - TAPROOT_HTLC_ACCEPTED_LOCAL_SUCCESS: A witness type that allows us to sweep the HTLC offered to us on our local\ncommitment transaction. We'll use this when we need to go on chain to sweep\nthe HTLC. In this case, this is the second level HTLC success transaction.\n - TAPROOT_COMMITMENT_REVOKE: A witness that allows us to sweep the settled output of a malicious\ncounterparty's who broadcasts a revoked taproot commitment transaction.\n - TAPROOT_LOCAL_COMMIT_SPEND_FINAL: A witness type that allows us to spend our settled local commitment after a\nCSV delay when we force close a production taproot channel.\n - TAPROOT_REMOTE_COMMIT_SPEND_FINAL: A witness type that allows us to spend our settled local commitment after\na CSV delay when the remote party has force closed a production taproot\nchannel.\n - TAPROOT_HTLC_OFFERED_TIMEOUT_SECOND_LEVEL_FINAL: A witness that allows us to timeout an HTLC we offered to the remote party\non our production taproot commitment transaction. We use this when we need\nto go on chain to time out an HTLC.\n - TAPROOT_HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL_FINAL: A witness type that allows us to sweep an HTLC we accepted on our\nproduction taproot commitment transaction after we go to the second level\non chain.\n - TAPROOT_HTLC_OFFERED_REMOTE_TIMEOUT_FINAL: A witness that allows us to sweep an HTLC we offered to the remote party\nthat lies on the production taproot commitment transaction for the remote\nparty. We can spend this output after the absolute CLTV timeout of the\nHTLC as passed.\n - TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS_FINAL: A witness that allows us to sweep an HTLC that was offered to us by the\nremote party for a production taproot channel. We use this witness in the\ncase that the remote party goes to chain, and we know the pre-image to the\nHTLC. We can sweep this without any additional timeout.\n - TAPROOT_COMMITMENT_REVOKE_FINAL: A witness type that allows us to sweep the settled output of a malicious\ncounterparty's who broadcasts a revoked production taproot commitment\ntransaction.\n - DESCRIPTOR_WSH: A witness constructed from a registered native P2WSH output descriptor and\nits selected Miniscript satisfaction plan." } } } diff --git a/lnrpc/walletrpc/walletkit.yaml b/lnrpc/walletrpc/walletkit.yaml index 06c90892316..a09c3937284 100644 --- a/lnrpc/walletrpc/walletkit.yaml +++ b/lnrpc/walletrpc/walletkit.yaml @@ -44,6 +44,14 @@ http: get: "/v2/wallet/estimatefee/{conf_target}" - selector: walletrpc.WalletKit.PendingSweeps get: "/v2/wallet/sweeps/pending" + - selector: walletrpc.WalletKit.RegisterSweepDescriptor + post: "/v2/wallet/sweep-descriptors" + body: "*" + - selector: walletrpc.WalletKit.AddSweepDescriptorData + post: "/v2/wallet/sweep-descriptors/{registration_id}/data" + body: "*" + - selector: walletrpc.WalletKit.ListSweepDescriptors + get: "/v2/wallet/sweep-descriptors" - selector: walletrpc.WalletKit.BumpFee post: "/v2/wallet/bumpfee" body: "*" diff --git a/lnrpc/walletrpc/walletkit_grpc.pb.go b/lnrpc/walletrpc/walletkit_grpc.pb.go index 8cd991ceee4..906f59ae6f6 100644 --- a/lnrpc/walletrpc/walletkit_grpc.pb.go +++ b/lnrpc/walletrpc/walletkit_grpc.pb.go @@ -191,6 +191,27 @@ type WalletKitClient interface { // remain supported. This is an advanced API that depends on the internals of // the UtxoSweeper, so things may change. PendingSweeps(ctx context.Context, in *PendingSweepsRequest, opts ...grpc.CallOption) (*PendingSweepsResponse, error) + // lncli: `wallet registersweepdescriptor` + // RegisterSweepDescriptor registers a fixed output descriptor for on-chain + // discovery and automatic sweeping. The descriptor must use only public key + // material. Any keys that should be signed by lnd must be explicitly bound to + // a wallet key locator. + // + // The first version of this RPC supports fixed-index P2WSH descriptors. The + // registration remains active while lnd waits for the output and for a + // satisfiable Miniscript branch. Additional satisfaction data, such as a + // hash preimage, can be supplied through AddSweepDescriptorData. + RegisterSweepDescriptor(ctx context.Context, in *RegisterSweepDescriptorRequest, opts ...grpc.CallOption) (*RegisterSweepDescriptorResponse, error) + // lncli: `wallet addsweepdescriptordata` + // AddSweepDescriptorData supplies data that became available after a sweep + // descriptor was registered. Supplying data may make a Miniscript branch + // satisfiable and trigger an automatic sweep. + AddSweepDescriptorData(ctx context.Context, in *AddSweepDescriptorDataRequest, opts ...grpc.CallOption) (*AddSweepDescriptorDataResponse, error) + // lncli: `wallet listsweepdescriptors` + // ListSweepDescriptors lists descriptor sweep registrations and their current + // lifecycle state. A registration ID can be supplied to select a single + // registration. + ListSweepDescriptors(ctx context.Context, in *ListSweepDescriptorsRequest, opts ...grpc.CallOption) (*ListSweepDescriptorsResponse, error) // lncli: `wallet bumpfee` // BumpFee is an endpoint that allows users to interact with lnd's sweeper // directly. It takes an outpoint from an unconfirmed transaction and sends it @@ -500,6 +521,33 @@ func (c *walletKitClient) PendingSweeps(ctx context.Context, in *PendingSweepsRe return out, nil } +func (c *walletKitClient) RegisterSweepDescriptor(ctx context.Context, in *RegisterSweepDescriptorRequest, opts ...grpc.CallOption) (*RegisterSweepDescriptorResponse, error) { + out := new(RegisterSweepDescriptorResponse) + err := c.cc.Invoke(ctx, "/walletrpc.WalletKit/RegisterSweepDescriptor", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *walletKitClient) AddSweepDescriptorData(ctx context.Context, in *AddSweepDescriptorDataRequest, opts ...grpc.CallOption) (*AddSweepDescriptorDataResponse, error) { + out := new(AddSweepDescriptorDataResponse) + err := c.cc.Invoke(ctx, "/walletrpc.WalletKit/AddSweepDescriptorData", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *walletKitClient) ListSweepDescriptors(ctx context.Context, in *ListSweepDescriptorsRequest, opts ...grpc.CallOption) (*ListSweepDescriptorsResponse, error) { + out := new(ListSweepDescriptorsResponse) + err := c.cc.Invoke(ctx, "/walletrpc.WalletKit/ListSweepDescriptors", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *walletKitClient) BumpFee(ctx context.Context, in *BumpFeeRequest, opts ...grpc.CallOption) (*BumpFeeResponse, error) { out := new(BumpFeeResponse) err := c.cc.Invoke(ctx, "/walletrpc.WalletKit/BumpFee", in, out, opts...) @@ -738,6 +786,27 @@ type WalletKitServer interface { // remain supported. This is an advanced API that depends on the internals of // the UtxoSweeper, so things may change. PendingSweeps(context.Context, *PendingSweepsRequest) (*PendingSweepsResponse, error) + // lncli: `wallet registersweepdescriptor` + // RegisterSweepDescriptor registers a fixed output descriptor for on-chain + // discovery and automatic sweeping. The descriptor must use only public key + // material. Any keys that should be signed by lnd must be explicitly bound to + // a wallet key locator. + // + // The first version of this RPC supports fixed-index P2WSH descriptors. The + // registration remains active while lnd waits for the output and for a + // satisfiable Miniscript branch. Additional satisfaction data, such as a + // hash preimage, can be supplied through AddSweepDescriptorData. + RegisterSweepDescriptor(context.Context, *RegisterSweepDescriptorRequest) (*RegisterSweepDescriptorResponse, error) + // lncli: `wallet addsweepdescriptordata` + // AddSweepDescriptorData supplies data that became available after a sweep + // descriptor was registered. Supplying data may make a Miniscript branch + // satisfiable and trigger an automatic sweep. + AddSweepDescriptorData(context.Context, *AddSweepDescriptorDataRequest) (*AddSweepDescriptorDataResponse, error) + // lncli: `wallet listsweepdescriptors` + // ListSweepDescriptors lists descriptor sweep registrations and their current + // lifecycle state. A registration ID can be supplied to select a single + // registration. + ListSweepDescriptors(context.Context, *ListSweepDescriptorsRequest) (*ListSweepDescriptorsResponse, error) // lncli: `wallet bumpfee` // BumpFee is an endpoint that allows users to interact with lnd's sweeper // directly. It takes an outpoint from an unconfirmed transaction and sends it @@ -912,6 +981,15 @@ func (UnimplementedWalletKitServer) EstimateFee(context.Context, *EstimateFeeReq func (UnimplementedWalletKitServer) PendingSweeps(context.Context, *PendingSweepsRequest) (*PendingSweepsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method PendingSweeps not implemented") } +func (UnimplementedWalletKitServer) RegisterSweepDescriptor(context.Context, *RegisterSweepDescriptorRequest) (*RegisterSweepDescriptorResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RegisterSweepDescriptor not implemented") +} +func (UnimplementedWalletKitServer) AddSweepDescriptorData(context.Context, *AddSweepDescriptorDataRequest) (*AddSweepDescriptorDataResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method AddSweepDescriptorData not implemented") +} +func (UnimplementedWalletKitServer) ListSweepDescriptors(context.Context, *ListSweepDescriptorsRequest) (*ListSweepDescriptorsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListSweepDescriptors not implemented") +} func (UnimplementedWalletKitServer) BumpFee(context.Context, *BumpFeeRequest) (*BumpFeeResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method BumpFee not implemented") } @@ -1342,6 +1420,60 @@ func _WalletKit_PendingSweeps_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _WalletKit_RegisterSweepDescriptor_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegisterSweepDescriptorRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletKitServer).RegisterSweepDescriptor(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/walletrpc.WalletKit/RegisterSweepDescriptor", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletKitServer).RegisterSweepDescriptor(ctx, req.(*RegisterSweepDescriptorRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WalletKit_AddSweepDescriptorData_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddSweepDescriptorDataRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletKitServer).AddSweepDescriptorData(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/walletrpc.WalletKit/AddSweepDescriptorData", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletKitServer).AddSweepDescriptorData(ctx, req.(*AddSweepDescriptorDataRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WalletKit_ListSweepDescriptors_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListSweepDescriptorsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WalletKitServer).ListSweepDescriptors(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/walletrpc.WalletKit/ListSweepDescriptors", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WalletKitServer).ListSweepDescriptors(ctx, req.(*ListSweepDescriptorsRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _WalletKit_BumpFee_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(BumpFeeRequest) if err := dec(in); err != nil { @@ -1563,6 +1695,18 @@ var WalletKit_ServiceDesc = grpc.ServiceDesc{ MethodName: "PendingSweeps", Handler: _WalletKit_PendingSweeps_Handler, }, + { + MethodName: "RegisterSweepDescriptor", + Handler: _WalletKit_RegisterSweepDescriptor_Handler, + }, + { + MethodName: "AddSweepDescriptorData", + Handler: _WalletKit_AddSweepDescriptorData_Handler, + }, + { + MethodName: "ListSweepDescriptors", + Handler: _WalletKit_ListSweepDescriptors_Handler, + }, { MethodName: "BumpFee", Handler: _WalletKit_BumpFee_Handler, diff --git a/lnrpc/walletrpc/walletkit_server.go b/lnrpc/walletrpc/walletkit_server.go index 8480f62d079..064c54a3270 100644 --- a/lnrpc/walletrpc/walletkit_server.go +++ b/lnrpc/walletrpc/walletkit_server.go @@ -47,6 +47,7 @@ import ( "github.com/lightningnetwork/lnd/lnwallet/chanfunding" "github.com/lightningnetwork/lnd/macaroons" "github.com/lightningnetwork/lnd/sweep" + "github.com/lightningnetwork/lnd/sweep/descriptorsweep" "google.golang.org/grpc" "gopkg.in/macaroon-bakery.v2/bakery" ) @@ -111,6 +112,18 @@ var ( Entity: "onchain", Action: "read", }}, + "/walletrpc.WalletKit/RegisterSweepDescriptor": {{ + Entity: "onchain", + Action: "write", + }}, + "/walletrpc.WalletKit/AddSweepDescriptorData": {{ + Entity: "onchain", + Action: "write", + }}, + "/walletrpc.WalletKit/ListSweepDescriptors": {{ + Entity: "onchain", + Action: "read", + }}, "/walletrpc.WalletKit/BumpFee": {{ Entity: "onchain", Action: "write", @@ -268,6 +281,8 @@ type WalletKit struct { UnimplementedWalletKitServer cfg *Config + + descriptorSweeper *descriptorsweep.Service } // A compile time check to ensure that WalletKit fully implements the @@ -315,8 +330,24 @@ func New(cfg *Config) (*WalletKit, lnrpc.MacaroonPerms, error) { } } - walletKit := &WalletKit{ - cfg: cfg, + walletKit := &WalletKit{cfg: cfg} + if cfg.DescriptorSweepDB != nil && cfg.ChainNotifier != nil && + cfg.KeyRing != nil && cfg.Sweeper != nil && cfg.Chain != nil && + cfg.ChainParams != nil && cfg.DescriptorSweepReady != nil { + + service, err := descriptorsweep.New(descriptorsweep.Config{ + DB: cfg.DescriptorSweepDB, + Notifier: cfg.ChainNotifier, + KeyRing: cfg.KeyRing, + Sweeper: cfg.Sweeper, + BlockSource: cfg.Chain, + ChainParams: cfg.ChainParams, + Ready: cfg.DescriptorSweepReady, + }) + if err != nil { + return nil, nil, err + } + walletKit.descriptorSweeper = service } return walletKit, macPermissions, nil @@ -326,6 +357,9 @@ func New(cfg *Config) (*WalletKit, lnrpc.MacaroonPerms, error) { // // NOTE: This is part of the lnrpc.SubServer interface. func (w *WalletKit) Start() error { + if w.descriptorSweeper != nil { + return w.descriptorSweeper.Start() + } return nil } @@ -333,9 +367,224 @@ func (w *WalletKit) Start() error { // // NOTE: This is part of the lnrpc.SubServer interface. func (w *WalletKit) Stop() error { + if w.descriptorSweeper != nil { + return w.descriptorSweeper.Stop() + } return nil } +// DescriptorSweeper returns the descriptor sweep service used by the RPC +// adapters. It is nil only in narrowly scoped WalletKit unit tests that do not +// construct the daemon dependencies. +func (w *WalletKit) DescriptorSweeper() *descriptorsweep.Service { + return w.descriptorSweeper +} + +// RegisterSweepDescriptor registers a fixed descriptor for discovery and +// automatic sweeping. +func (w *WalletKit) RegisterSweepDescriptor(ctx context.Context, + req *RegisterSweepDescriptorRequest) (*RegisterSweepDescriptorResponse, + error) { + + service, err := w.requireDescriptorSweeper() + if err != nil { + return nil, err + } + if req.BudgetSat > math.MaxInt64 { + return nil, errors.New("sweep budget exceeds maximum satoshi amount") + } + if req.ExpectedValueSat > math.MaxInt64 { + return nil, errors.New("expected value exceeds maximum satoshi amount") + } + + bindings := make([]descriptorsweep.KeyBinding, len(req.KeyBindings)) + for i, binding := range req.KeyBindings { + if binding == nil || binding.KeyLocator == nil { + return nil, fmt.Errorf("key binding %d has no key locator", i) + } + if binding.KeyLocator.KeyFamily < 0 { + return nil, fmt.Errorf("key binding %d has negative key family", i) + } + if binding.KeyLocator.KeyIndex < 0 { + return nil, fmt.Errorf("key binding %d has negative key index", i) + } + + bindings[i] = descriptorsweep.KeyBinding{ + DescriptorKey: binding.DescriptorKey, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily( + binding.KeyLocator.KeyFamily, + ), + Index: uint32(binding.KeyLocator.KeyIndex), + }, + } + } + + record, err := service.Register(ctx, descriptorsweep.RegisterRequest{ + Descriptor: req.OutputDescriptor, + DerivationIndex: req.DerivationIndex, + KeyBindings: bindings, + HeightHint: req.HeightHint, + MinConfs: req.MinConfs, + ExpectedValue: btcutil.Amount(req.ExpectedValueSat), + Budget: btcutil.Amount(req.BudgetSat), + DeadlineDelta: req.DeadlineDelta, + Immediate: req.Immediate, + Label: req.Label, + }) + if err != nil { + return nil, err + } + + return &RegisterSweepDescriptorResponse{ + RegistrationId: record.ID.Bytes(), + OutputDescriptor: record.CanonicalDescriptor, + Address: record.Address, + PkScript: append([]byte(nil), record.PkScript...), + }, nil +} + +// AddSweepDescriptorData supplies satisfaction data that became available +// after descriptor registration. +func (w *WalletKit) AddSweepDescriptorData(ctx context.Context, + req *AddSweepDescriptorDataRequest) (*AddSweepDescriptorDataResponse, + error) { + + service, err := w.requireDescriptorSweeper() + if err != nil { + return nil, err + } + id, err := descriptorsweep.RegistrationIDFromBytes(req.RegistrationId) + if err != nil { + return nil, err + } + + switch data := req.Data.(type) { + case *AddSweepDescriptorDataRequest_Preimage: + record, err := service.AddPreimage(ctx, id, data.Preimage) + if err != nil { + return nil, err + } + + return &AddSweepDescriptorDataResponse{ + Status: marshalSweepDescriptorStatus(record), + }, nil + + default: + return nil, errors.New("sweep descriptor data is required") + } +} + +// ListSweepDescriptors lists descriptor sweep registrations, optionally +// selecting a single registration by ID. +func (w *WalletKit) ListSweepDescriptors(_ context.Context, + req *ListSweepDescriptorsRequest) (*ListSweepDescriptorsResponse, error) { + + service, err := w.requireDescriptorSweeper() + if err != nil { + return nil, err + } + + var records []*descriptorsweep.Record + if len(req.RegistrationId) != 0 { + id, err := descriptorsweep.RegistrationIDFromBytes( + req.RegistrationId, + ) + if err != nil { + return nil, err + } + record, err := service.Get(id) + if err != nil { + return nil, err + } + records = []*descriptorsweep.Record{record} + } else { + records = service.List() + } + + resp := &ListSweepDescriptorsResponse{ + Descriptors: make([]*SweepDescriptorStatus, len(records)), + } + for i, record := range records { + resp.Descriptors[i] = marshalSweepDescriptorStatus(record) + } + + return resp, nil +} + +func (w *WalletKit) requireDescriptorSweeper() (*descriptorsweep.Service, + error) { + + if w.descriptorSweeper == nil { + return nil, errors.New("descriptor sweep service is unavailable") + } + + return w.descriptorSweeper, nil +} + +func marshalSweepDescriptorStatus( + record *descriptorsweep.Record) *SweepDescriptorStatus { + + bindings := make([]*SweepDescriptorKeyBinding, len(record.KeyBindings)) + for i, binding := range record.KeyBindings { + bindings[i] = &SweepDescriptorKeyBinding{ + DescriptorKey: binding.DescriptorKey, + KeyLocator: &signrpc.KeyLocator{ + KeyFamily: int32(binding.KeyLocator.Family), + KeyIndex: int32(binding.KeyLocator.Index), + }, + } + } + + result := &SweepDescriptorStatus{ + RegistrationId: record.ID.Bytes(), + OutputDescriptor: record.CanonicalDescriptor, + Address: record.Address, + PkScript: append([]byte(nil), record.PkScript...), + State: marshalSweepDescriptorState(record.Status), + FailureReason: record.Error, + HeightHint: record.HeightHint, + MinConfs: record.MinConfs, + ExpectedValueSat: uint64(record.ExpectedValue), + KeyBindings: bindings, + BudgetSat: uint64(record.Budget), + DeadlineDelta: record.DeadlineDelta, + Immediate: record.Immediate, + Label: record.Label, + } + if record.OutPoint != nil { + result.Outpoint = lnrpc.MarshalOutPoint(record.OutPoint) + } + if record.SweepTxID != nil { + result.SweepTxid = append([]byte(nil), record.SweepTxID[:]...) + } + + return result +} + +func marshalSweepDescriptorState( + status descriptorsweep.Status) SweepDescriptorState { + + switch status { + case descriptorsweep.StatusRegistered: + return SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_REGISTERED + case descriptorsweep.StatusWatching: + return SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_WATCHING + case descriptorsweep.StatusFound: + return SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_FOUND + case descriptorsweep.StatusWaiting: + return SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_WAITING + case descriptorsweep.StatusSweeping: + return SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_SWEEPING + case descriptorsweep.StatusSwept: + return SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_SWEPT + case descriptorsweep.StatusFailed: + return SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_FAILED + default: + return SweepDescriptorState_SWEEP_DESCRIPTOR_STATE_UNKNOWN + } +} + // Name returns a unique string representation of the sub-server. This can be // used to identify the sub-server and also de-duplicate them. // @@ -977,6 +1226,10 @@ func (w *WalletKit) PendingSweeps(ctx context.Context, rpcPendingSweeps := make([]*PendingSweep, 0, len(inputsMap)) for _, inp := range inputsMap { witnessType, ok := allWitnessTypes[inp.WitnessType] + if !ok && descriptorsweep.IsWitnessType(inp.WitnessType) { + witnessType = WitnessType_DESCRIPTOR_WSH + ok = true + } if !ok { return nil, fmt.Errorf("unhandled witness type %v for "+ "input %v", inp.WitnessType, inp.OutPoint) diff --git a/lnrpc/walletrpc/walletkit_server_test.go b/lnrpc/walletrpc/walletkit_server_test.go index 803c4d1ba71..2e090bfce9f 100644 --- a/lnrpc/walletrpc/walletkit_server_test.go +++ b/lnrpc/walletrpc/walletkit_server_test.go @@ -31,10 +31,10 @@ func TestWitnessTypeMapping(t *testing.T) { t.Parallel() // Tests that both enum types have the same length except the - // UNKNOWN_WITNESS type which is only present in the walletrpc - // witness type enum. + // UNKNOWN_WITNESS and DESCRIPTOR_WSH types, which don't correspond to + // concrete entries in the statically numbered input witness type enum. require.Equal( - t, len(allWitnessTypes), len(WitnessType_name)-1, + t, len(allWitnessTypes), len(WitnessType_name)-2, "number of witness types should match proto definition", ) diff --git a/lntest/rpc/wallet_kit.go b/lntest/rpc/wallet_kit.go index 1251555c210..18678822c9e 100644 --- a/lntest/rpc/wallet_kit.go +++ b/lntest/rpc/wallet_kit.go @@ -275,6 +275,45 @@ func (h *HarnessRPC) BumpFeeAssertErr(req *walletrpc.BumpFeeRequest) error { return err } +// RegisterSweepDescriptor registers a descriptor sweep and asserts success. +func (h *HarnessRPC) RegisterSweepDescriptor( + req *walletrpc.RegisterSweepDescriptorRequest) *walletrpc.RegisterSweepDescriptorResponse { + + ctxt, cancel := context.WithTimeout(h.runCtx, DefaultTimeout) + defer cancel() + + resp, err := h.WalletKit.RegisterSweepDescriptor(ctxt, req) + h.NoError(err, "RegisterSweepDescriptor") + + return resp +} + +// AddSweepDescriptorData adds late satisfaction data and asserts success. +func (h *HarnessRPC) AddSweepDescriptorData( + req *walletrpc.AddSweepDescriptorDataRequest) *walletrpc.AddSweepDescriptorDataResponse { + + ctxt, cancel := context.WithTimeout(h.runCtx, DefaultTimeout) + defer cancel() + + resp, err := h.WalletKit.AddSweepDescriptorData(ctxt, req) + h.NoError(err, "AddSweepDescriptorData") + + return resp +} + +// ListSweepDescriptors lists descriptor sweeps and asserts success. +func (h *HarnessRPC) ListSweepDescriptors( + req *walletrpc.ListSweepDescriptorsRequest) *walletrpc.ListSweepDescriptorsResponse { + + ctxt, cancel := context.WithTimeout(h.runCtx, DefaultTimeout) + defer cancel() + + resp, err := h.WalletKit.ListSweepDescriptors(ctxt, req) + h.NoError(err, "ListSweepDescriptors") + + return resp +} + // BumpForceCloseFee makes a RPC call to the node's WalletKitClient and asserts. // //nolint:ll diff --git a/rpcserver.go b/rpcserver.go index bfedfbf7169..a747101c351 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -798,7 +798,8 @@ func (r *rpcServer) addDeps(ctx context.Context, s *server, err = subServerCgs.PopulateDependencies( r.cfg, s.cc, r.cfg.networkDir, macService, atpl, invoiceRegistry, s.htlcSwitch, r.cfg.ActiveNetParams.Params, s.chanRouter, - routerBackend, s.nodeSigner, s.graphDB, s.chanStateDB, + routerBackend, s.nodeSigner, s.graphDB, s.chanStateDB, s.miscDB, + s.descriptorSweepReady, s.sweeper, tower, s.towerClientMgr, r.cfg.net.ResolveTCPAddr, genInvoiceFeatures, genAmpInvoiceFeatures, s.getNodeAnnouncement, s.updateAndBroadcastSelfNode, parseAddr, diff --git a/server.go b/server.go index a0312bb0146..7e158662441 100644 --- a/server.go +++ b/server.go @@ -378,6 +378,11 @@ type server struct { sweeper *sweep.UtxoSweeper + // descriptorSweepReady is closed once both the chain notifier and UTXO + // sweeper have started, allowing WalletKit's descriptor service to + // safely install watches and offer inputs. + descriptorSweepReady chan struct{} + chainArb *contractcourt.ChainArbitrator sphinxPayment *hop.OnionProcessor @@ -832,7 +837,8 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, peerConnectedListeners: make(map[string][]chan<- lnpeer.Peer), peerDisconnectedListeners: make(map[string][]chan<- struct{}), - invoiceHtlcModifier: invoiceHtlcModifier, + invoiceHtlcModifier: invoiceHtlcModifier, + descriptorSweepReady: make(chan struct{}), customMessageServer: subscribe.NewServer(), @@ -2380,6 +2386,7 @@ func (s *server) Start(ctx context.Context) error { startErr = err return } + close(s.descriptorSweepReady) cleanup = cleanup.add(s.utxoNursery.Stop) if err := s.utxoNursery.Start(); err != nil { diff --git a/subrpcserver_config.go b/subrpcserver_config.go index 8bd4b027017..316bb51a279 100644 --- a/subrpcserver_config.go +++ b/subrpcserver_config.go @@ -16,6 +16,7 @@ import ( graphdb "github.com/lightningnetwork/lnd/graph/db" "github.com/lightningnetwork/lnd/htlcswitch" "github.com/lightningnetwork/lnd/invoices" + "github.com/lightningnetwork/lnd/kvdb" "github.com/lightningnetwork/lnd/lncfg" "github.com/lightningnetwork/lnd/lnrpc/autopilotrpc" "github.com/lightningnetwork/lnd/lnrpc/chainrpc" @@ -115,7 +116,8 @@ func (s *subRPCServerConfigs) PopulateDependencies(cfg *Config, routerBackend *routerrpc.RouterBackend, nodeSigner *netann.NodeSigner, graphDB *graphdb.ChannelGraph, - chanStateDB chanstate.Store, + chanStateDB chanstate.Store, descriptorSweepDB kvdb.Backend, + descriptorSweepReady <-chan struct{}, sweeper *sweep.UtxoSweeper, tower *watchtower.Standalone, towerClientMgr *wtclient.Manager, @@ -195,6 +197,15 @@ func (s *subRPCServerConfigs) PopulateDependencies(cfg *Config, subCfgValue.FieldByName("Sweeper").Set( reflect.ValueOf(sweeper), ) + subCfgValue.FieldByName("ChainNotifier").Set( + reflect.ValueOf(cc.ChainNotifier), + ) + subCfgValue.FieldByName("DescriptorSweepDB").Set( + reflect.ValueOf(descriptorSweepDB), + ) + subCfgValue.FieldByName("DescriptorSweepReady").Set( + reflect.ValueOf(descriptorSweepReady), + ) subCfgValue.FieldByName("Chain").Set( reflect.ValueOf(cc.ChainIO), ) diff --git a/sweep/descriptorsweep/retry.go b/sweep/descriptorsweep/retry.go new file mode 100644 index 00000000000..b8fb72f9621 --- /dev/null +++ b/sweep/descriptorsweep/retry.go @@ -0,0 +1,209 @@ +package descriptorsweep + +import ( + "errors" + "fmt" + "time" +) + +type retryableError struct { + err error +} + +type deterministicError struct { + err error +} + +func (e *deterministicError) Error() string { return e.err.Error() } +func (e *deterministicError) Unwrap() error { return e.err } + +func deterministic(err error) error { + if err == nil { + return nil + } + return &deterministicError{err: err} +} + +func isDeterministic(err error) bool { + var target *deterministicError + return errors.As(err, &target) +} + +func (e *retryableError) Error() string { return e.err.Error() } +func (e *retryableError) Unwrap() error { return e.err } + +func retryable(err error) error { + if err == nil || isRetryable(err) { + return err + } + return &retryableError{err: err} +} + +func isRetryable(err error) bool { + var target *retryableError + return errors.As(err, &target) +} + +func (s *Service) retryBounds() (time.Duration, time.Duration) { + initial, maximum := s.retryInitial, s.retryMax + if initial <= 0 { + initial = defaultRetryInitial + } + if maximum < initial { + maximum = initial + } + return initial, maximum +} + +// scheduleRetry runs at most one retry worker for a registration and operation +// kind. Delays grow exponentially but are capped, and every wait is interruptible +// by Stop. +func (s *Service) scheduleRetry(key retryKey, task func() error, + onDeterministic func(error)) { + + s.mu.Lock() + select { + case <-s.quit: + s.mu.Unlock() + return + default: + } + if s.retrying == nil { + s.retrying = make(map[retryKey]bool) + } + if _, ok := s.retrying[key]; ok { + // The active worker will run the task again even if its current call + // succeeds. This closes the race where a newly attached result stream + // fails before the worker that attached it has exited. + s.retrying[key] = true + s.mu.Unlock() + return + } + s.retrying[key] = false + worker := func() { + defer s.wg.Done() + + initial, maximum := s.retryBounds() + backoff := initial + for { + timer := time.NewTimer(backoff) + select { + case <-timer.C: + case <-s.quit: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + s.mu.Lock() + delete(s.retrying, key) + s.mu.Unlock() + return + } + + err := task() + if err == nil { + s.mu.Lock() + pending := s.retrying[key] + if pending { + s.retrying[key] = false + s.mu.Unlock() + backoff = initial + continue + } + delete(s.retrying, key) + s.mu.Unlock() + return + } + if !isRetryable(err) { + s.mu.Lock() + delete(s.retrying, key) + s.mu.Unlock() + if onDeterministic != nil { + onDeterministic(err) + } + return + } + s.mu.Lock() + s.retrying[key] = false + s.mu.Unlock() + + if backoff < maximum { + backoff *= 2 + if backoff > maximum { + backoff = maximum + } + } + } + } + s.wg.Add(1) + s.mu.Unlock() + go worker() +} + +func (s *Service) handleRegistrationError(id RegistrationID, err error) { + if err == nil { + return + } + if !isRetryable(err) { + if !isDeterministic(err) { + err = retryable(err) + } else { + s.failDurably(id, err) + return + } + } + + s.scheduleRetry(retryKey{id: id, kind: "resume"}, func() error { + return s.resume(id) + }, func(err error) { + s.failDurably(id, err) + }) +} + +func (s *Service) failDurably(id RegistrationID, failure error) { + err := s.persistTransition(id, func(next *storedRecord) error { + next.Status = StatusFailed + next.Error = failure.Error() + return nil + }) + if err == nil { + return + } + + s.scheduleRetry(retryKey{id: id, kind: "persist-failure"}, func() error { + return s.persistTransition(id, func(next *storedRecord) error { + next.Status = StatusFailed + next.Error = failure.Error() + return nil + }) + }, nil) +} + +func (s *Service) persistTransition(id RegistrationID, + mutate func(*storedRecord) error) error { + + s.mu.Lock() + defer s.mu.Unlock() + _, err := s.updateRecordLocked(id, mutate) + return err +} + +func retryablef(format string, args ...interface{}) error { + return retryable(fmt.Errorf(format, args...)) +} + +func (s *Service) launch(worker func()) bool { + s.mu.Lock() + select { + case <-s.quit: + s.mu.Unlock() + return false + default: + s.wg.Add(1) + s.mu.Unlock() + go worker() + return true + } +} diff --git a/sweep/descriptorsweep/service.go b/sweep/descriptorsweep/service.go new file mode 100644 index 00000000000..17c410e2193 --- /dev/null +++ b/sweep/descriptorsweep/service.go @@ -0,0 +1,1162 @@ +package descriptorsweep + +import ( + "bytes" + "encoding/hex" + "errors" + "fmt" + "math" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/descriptors" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/sweep" + "github.com/lightningnetwork/lnd/tlv" +) + +func (s *Service) verifyBindings(desc *descriptors.Descriptor, + bindings []KeyBinding) error { + + keys := desc.Keys() + if len(bindings) != len(keys) { + return fmt.Errorf("descriptor has %d keys, got %d bindings", + len(keys), len(bindings)) + } + + remaining := make(map[string]struct{}, len(keys)) + for _, key := range keys { + remaining[key] = struct{}{} + } + for _, binding := range bindings { + if _, ok := remaining[binding.DescriptorKey]; !ok { + return fmt.Errorf("unknown or duplicate descriptor key %q", + binding.DescriptorKey) + } + + derived, err := s.cfg.KeyRing.DeriveKey(binding.KeyLocator) + if err != nil { + return fmt.Errorf("derive key %q: %w", binding.DescriptorKey, err) + } + if derived.PubKey == nil { + return fmt.Errorf("derived key %q has no public key", + binding.DescriptorKey) + } + + want, err := descriptorPubKey(binding.DescriptorKey) + if err != nil { + return fmt.Errorf("descriptor key %q: %w", + binding.DescriptorKey, err) + } + if !bytes.Equal(want, derived.PubKey.SerializeCompressed()) { + return fmt.Errorf("descriptor key %q does not match locator", + binding.DescriptorKey) + } + + delete(remaining, binding.DescriptorKey) + } + + return nil +} + +func descriptorPubKey(key string) ([]byte, error) { + // Fixed-index MVP bindings intentionally only accept a raw compressed key. + // Extended keys and origin paths are range-capable and need a derivation + // aware binding format before they can be safely accepted. + if len(key) != 66 { + return nil, errors.New("only raw compressed public keys are supported") + } + raw, err := hex.DecodeString(key) + if err != nil { + return nil, err + } + pubKey, err := btcec.ParsePubKey(raw) + if err != nil { + return nil, err + } + return pubKey.SerializeCompressed(), nil +} + +func rejectTimeLocks(desc *descriptors.Descriptor) error { + timelocks, err := desc.PotentialTimelocks() + if err != nil { + return fmt.Errorf("inspect descriptor timelocks: %w", err) + } + for _, timelock := range timelocks { + switch timelock.Type { + case descriptors.TimelockTypeAbsolute: + if timelock.Value >= txscript.LockTimeThreshold { + return errors.New("time-based CLTV is not supported") + } + + case descriptors.TimelockTypeRelative: + if timelock.Value&wire.SequenceLockTimeIsSeconds != 0 { + return errors.New("time-based CSV is not supported") + } + } + } + return nil +} + +func (s *Service) resume(id RegistrationID) error { + select { + case <-s.quit: + return nil + default: + } + + s.mu.RLock() + stored, ok := s.records[id] + if !ok { + s.mu.RUnlock() + return ErrNotFound + } + record := stored.snapshot() + blockScan := stored.BlockScan + s.mu.RUnlock() + + switch record.Status { + case StatusSwept, StatusFailed: + return nil + case StatusSweeping: + // SweepInput is intentionally idempotent for an already-known + // outpoint. Rebuild the exact frozen input after restart. + err := s.restoreSweep(id) + if err == nil || isRetryable(err) { + return err + } + return deterministic(err) + default: + if record.OutPoint != nil { + return s.trySweep(id) + } + if blockScan { + s.mu.RLock() + bestHeight := s.bestHeight + s.mu.RUnlock() + return s.scanMatureBlocks(id, bestHeight) + } + return s.watchOutput(id) + } +} + +// waitForReady defers all notifier calls until the daemon has started both the +// notifier and sweeper. RegisterBlockEpochNtfn is retried because Start has +// already returned by this point and a transient notifier error must not leave +// durable registrations inert until the next daemon restart. +func (s *Service) waitForReady() { + defer s.wg.Done() + + select { + case <-s.cfg.Ready: + case <-s.quit: + return + } + + var epochs *chainntnfs.BlockEpochEvent + for { + select { + case <-s.quit: + return + default: + } + + var err error + epochs, err = s.cfg.Notifier.RegisterBlockEpochNtfn(nil) + if err == nil { + // A nil best block asks the notifier to send its current tip + // immediately. Consume that tip before restoring registrations so + // CLTV/CSV scheduling and frozen fee deadlines never start from + // height zero after a restart. + for { + select { + case epoch, ok := <-epochs.Epochs: + if !ok { + epochs.Cancel() + err = errors.New("block epoch stream closed before current tip") + break + } + if epoch == nil || epoch.Height < 0 { + continue + } + + s.mu.Lock() + s.bestHeight = uint32(epoch.Height) + s.mu.Unlock() + err = nil + + case <-s.quit: + epochs.Cancel() + return + } + break + } + if err == nil { + break + } + } + + select { + case <-time.After(time.Second): + case <-s.quit: + return + } + } + + // Keep notifierReady false while draining registrations. Register and + // AddPreimage only persist and mark the ID pending in that state. Taking + // and clearing pending under the same lock used to publish readiness + // ensures data added while an ID is being resumed triggers another pass. + for { + s.mu.Lock() + if len(s.pending) == 0 { + s.notifierReady = true + s.mu.Unlock() + break + } + ids := make([]RegistrationID, 0, len(s.pending)) + for id := range s.pending { + ids = append(ids, id) + delete(s.pending, id) + } + s.mu.Unlock() + + for _, id := range ids { + if err := s.resume(id); err != nil { + s.handleRegistrationError(id, err) + } + } + } + + s.consumeEpochs(epochs) +} + +func (s *Service) restoreSweep(id RegistrationID) error { + s.mu.RLock() + record, ok := s.records[id] + if !ok { + s.mu.RUnlock() + return ErrNotFound + } + if record.OutPoint == nil { + s.mu.RUnlock() + return deterministic(errors.New( + "frozen descriptor sweep has no outpoint", + )) + } + frozen := record.cloneForInput() + s.mu.RUnlock() + + desc, err := descriptors.NewDescriptor(frozen.CanonicalDescriptor) + if err != nil { + return deterministic(err) + } + assets := makeFrozenAssets(frozen) + plan, err := desc.PlanAt(0, frozen.DerivationIndex, assets) + if err != nil { + return deterministic(fmt.Errorf( + "restore frozen descriptor plan: %w", err, + )) + } + constraints := plan.TxConstraints() + if constraints.MinTxVersion > 2 { + return deterministic(fmt.Errorf( + "frozen path requires transaction version %d", + constraints.MinTxVersion, + )) + } + if !sameOptionalUint32(constraints.AbsoluteLocktime, + frozen.PlanLocktime) || !sameOptionalUint32( + constraints.RelativeLocktime, frozen.PlanSequence, + ) { + + return deterministic(errors.New( + "restored descriptor plan changed frozen branch", + )) + } + + inp, err := newDescriptorInput(desc, plan, frozen) + if err != nil { + return deterministic(err) + } + params := sweep.Params{ + Budget: frozen.Budget, + Immediate: frozen.Immediate, + } + if frozen.HasStartingFeeRate { + params.StartingFeeRate = fn.Some(frozen.StartingFeeRate) + } + if frozen.PlanDeadlineHeight != nil { + params.DeadlineHeight = fn.Some(*frozen.PlanDeadlineHeight) + } + result, err := s.cfg.Sweeper.SweepInput(inp, params) + if err != nil { + return retryablef("restore descriptor sweep input: %w", err) + } + s.launch(func() { s.consumeSweepResult(id, result) }) + return nil +} + +func (s *Service) watchOutput(id RegistrationID) error { + select { + case <-s.quit: + return nil + default: + } + + // Serialize installation per registration. In particular this makes the + // transition from the initial readiness drain to live registration + // idempotent even when both paths race for the same ID. + s.mu.Lock() + record, ok := s.records[id] + if !ok { + s.mu.Unlock() + return ErrNotFound + } + if _, ok := s.watches[id]; ok { + s.mu.Unlock() + return nil + } + pkScript := append([]byte(nil), record.PkScript...) + minConfs, heightHint := record.MinConfs, record.WatchHeight + if heightHint == 0 { + heightHint = record.HeightHint + } + + event, err := s.cfg.Notifier.RegisterConfirmationsNtfn( + nil, pkScript, minConfs, heightHint, chainntnfs.WithIncludeBlock(), + ) + if err != nil { + s.mu.Unlock() + return retryablef("register descriptor confirmation: %w", err) + } + + _, err = s.updateRecordLocked(id, func(next *storedRecord) error { + next.Status = StatusWatching + return nil + }) + if err != nil { + s.mu.Unlock() + event.Cancel() + return err + } + s.watches[id] = event.Cancel + s.wg.Add(1) + s.mu.Unlock() + + go s.consumeConfirmation(id, event) + return nil +} + +func (s *Service) consumeConfirmation(id RegistrationID, + event *chainntnfs.ConfirmationEvent) { + + defer s.wg.Done() + select { + case conf, ok := <-event.Confirmed: + if !ok || conf == nil { + s.detachWatch(id) + s.handleRegistrationError(id, retryable(errors.New( + "descriptor confirmation stream closed", + ))) + return + } + if err := s.outputConfirmed(id, conf); err != nil { + s.handleRegistrationError(id, err) + } + + case <-s.quit: + return + } +} + +func (s *Service) outputConfirmed(id RegistrationID, + conf *chainntnfs.TxConfirmation) error { + + s.mu.Lock() + record, ok := s.records[id] + if !ok { + s.mu.Unlock() + return ErrNotFound + } + if cancel, ok := s.watches[id]; ok { + cancel() + delete(s.watches, id) + } + + if conf.Block == nil { + s.mu.Unlock() + return retryable(errors.New( + "descriptor confirmation did not include its block", + )) + } + match, err := findExactOutput( + conf.Block, record.PkScript, int64(record.ExpectedValue), + ) + if err != nil { + s.mu.Unlock() + return deterministic(err) + } + if match == nil { + if conf.BlockHeight == math.MaxUint32 { + s.mu.Unlock() + return deterministic(errors.New( + "descriptor watch height overflow", + )) + } + + // A script-only notifier may first report an output with a value + // chosen by an unrelated party. Advance past that block and persist + // the scan cursor; re-registering the same script can replay the + // notifier's cached match forever. + bestHeight := s.bestHeight + _, err := s.updateRecordLocked(id, func(next *storedRecord) error { + next.WatchHeight = conf.BlockHeight + 1 + next.BlockScan = true + next.Status = StatusWatching + return nil + }) + if err != nil { + s.mu.Unlock() + return err + } + s.mu.Unlock() + + return s.scanMatureBlocks(id, bestHeight) + } + + txid := match.tx.TxHash() + op := wire.OutPoint{Hash: txid, Index: match.outputIndex} + _, err = s.updateRecordLocked(id, func(next *storedRecord) error { + next.OutPoint = &op + next.Value = match.tx.TxOut[match.outputIndex].Value + next.ConfirmationHeight = conf.BlockHeight + next.BlockScan = false + next.Status = StatusFound + return nil + }) + if err != nil { + s.mu.Unlock() + return err + } + s.mu.Unlock() + + return s.trySweep(id) +} + +func (s *Service) detachWatch(id RegistrationID) { + s.mu.Lock() + defer s.mu.Unlock() + + if cancel, ok := s.watches[id]; ok { + cancel() + delete(s.watches, id) + } +} + +type exactOutputMatch struct { + tx *wire.MsgTx + outputIndex uint32 +} + +func findExactOutput(block *wire.MsgBlock, pkScript []byte, + expectedValue int64) (*exactOutputMatch, error) { + + if block == nil { + return nil, errors.New("descriptor confirmation did not include its block") + } + + var match *exactOutputMatch + for _, tx := range block.Transactions { + if tx == nil { + continue + } + for outputIndex, output := range tx.TxOut { + if output == nil || output.Value != expectedValue || + !bytes.Equal(output.PkScript, pkScript) { + + continue + } + if match != nil { + return nil, errors.New("confirmed block has multiple exact descriptor outputs") + } + match = &exactOutputMatch{ + tx: tx, + outputIndex: uint32(outputIndex), + } + } + } + + return match, nil +} + +func (s *Service) scanMatureBlocks(id RegistrationID, + bestHeight uint32) error { + select { + case <-s.quit: + return nil + default: + } + + // Readiness restore and block epochs can overlap briefly. Serializing the + // range scan prevents duplicate block work and duplicate sweeper offers. + s.scanMu.Lock() + defer s.scanMu.Unlock() + + s.mu.RLock() + record, ok := s.records[id] + if !ok { + s.mu.RUnlock() + return ErrNotFound + } + if !record.BlockScan || record.Status == StatusFailed || + record.Status == StatusSwept || record.OutPoint != nil { + + s.mu.RUnlock() + return nil + } + if bestHeight+1 < record.MinConfs { + s.mu.RUnlock() + return nil + } + startHeight := record.WatchHeight + matureThrough := bestHeight - (record.MinConfs - 1) + pkScript := append([]byte(nil), record.PkScript...) + expectedValue := int64(record.ExpectedValue) + s.mu.RUnlock() + + if startHeight > matureThrough { + return nil + } + + for height := startHeight; height <= matureThrough; height++ { + blockHash, err := s.cfg.BlockSource.GetBlockHash(int64(height)) + if err != nil { + return retryablef("get descriptor scan block %d hash: %w", + height, err) + } + block, err := s.cfg.BlockSource.GetBlock(blockHash) + if err != nil { + return retryablef("get descriptor scan block %d: %w", + height, err) + } + match, err := findExactOutput(block, pkScript, expectedValue) + if err != nil { + return deterministic(err) + } + + s.mu.Lock() + record, ok := s.records[id] + if !ok { + s.mu.Unlock() + return ErrNotFound + } + if !record.BlockScan || record.Status == StatusFailed || + record.Status == StatusSwept || record.OutPoint != nil { + + s.mu.Unlock() + return nil + } + + if match == nil { + if height == math.MaxUint32 { + s.mu.Unlock() + return deterministic(errors.New( + "descriptor scan height overflow", + )) + } + _, err := s.updateRecordLocked( + id, func(next *storedRecord) error { + next.WatchHeight = height + 1 + return nil + }, + ) + if err != nil { + s.mu.Unlock() + return err + } + s.mu.Unlock() + continue + } + + txid := match.tx.TxHash() + op := wire.OutPoint{Hash: txid, Index: match.outputIndex} + _, err = s.updateRecordLocked(id, func(next *storedRecord) error { + next.OutPoint = &op + next.Value = match.tx.TxOut[match.outputIndex].Value + next.ConfirmationHeight = height + next.BlockScan = false + next.Status = StatusFound + return nil + }) + if err != nil { + s.mu.Unlock() + return err + } + s.mu.Unlock() + + return s.trySweep(id) + } + + return nil +} + +func (s *Service) consumeEpochs(event *chainntnfs.BlockEpochEvent) { + defer func() { event.Cancel() }() + + for { + select { + case epoch, ok := <-event.Epochs: + if !ok { + event.Cancel() + replacement := s.reconnectEpochs() + if replacement == nil { + return + } + event = replacement + continue + } + if epoch == nil || epoch.Height < 0 { + continue + } + s.mu.Lock() + s.bestHeight = uint32(epoch.Height) + ids := make([]RegistrationID, 0, len(s.records)) + scanIDs := make([]RegistrationID, 0, len(s.records)) + for id, record := range s.records { + if record.BlockScan && record.Status != StatusFailed && + record.Status != StatusSwept { + + scanIDs = append(scanIDs, id) + } + if record.OutPoint != nil && + record.Status != StatusSwept && + record.Status != StatusSweeping && + record.Status != StatusFailed { + + ids = append(ids, id) + } + } + s.mu.Unlock() + for _, id := range scanIDs { + if err := s.scanMatureBlocks( + id, uint32(epoch.Height), + ); err != nil { + + s.handleRegistrationError(id, err) + } + } + for _, id := range ids { + if err := s.trySweep(id); err != nil { + s.handleRegistrationError(id, err) + } + } + + case <-s.quit: + return + } + } +} + +func (s *Service) reconnectEpochs() *chainntnfs.BlockEpochEvent { + backoff, maximum := s.retryBounds() + for { + select { + case <-s.quit: + return nil + default: + } + + event, err := s.cfg.Notifier.RegisterBlockEpochNtfn(nil) + if err == nil { + return event + } + + timer := time.NewTimer(backoff) + select { + case <-timer.C: + case <-s.quit: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return nil + } + if backoff < maximum { + backoff *= 2 + if backoff > maximum { + backoff = maximum + } + } + } +} + +func (s *Service) trySweep(id RegistrationID) error { + select { + case <-s.quit: + return nil + default: + } + + s.mu.Lock() + record, ok := s.records[id] + if !ok { + s.mu.Unlock() + return ErrNotFound + } + if record.OutPoint == nil || record.Status == StatusSweeping || + record.Status == StatusSwept || record.Status == StatusFailed { + + s.mu.Unlock() + return nil + } + + desc, err := descriptors.NewDescriptor(record.CanonicalDescriptor) + if err != nil { + s.mu.Unlock() + return deterministic(err) + } + + // Prefer an immediately satisfiable branch without making any timeout + // available. This makes a supplied preimage win over an already-mature + // timeout even when the timeout witness is smaller. Only when no such + // branch exists do we expose mature CLTV/CSV candidates to the planner. + assets := makeAssets(record, 0) + plan, err := desc.PlanAt(0, record.DerivationIndex, assets) + if err != nil { + assets = makeAssets(record, s.bestHeight) + plan, err = desc.PlanAt(0, record.DerivationIndex, assets) + } + if err != nil { + _, storeErr := s.updateRecordLocked( + id, func(next *storedRecord) error { + next.Status = StatusWaiting + return nil + }, + ) + s.mu.Unlock() + return storeErr + } + + constraints := plan.TxConstraints() + if constraints.MinTxVersion > 2 { + s.mu.Unlock() + return deterministic(fmt.Errorf( + "selected path requires transaction version %d", + constraints.MinTxVersion, + )) + } + if constraints.AbsoluteLocktime != nil && + *constraints.AbsoluteLocktime >= txscript.LockTimeThreshold { + + s.mu.Unlock() + return deterministic(errors.New("selected path has time-based CLTV")) + } + if constraints.RelativeLocktime != nil && + *constraints.RelativeLocktime&wire.SequenceLockTimeIsSeconds != 0 { + + s.mu.Unlock() + return deterministic(errors.New("selected path has time-based CSV")) + } + + // Freeze all witness material and the exact branch before giving the + // input to UtxoSweeper. The only mutable object after this point is the + // durable lifecycle record, never the input implementation. + frozen := record.cloneForInput() + bestHeight := s.bestHeight + var deadline *int32 + if record.DeadlineDelta > 0 { + if bestHeight > math.MaxInt32-record.DeadlineDelta { + s.mu.Unlock() + return deterministic(errors.New( + "selected sweep deadline exceeds maximum block height", + )) + } + value := int32(bestHeight + record.DeadlineDelta) + deadline = &value + } + next, err := s.updateRecordLocked(id, func(next *storedRecord) error { + next.PlanLocktime = cloneUint32(constraints.AbsoluteLocktime) + next.PlanSequence = cloneUint32(constraints.RelativeLocktime) + next.PlanDeadlineHeight = cloneInt32(deadline) + next.Status = StatusSweeping + return nil + }) + if err != nil { + s.mu.Unlock() + return err + } + frozen = next.cloneForInput() + s.mu.Unlock() + + inp, err := newDescriptorInput(desc, plan, frozen) + if err != nil { + return deterministic(err) + } + params := sweep.Params{ + Budget: frozen.Budget, + Immediate: frozen.Immediate, + } + if frozen.HasStartingFeeRate { + params.StartingFeeRate = fn.Some(frozen.StartingFeeRate) + } + if frozen.PlanDeadlineHeight != nil { + params.DeadlineHeight = fn.Some(*frozen.PlanDeadlineHeight) + } + + result, err := s.cfg.Sweeper.SweepInput(inp, params) + if err != nil { + return retryablef("offer descriptor sweep input: %w", err) + } + + s.launch(func() { s.consumeSweepResult(id, result) }) + return nil +} + +func (s *Service) consumeSweepResult(id RegistrationID, + result <-chan sweep.Result) { + + defer s.wg.Done() + select { + case sweepResult, ok := <-result: + if !ok { + s.handleRegistrationError(id, retryable(errors.New( + "descriptor sweep result stream closed", + ))) + return + } + var persistErr error + if sweepResult.Err != nil { + // A sweeper result can represent a transient publisher or + // backend failure. Re-offer the exact frozen branch unless the + // input was definitively spent by somebody else. + if errors.Is(sweepResult.Err, sweep.ErrRemoteSpend) || + errors.Is(sweepResult.Err, sweep.ErrExclusiveGroupSpend) { + + s.failDurably(id, sweepResult.Err) + return + } + persistErr = retryablef( + "descriptor sweep result: %w", sweepResult.Err, + ) + } else { + persistSuccess := func() error { + return s.persistTransition(id, func(next *storedRecord) error { + next.Status = StatusSwept + next.Error = "" + if sweepResult.Tx != nil { + txid := sweepResult.Tx.TxHash() + next.SweepTxID = &txid + } + return nil + }) + } + persistErr = persistSuccess() + if persistErr != nil { + s.scheduleRetry( + retryKey{id: id, kind: "persist-success"}, + persistSuccess, nil, + ) + return + } + } + if persistErr != nil { + s.handleRegistrationError(id, persistErr) + } + + case <-s.quit: + return + } +} + +func makeAssets(record *storedRecord, bestHeight uint32) descriptors.Assets { + availableKeys := make(map[string]struct{}, len(record.KeyBindings)) + for _, binding := range record.KeyBindings { + availableKeys[binding.DescriptorKey] = struct{}{} + } + + // Candidate lock values expose every currently mature branch to PlanAt. + // The returned Plan.TxConstraints then freezes only the selected branch. + var absolute, relative *uint32 + if bestHeight > 0 { + absolute = &bestHeight + } + if record.ConfirmationHeight > 0 && bestHeight+1 >= + record.ConfirmationHeight { + + value := bestHeight + 1 - record.ConfirmationHeight + relative = &value + } + + return descriptors.Assets{ + LookupEcdsaSig: func(key string) bool { + _, ok := availableKeys[key] + return ok + }, + LookupPreimage: func(hashFunc string, hash []byte) bool { + _, ok := record.Preimages[preimageKey(hashFunc, hash)] + return ok + }, + AbsoluteLocktime: absolute, + RelativeLocktime: relative, + } +} + +func makeFrozenAssets(record *storedRecord) descriptors.Assets { + assets := makeAssets(record, 0) + assets.AbsoluteLocktime = cloneUint32(record.PlanLocktime) + assets.RelativeLocktime = cloneUint32(record.PlanSequence) + return assets +} + +func sameOptionalUint32(a, b *uint32) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +func preimageKey(hashFunc string, hash []byte) string { + return hashFunc + ":" + hex.EncodeToString(hash) +} + +func (r *storedRecord) cloneForInput() *storedRecord { + result := *r + result.Record = *r.snapshot() + result.Preimages = make(map[string][]byte, len(r.Preimages)) + for key, preimage := range r.Preimages { + result.Preimages[key] = append([]byte(nil), preimage...) + } + result.PlanLocktime = cloneUint32(r.PlanLocktime) + result.PlanSequence = cloneUint32(r.PlanSequence) + result.PlanDeadlineHeight = cloneInt32(r.PlanDeadlineHeight) + return &result +} + +func cloneUint32(value *uint32) *uint32 { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneInt32(value *int32) *int32 { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +type descriptorInput struct { + op wire.OutPoint + signDesc input.SignDescriptor + witnessType *descriptorWitnessType + heightHint uint32 + sequence uint32 + locktime *uint32 + confirmation uint32 + preimage fn.Option[lntypes.Preimage] +} + +func newDescriptorInput(desc *descriptors.Descriptor, plan *descriptors.Plan, + record *storedRecord) (*descriptorInput, error) { + + if record.OutPoint == nil { + return nil, errors.New("descriptor input has no outpoint") + } + witnessType, err := newDescriptorWitnessType(desc, plan, record) + if err != nil { + return nil, err + } + + return &descriptorInput{ + op: *record.OutPoint, + signDesc: input.SignDescriptor{ + WitnessScript: append([]byte(nil), record.WitnessScript...), + Output: &wire.TxOut{ + Value: record.Value, + PkScript: append([]byte(nil), record.PkScript...), + }, + HashType: txscript.SigHashAll, + SignMethod: input.WitnessV0SignMethod, + }, + witnessType: witnessType, + heightHint: record.ConfirmationHeight, + sequence: valueOrZero(record.PlanSequence), + locktime: cloneUint32(record.PlanLocktime), + confirmation: record.ConfirmationHeight, + preimage: firstPreimage(record.Preimages), + }, nil +} + +func firstPreimage(preimages map[string][]byte) fn.Option[lntypes.Preimage] { + for _, raw := range preimages { + var preimage lntypes.Preimage + copy(preimage[:], raw) + return fn.Some(preimage) + } + return fn.None[lntypes.Preimage]() +} + +func valueOrZero(value *uint32) uint32 { + if value == nil { + return 0 + } + return *value +} + +func (i *descriptorInput) OutPoint() wire.OutPoint { return i.op } +func (i *descriptorInput) RequiredTxOut() *wire.TxOut { return nil } +func (i *descriptorInput) RequiredLockTime() (uint32, bool) { + if i.locktime == nil { + return 0, false + } + return *i.locktime, true +} +func (i *descriptorInput) WitnessType() input.WitnessType { return i.witnessType } +func (i *descriptorInput) SignDesc() *input.SignDescriptor { return &i.signDesc } +func (i *descriptorInput) CraftInputScript(signer input.Signer, + tx *wire.MsgTx, hashes *txscript.TxSigHashes, + fetcher txscript.PrevOutputFetcher, index int) (*input.Script, error) { + + i.signDesc.SigHashes = hashes + i.signDesc.PrevOutputFetcher = fetcher + i.signDesc.InputIndex = index + return i.witnessType.craft(signer, &i.signDesc, tx, index) +} +func (i *descriptorInput) BlocksToMaturity() uint32 { return i.sequence } +func (i *descriptorInput) HeightHint() uint32 { return i.heightHint } +func (i *descriptorInput) UnconfParent() *input.TxInfo { return nil } +func (i *descriptorInput) ResolutionBlob() fn.Option[tlv.Blob] { + return fn.None[tlv.Blob]() +} +func (i *descriptorInput) Preimage() fn.Option[lntypes.Preimage] { + return i.preimage +} + +type descriptorWitnessType struct { + desc *descriptors.Descriptor + plan *descriptors.Plan + bindings map[string]keychain.KeyDescriptor + preimages map[string][]byte + witnessScript []byte + witnessSize lntypes.WeightUnit +} + +// IsWitnessType reports whether a sweeper witness belongs to this service. +// WalletKit uses this to render custom pending inputs without failing its +// standard witness enum conversion. +func IsWitnessType(witness input.WitnessType) bool { + _, ok := witness.(*descriptorWitnessType) + return ok +} + +func newDescriptorWitnessType(desc *descriptors.Descriptor, + plan *descriptors.Plan, record *storedRecord) (*descriptorWitnessType, error) { + + bindings := make(map[string]keychain.KeyDescriptor, len(record.KeyBindings)) + for _, binding := range record.KeyBindings { + pubKeyBytes, err := descriptorPubKey(binding.DescriptorKey) + if err != nil { + return nil, err + } + pubKey, err := btcec.ParsePubKey(pubKeyBytes) + if err != nil { + return nil, err + } + bindings[binding.DescriptorKey] = keychain.KeyDescriptor{ + KeyLocator: binding.KeyLocator, + PubKey: pubKey, + } + } + + maxWeight, err := desc.MaxWeightToSatisfy() + if err != nil { + return nil, err + } + return &descriptorWitnessType{ + desc: desc, + plan: plan, + bindings: bindings, + preimages: record.Preimages, + witnessScript: append([]byte(nil), record.WitnessScript...), + // MaxWeightToSatisfy is relative to an empty witness. lnd expects + // the complete serialized witness, including the element-count byte. + witnessSize: lntypes.WeightUnit(maxWeight + 1), + }, nil +} + +func (w *descriptorWitnessType) String() string { return "descriptor-wsh" } +func (w *descriptorWitnessType) WitnessGenerator(signer input.Signer, + desc *input.SignDescriptor) input.WitnessGenerator { + + return func(tx *wire.MsgTx, _ *txscript.TxSigHashes, + index int) (*input.Script, error) { + + return w.craft(signer, desc, tx, index) + } +} +func (w *descriptorWitnessType) SizeUpperBound() (lntypes.WeightUnit, bool, + error) { + + return w.witnessSize, false, nil +} +func (w *descriptorWitnessType) AddWeightEstimation( + estimator *input.TxWeightEstimator) error { + + estimator.AddWitnessInput(w.witnessSize) + return nil +} + +func (w *descriptorWitnessType) craft(signer input.Signer, + signDesc *input.SignDescriptor, tx *wire.MsgTx, + index int) (*input.Script, error) { + + satisfier := &descriptors.Satisfier{ + LookupEcdsaSig: func(key string) ([]byte, bool) { + keyDesc, ok := w.bindings[key] + if !ok { + return nil, false + } + local := *signDesc + local.KeyDesc = keyDesc + local.InputIndex = index + signature, err := signer.SignOutputRaw(tx, &local) + if err != nil { + return nil, false + } + serialized := signature.Serialize() + serialized = append(serialized, byte(local.HashType)) + return serialized, true + }, + LookupPreimage: func(hashFunc string, hash []byte) ([]byte, bool) { + preimage, ok := w.preimages[preimageKey(hashFunc, hash)] + return append([]byte(nil), preimage...), ok + }, + } + + result, err := w.plan.Satisfy(satisfier) + if err != nil { + return nil, err + } + witness := append(wire.TxWitness{}, result.Witness...) + witness = append(witness, append([]byte(nil), w.witnessScript...)) + + return &input.Script{ + Witness: witness, + SigScript: result.ScriptSig, + }, nil +} + +var _ input.Input = (*descriptorInput)(nil) +var _ input.WitnessType = (*descriptorWitnessType)(nil) diff --git a/sweep/descriptorsweep/service_test.go b/sweep/descriptorsweep/service_test.go new file mode 100644 index 00000000000..f3752e72a7f --- /dev/null +++ b/sweep/descriptorsweep/service_test.go @@ -0,0 +1,1265 @@ +package descriptorsweep + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/descriptors" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/kvdb" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/sweep" + "github.com/stretchr/testify/require" +) + +type testKeyRing struct { + keys map[keychain.KeyLocator]*btcec.PublicKey +} + +func (k *testKeyRing) DeriveNextKey(keychain.KeyFamily) ( + keychain.KeyDescriptor, error) { + + return keychain.KeyDescriptor{}, fmt.Errorf("not implemented") +} + +func (k *testKeyRing) DeriveKey(locator keychain.KeyLocator) ( + keychain.KeyDescriptor, error) { + + key, ok := k.keys[locator] + if !ok { + return keychain.KeyDescriptor{}, fmt.Errorf("key not found") + } + return keychain.KeyDescriptor{KeyLocator: locator, PubKey: key}, nil +} + +type testSweeper struct { + inputs chan input.Input +} + +func (s *testSweeper) SweepInput(inp input.Input, _ sweep.Params) ( + chan sweep.Result, error) { + + s.inputs <- inp + return make(chan sweep.Result, 1), nil +} + +type restartSweeper struct { + input input.Input + params sweep.Params +} + +type failOnceStore struct { + mu sync.Mutex + delegate recordStore + failures int +} + +func (s *failOnceStore) init() error { + return s.delegate.init() +} + +func (s *failOnceStore) put(record *storedRecord) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.failures > 0 { + s.failures-- + return errors.New("transient store failure") + } + return s.delegate.put(record) +} + +func (s *failOnceStore) list() ([]*storedRecord, error) { + return s.delegate.list() +} + +type testBlockSource struct { + mu sync.Mutex + + hashes map[int64]chainhash.Hash + blocks map[chainhash.Hash]*wire.MsgBlock + hashFailures map[int64]int +} + +func newTestBlockSource() *testBlockSource { + return &testBlockSource{ + hashes: make(map[int64]chainhash.Hash), + blocks: make(map[chainhash.Hash]*wire.MsgBlock), + hashFailures: make(map[int64]int), + } +} + +func (s *testBlockSource) failHash(height int64, count int) { + s.mu.Lock() + defer s.mu.Unlock() + s.hashFailures[height] = count +} + +func (s *testBlockSource) add(height int64, block *wire.MsgBlock) { + s.mu.Lock() + defer s.mu.Unlock() + + hash := chainhash.Hash{byte(height), byte(height >> 8)} + s.hashes[height] = hash + s.blocks[hash] = block +} + +func (s *testBlockSource) GetBlockHash(height int64) (*chainhash.Hash, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.hashFailures[height] > 0 { + s.hashFailures[height]-- + return nil, errors.New("transient block hash failure") + } + + hash, ok := s.hashes[height] + if !ok { + return nil, fmt.Errorf("block %d not found", height) + } + return &hash, nil +} + +func (s *testBlockSource) GetBlock( + hash *chainhash.Hash) (*wire.MsgBlock, error) { + + s.mu.Lock() + defer s.mu.Unlock() + + block, ok := s.blocks[*hash] + if !ok { + return nil, fmt.Errorf("block %v not found", hash) + } + return block.Copy(), nil +} + +type readyTestNotifier struct { + mu sync.Mutex + + epochCalls int + confCalls map[string]int + confTotal int + confCancel int + epochCancel int + + epochRegistered chan struct{} + confRegistered chan []byte + confEvents chan *chainntnfs.ConfirmationEvent + confHeightHints []uint32 + blockFirstConf <-chan struct{} + blockEpochs chan *chainntnfs.BlockEpoch + confFailures int +} + +func (n *readyTestNotifier) failConfirmations(count int) { + n.mu.Lock() + defer n.mu.Unlock() + n.confFailures = count +} + +func newReadyTestNotifier() *readyTestNotifier { + notifier := &readyTestNotifier{ + confCalls: make(map[string]int), + epochRegistered: make(chan struct{}, 1), + confRegistered: make(chan []byte, 10), + confEvents: make(chan *chainntnfs.ConfirmationEvent, 10), + blockEpochs: make(chan *chainntnfs.BlockEpoch, 1), + } + notifier.blockEpochs <- &chainntnfs.BlockEpoch{Height: 100} + return notifier +} + +func (n *readyTestNotifier) RegisterConfirmationsNtfn(_ *chainhash.Hash, + pkScript []byte, numConfs, heightHint uint32, + _ ...chainntnfs.NotifierOption) ( + *chainntnfs.ConfirmationEvent, error) { + + n.mu.Lock() + n.confTotal++ + call := n.confTotal + n.confCalls[string(pkScript)]++ + n.confHeightHints = append(n.confHeightHints, heightHint) + if n.confFailures > 0 { + n.confFailures-- + n.mu.Unlock() + return nil, errors.New("transient confirmation registration failure") + } + n.mu.Unlock() + + n.confRegistered <- append([]byte(nil), pkScript...) + if call == 1 && n.blockFirstConf != nil { + <-n.blockFirstConf + } + + event := chainntnfs.NewConfirmationEvent(numConfs, func() { + n.mu.Lock() + n.confCancel++ + n.mu.Unlock() + }) + n.confEvents <- event + + return event, nil +} + +func (n *readyTestNotifier) RegisterSpendNtfn(*wire.OutPoint, []byte, + uint32) (*chainntnfs.SpendEvent, error) { + + return nil, errors.New("not implemented") +} + +func (n *readyTestNotifier) RegisterBlockEpochNtfn( + *chainntnfs.BlockEpoch) (*chainntnfs.BlockEpochEvent, error) { + + n.mu.Lock() + n.epochCalls++ + n.mu.Unlock() + n.epochRegistered <- struct{}{} + + return &chainntnfs.BlockEpochEvent{ + Epochs: n.blockEpochs, + Cancel: func() { + n.mu.Lock() + n.epochCancel++ + n.mu.Unlock() + }, + }, nil +} + +func (n *readyTestNotifier) Start() error { return nil } +func (n *readyTestNotifier) Started() bool { return true } +func (n *readyTestNotifier) Stop() error { return nil } + +func (n *readyTestNotifier) counts() (int, int, int, int) { + n.mu.Lock() + defer n.mu.Unlock() + return n.epochCalls, n.confTotal, n.epochCancel, n.confCancel +} + +func (n *readyTestNotifier) scriptCalls(pkScript []byte) int { + n.mu.Lock() + defer n.mu.Unlock() + return n.confCalls[string(pkScript)] +} + +func (n *readyTestNotifier) heightHints() []uint32 { + n.mu.Lock() + defer n.mu.Unlock() + return append([]uint32(nil), n.confHeightHints...) +} + +func (s *restartSweeper) SweepInput(inp input.Input, params sweep.Params) ( + chan sweep.Result, error) { + + s.input = inp + s.params = params + return make(chan sweep.Result, 1), nil +} + +func newTestSigner(keys []*btcec.PrivateKey) input.Signer { + return input.NewMockSigner(keys, &chaincfg.RegressionNetParams) +} + +func testBackend(t *testing.T) kvdb.Backend { + t.Helper() + + db, cleanup, err := kvdb.GetTestBackend(t.TempDir(), "descriptor.db") + require.NoError(t, err) + t.Cleanup(cleanup) + return db +} + +func requireReceive[T any](t *testing.T, channel <-chan T) T { + t.Helper() + + select { + case value := <-channel: + return value + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel value") + var zero T + return zero + } +} + +func testDescriptor(t *testing.T, timeout uint32) (string, []byte, + []*btcec.PrivateKey) { + + t.Helper() + keyA, err := btcec.NewPrivateKey() + require.NoError(t, err) + keyB, err := btcec.NewPrivateKey() + require.NoError(t, err) + preimage := bytes.Repeat([]byte{0x2a}, 32) + hash := sha256.Sum256(preimage) + desc := fmt.Sprintf( + "wsh(or_i(and_v(v:pk(%x),sha256(%x)),"+ + "and_v(v:pk(%x),after(%d))))", + keyA.PubKey().SerializeCompressed(), hash, + keyB.PubKey().SerializeCompressed(), timeout, + ) + + return desc, preimage, []*btcec.PrivateKey{keyA, keyB} +} + +func TestRegisterValidatesAndPersists(t *testing.T) { + t.Parallel() + + descriptor, _, keys := testDescriptor(t, 500) + locA := keychain.KeyLocator{Family: 1, Index: 2} + locB := keychain.KeyLocator{Family: 1, Index: 3} + keyRing := &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locA: keys[0].PubKey(), locB: keys[1].PubKey(), + }} + db := testBackend(t) + service, err := New(Config{ + DB: db, + Notifier: &chainntnfs.MockChainNotifier{}, + KeyRing: keyRing, + Sweeper: &testSweeper{inputs: make(chan input.Input, 1)}, + BlockSource: newTestBlockSource(), + ChainParams: &chaincfg.RegressionNetParams, + Ready: make(chan struct{}), + }) + require.NoError(t, err) + + record, err := service.Register(context.Background(), RegisterRequest{ + Descriptor: descriptor, + KeyBindings: []KeyBinding{{ + DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + KeyLocator: locA, + }, { + DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + KeyLocator: locB, + }}, + ExpectedValue: 50_000, + HeightHint: 100, + Budget: 10_000, + StartingFeeRate: fn.Some(chainfee.SatPerKWeight(1000)), + }) + require.NoError(t, err) + require.Equal(t, StatusRegistered, record.Status) + require.NotEmpty(t, record.PkScript) + require.NotEmpty(t, record.WitnessScript) + require.Equal(t, btcutil.Amount(50_000), record.ExpectedValue) + + loaded, err := newStore(db).list() + require.NoError(t, err) + require.Len(t, loaded, 1) + require.Equal(t, record.CanonicalDescriptor, + loaded[0].CanonicalDescriptor) + require.True(t, loaded[0].HasStartingFeeRate) + require.Equal(t, chainfee.SatPerKWeight(1000), + loaded[0].StartingFeeRate) + + other, err := btcec.NewPrivateKey() + require.NoError(t, err) + bad := other.PubKey().SerializeCompressed() + _, err = service.Register(context.Background(), RegisterRequest{ + Descriptor: fmt.Sprintf("wsh(pk(%x))", bad), + KeyBindings: []KeyBinding{{ + DescriptorKey: fmt.Sprintf("%x", bad), KeyLocator: locA, + }}, + ExpectedValue: 50_000, + HeightHint: 100, + Budget: 10_000, + }) + require.ErrorContains(t, err, "does not match locator") +} + +func TestRegisterRejectsZeroBudget(t *testing.T) { + t.Parallel() + + descriptor, _, keys := testDescriptor(t, 500) + locatorA := keychain.KeyLocator{Family: 1, Index: 1} + locatorB := keychain.KeyLocator{Family: 1, Index: 2} + service, err := New(Config{ + DB: testBackend(t), + Notifier: &chainntnfs.MockChainNotifier{}, + KeyRing: &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locatorA: keys[0].PubKey(), locatorB: keys[1].PubKey(), + }}, + Sweeper: &testSweeper{inputs: make(chan input.Input, 1)}, + BlockSource: newTestBlockSource(), + ChainParams: &chaincfg.RegressionNetParams, + Ready: make(chan struct{}), + }) + require.NoError(t, err) + + _, err = service.Register(context.Background(), RegisterRequest{ + Descriptor: descriptor, + KeyBindings: []KeyBinding{{ + DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + KeyLocator: locatorA, + }, { + DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + KeyLocator: locatorB, + }}, + ExpectedValue: 50_000, + HeightHint: 100, + }) + require.ErrorContains(t, err, "budget must be positive") +} + +func TestRegisterValueAndLabelValidation(t *testing.T) { + t.Parallel() + + service := &Service{} + base := RegisterRequest{ + Descriptor: "wsh(pk(02))", + ExpectedValue: 10_000, + HeightHint: 1, + Budget: 1_000, + } + + req := base + req.ExpectedValue = 0 + _, err := service.Register(context.Background(), req) + require.ErrorContains(t, err, "expected output value must be positive") + + req = base + req.ExpectedValue = btcutil.MaxSatoshi + 1 + _, err = service.Register(context.Background(), req) + require.ErrorContains(t, err, "maximum money") + + req = base + req.Budget = req.ExpectedValue + 1 + _, err = service.Register(context.Background(), req) + require.ErrorContains(t, err, "budget must not exceed expected") + + req = base + req.Label = string(bytes.Repeat([]byte{'a'}, 501)) + _, err = service.Register(context.Background(), req) + require.ErrorContains(t, err, "label must not exceed 500 bytes") + + idA := registrationID("wsh(pk(02))", nil, 0, 10_000) + idB := registrationID("wsh(pk(02))", nil, 0, 10_001) + require.NotEqual(t, idA, idB, + "expected output value must be part of registration identity") +} + +func TestRegisterRejectsTooManyConfirmations(t *testing.T) { + t.Parallel() + + service := &Service{} + _, err := service.Register(context.Background(), RegisterRequest{ + Descriptor: "wsh(pk(02))", + ExpectedValue: 1, + HeightHint: 1, + MinConfs: chainntnfs.MaxNumConfs + 1, + Budget: 1, + }) + require.ErrorContains(t, err, "min confirmations must not exceed") +} + +func TestRegisterBeforeNotifierReadiness(t *testing.T) { + t.Parallel() + + descriptor, _, keys := testDescriptor(t, 500) + locA := keychain.KeyLocator{Family: 11, Index: 1} + locB := keychain.KeyLocator{Family: 11, Index: 2} + notifier := newReadyTestNotifier() + ready := make(chan struct{}) + service, err := New(Config{ + DB: testBackend(t), + Notifier: notifier, + KeyRing: &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locA: keys[0].PubKey(), locB: keys[1].PubKey(), + }}, + Sweeper: &testSweeper{inputs: make(chan input.Input, 1)}, + BlockSource: newTestBlockSource(), + ChainParams: &chaincfg.RegressionNetParams, + Ready: ready, + }) + require.NoError(t, err) + require.NoError(t, service.Start()) + t.Cleanup(func() { require.NoError(t, service.Stop()) }) + + record, err := service.Register(context.Background(), RegisterRequest{ + Descriptor: descriptor, + KeyBindings: []KeyBinding{{ + DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + KeyLocator: locA, + }, { + DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + KeyLocator: locB, + }}, + ExpectedValue: 50_000, + HeightHint: 100, + Budget: 10_000, + }) + require.NoError(t, err) + require.Equal(t, StatusRegistered, record.Status) + + epochCalls, confCalls, _, _ := notifier.counts() + require.Zero(t, epochCalls) + require.Zero(t, confCalls) + + close(ready) + requireReceive(t, notifier.epochRegistered) + requireReceive(t, notifier.confRegistered) + require.Eventually(t, func() bool { + status, err := service.Get(record.ID) + return err == nil && status.Status == StatusWatching + }, time.Second, time.Millisecond) + service.mu.RLock() + require.Equal(t, uint32(100), service.bestHeight) + service.mu.RUnlock() + + epochCalls, confCalls, _, _ = notifier.counts() + require.Equal(t, 1, epochCalls) + require.Equal(t, 1, confCalls) + require.Equal(t, 1, notifier.scriptCalls(record.PkScript)) + require.NoError(t, service.Stop()) + _, _, epochCancel, confCancel := notifier.counts() + require.Equal(t, 1, epochCancel) + require.Equal(t, 1, confCancel) +} + +func TestTransientWatchFailureRetries(t *testing.T) { + t.Parallel() + + descriptor, _, keys := testDescriptor(t, 500) + locA := keychain.KeyLocator{Family: 15, Index: 1} + locB := keychain.KeyLocator{Family: 15, Index: 2} + notifier := newReadyTestNotifier() + notifier.failConfirmations(1) + ready := make(chan struct{}) + service, err := New(Config{ + DB: testBackend(t), + Notifier: notifier, + KeyRing: &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locA: keys[0].PubKey(), locB: keys[1].PubKey(), + }}, + Sweeper: &testSweeper{inputs: make(chan input.Input, 1)}, + BlockSource: newTestBlockSource(), + ChainParams: &chaincfg.RegressionNetParams, + Ready: ready, + }) + require.NoError(t, err) + service.retryInitial = time.Millisecond + service.retryMax = 5 * time.Millisecond + record, err := service.Register(context.Background(), RegisterRequest{ + Descriptor: descriptor, + KeyBindings: []KeyBinding{{ + DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + KeyLocator: locA, + }, { + DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + KeyLocator: locB, + }}, + ExpectedValue: 50_000, + HeightHint: 100, + Budget: 10_000, + }) + require.NoError(t, err) + require.NoError(t, service.Start()) + t.Cleanup(func() { require.NoError(t, service.Stop()) }) + close(ready) + requireReceive(t, notifier.epochRegistered) + requireReceive(t, notifier.confRegistered) + require.Eventually(t, func() bool { + status, err := service.Get(record.ID) + return err == nil && status.Status == StatusWatching && + notifier.scriptCalls(record.PkScript) == 2 + }, time.Second, time.Millisecond) + status, err := service.Get(record.ID) + require.NoError(t, err) + require.NotEqual(t, StatusFailed, status.Status) +} + +func TestReadinessDrainDoesNotDoubleWatch(t *testing.T) { + t.Parallel() + + descriptorA, _, keysA := testDescriptor(t, 500) + descriptorB, _, keysB := testDescriptor(t, 600) + locA1 := keychain.KeyLocator{Family: 12, Index: 1} + locA2 := keychain.KeyLocator{Family: 12, Index: 2} + locB1 := keychain.KeyLocator{Family: 12, Index: 3} + locB2 := keychain.KeyLocator{Family: 12, Index: 4} + notifier := newReadyTestNotifier() + releaseFirstWatch := make(chan struct{}) + notifier.blockFirstConf = releaseFirstWatch + ready := make(chan struct{}) + service, err := New(Config{ + DB: testBackend(t), + Notifier: notifier, + KeyRing: &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locA1: keysA[0].PubKey(), locA2: keysA[1].PubKey(), + locB1: keysB[0].PubKey(), locB2: keysB[1].PubKey(), + }}, + Sweeper: &testSweeper{inputs: make(chan input.Input, 1)}, + BlockSource: newTestBlockSource(), + ChainParams: &chaincfg.RegressionNetParams, + Ready: ready, + }) + require.NoError(t, err) + + binding := func(keys []*btcec.PrivateKey, first, + second keychain.KeyLocator) []KeyBinding { + + return []KeyBinding{{ + DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + KeyLocator: first, + }, { + DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + KeyLocator: second, + }} + } + recordA, err := service.Register(context.Background(), RegisterRequest{ + Descriptor: descriptorA, + KeyBindings: binding(keysA, locA1, locA2), + ExpectedValue: 50_000, + HeightHint: 100, + Budget: 10_000, + }) + require.NoError(t, err) + require.NoError(t, service.Start()) + t.Cleanup(func() { require.NoError(t, service.Stop()) }) + close(ready) + requireReceive(t, notifier.epochRegistered) + requireReceive(t, notifier.confRegistered) + + type registerResult struct { + record *Record + err error + } + registered := make(chan registerResult, 1) + go func() { + record, err := service.Register( + context.Background(), RegisterRequest{ + Descriptor: descriptorB, + KeyBindings: binding(keysB, locB1, locB2), + ExpectedValue: 50_000, + HeightHint: 100, + Budget: 10_000, + }, + ) + registered <- registerResult{record: record, err: err} + }() + + close(releaseFirstWatch) + result := requireReceive(t, registered) + require.NoError(t, result.err) + requireReceive(t, notifier.confRegistered) + require.Eventually(t, func() bool { + service.mu.RLock() + defer service.mu.RUnlock() + return service.notifierReady + }, time.Second, time.Millisecond) + + require.Equal(t, 1, notifier.scriptCalls(recordA.PkScript)) + require.Equal(t, 1, notifier.scriptCalls(result.record.PkScript)) + _, confCalls, _, _ := notifier.counts() + require.Equal(t, 2, confCalls) + require.NoError(t, service.Stop()) + _, _, epochCancel, confCancel := notifier.counts() + require.Equal(t, 1, epochCancel) + require.Equal(t, 2, confCancel) +} + +func TestAddPreimageWaitsForReadiness(t *testing.T) { + t.Parallel() + + descriptor, preimage, keys := testDescriptor(t, 500) + locA := keychain.KeyLocator{Family: 14, Index: 1} + locB := keychain.KeyLocator{Family: 14, Index: 2} + notifier := newReadyTestNotifier() + ready := make(chan struct{}) + sweeper := &testSweeper{inputs: make(chan input.Input, 1)} + service, err := New(Config{ + DB: testBackend(t), + Notifier: notifier, + KeyRing: &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locA: keys[0].PubKey(), locB: keys[1].PubKey(), + }}, + Sweeper: sweeper, + BlockSource: newTestBlockSource(), + ChainParams: &chaincfg.RegressionNetParams, + Ready: ready, + }) + require.NoError(t, err) + record, err := service.Register(context.Background(), RegisterRequest{ + Descriptor: descriptor, + KeyBindings: []KeyBinding{{ + DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + KeyLocator: locA, + }, { + DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + KeyLocator: locB, + }}, + ExpectedValue: 50_000, + HeightHint: 100, + Budget: 10_000, + }) + require.NoError(t, err) + + service.mu.Lock() + stored := service.records[record.ID] + stored.OutPoint = &wire.OutPoint{Index: 7} + stored.Value = int64(stored.ExpectedValue) + stored.ConfirmationHeight = 110 + stored.Status = StatusWaiting + require.NoError(t, newStore(service.cfg.DB).put(stored)) + service.mu.Unlock() + + require.NoError(t, service.Start()) + t.Cleanup(func() { require.NoError(t, service.Stop()) }) + updated, err := service.AddPreimage( + context.Background(), record.ID, preimage, + ) + require.NoError(t, err) + require.Equal(t, StatusWaiting, updated.Status) + select { + case <-sweeper.inputs: + t.Fatal("input offered before notifier and sweeper readiness") + default: + } + service.mu.RLock() + _, pending := service.pending[record.ID] + service.mu.RUnlock() + require.True(t, pending) + + close(ready) + requireReceive(t, notifier.epochRegistered) + _ = requireReceive(t, sweeper.inputs) + require.Eventually(t, func() bool { + status, err := service.Get(record.ID) + return err == nil && status.Status == StatusSweeping + }, time.Second, time.Millisecond) +} + +func TestAddPreimageRejectsFailedRegistration(t *testing.T) { + t.Parallel() + + descriptor, preimage, _ := testDescriptor(t, 500) + desc, err := descriptors.NewDescriptor(descriptor) + require.NoError(t, err) + id := registrationID(desc.String(), nil, 0, 50_000) + record := &storedRecord{ + Record: Record{ + ID: id, CanonicalDescriptor: desc.String(), + Status: StatusFailed, + }, + Preimages: make(map[string][]byte), + } + service := &Service{ + records: map[RegistrationID]*storedRecord{id: record}, + quit: make(chan struct{}), + } + + _, err = service.AddPreimage(context.Background(), id, preimage) + require.ErrorContains(t, err, "already frozen") + require.Empty(t, record.Preimages) + require.NoError(t, service.trySweep(id)) + require.Equal(t, StatusFailed, record.Status) +} + +func TestWrongValueRewatchesFromNextBlock(t *testing.T) { + t.Parallel() + + descriptor, _, keys := testDescriptor(t, 500) + locA := keychain.KeyLocator{Family: 13, Index: 1} + locB := keychain.KeyLocator{Family: 13, Index: 2} + notifier := newReadyTestNotifier() + ready := make(chan struct{}) + blockSource := newTestBlockSource() + service, err := New(Config{ + DB: testBackend(t), + Notifier: notifier, + KeyRing: &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locA: keys[0].PubKey(), locB: keys[1].PubKey(), + }}, + Sweeper: &testSweeper{inputs: make(chan input.Input, 1)}, + BlockSource: blockSource, + ChainParams: &chaincfg.RegressionNetParams, + Ready: ready, + }) + require.NoError(t, err) + service.retryInitial = time.Millisecond + service.retryMax = 5 * time.Millisecond + record, err := service.Register(context.Background(), RegisterRequest{ + Descriptor: descriptor, + KeyBindings: []KeyBinding{{ + DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + KeyLocator: locA, + }, { + DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + KeyLocator: locB, + }}, + ExpectedValue: 50_000, + HeightHint: 100, + Budget: 10_000, + }) + require.NoError(t, err) + require.NoError(t, service.Start()) + t.Cleanup(func() { require.NoError(t, service.Stop()) }) + close(ready) + requireReceive(t, notifier.epochRegistered) + requireReceive(t, notifier.confRegistered) + firstEvent := requireReceive(t, notifier.confEvents) + + wrongValueTx := wire.NewMsgTx(2) + wrongValueTx.AddTxOut(&wire.TxOut{ + Value: 49_999, PkScript: record.PkScript, + }) + validTx := wire.NewMsgTx(2) + validTx.AddTxOut(&wire.TxOut{ + Value: int64(record.ExpectedValue), PkScript: record.PkScript, + }) + firstEvent.Confirmed <- &chainntnfs.TxConfirmation{ + // The notification points at the wrong-value transaction. Full-block + // scanning must still find the valid transaction in the same block. + Tx: wrongValueTx, + BlockHeight: 120, + Block: &wire.MsgBlock{Transactions: []*wire.MsgTx{ + wrongValueTx, validTx, + }}, + } + require.Eventually(t, func() bool { + status, err := service.Get(record.ID) + return err == nil && status.OutPoint != nil && + status.OutPoint.Hash == validTx.TxHash() + }, time.Second, time.Millisecond) + require.Equal(t, []uint32{100}, notifier.heightHints()) + + // Exercise the wrong-value-only case directly with a fresh watchable + // record. It must advance and persist the scan cursor without + // re-registering the cached script request. + secondID := RegistrationID{99} + service.mu.Lock() + service.records[secondID] = &storedRecord{ + Record: Record{ + ID: secondID, CanonicalDescriptor: record.CanonicalDescriptor, + KeyBindings: append([]KeyBinding(nil), record.KeyBindings...), + PkScript: append([]byte(nil), record.PkScript...), + WitnessScript: append([]byte(nil), record.WitnessScript...), + ExpectedValue: 25_000, HeightHint: 90, MinConfs: 1, + Budget: 1_000, Status: StatusWatching, + }, + WatchHeight: 90, + Preimages: make(map[string][]byte), + } + require.NoError(t, newStore(service.cfg.DB).put(service.records[secondID])) + service.bestHeight = 132 + service.mu.Unlock() + require.NoError(t, service.watchOutput(secondID)) + requireReceive(t, notifier.confRegistered) + wrongEvent := requireReceive(t, notifier.confEvents) + wrongOnlyTx := wire.NewMsgTx(2) + wrongOnlyTx.AddTxOut(&wire.TxOut{ + Value: 24_999, PkScript: record.PkScript, + }) + emptyBlock := &wire.MsgBlock{} + blockSource.add(131, emptyBlock) + blockSource.failHash(131, 1) + scanTx := wire.NewMsgTx(2) + scanTx.AddTxOut(&wire.TxOut{ + Value: 25_000, PkScript: record.PkScript, + }) + blockSource.add(132, &wire.MsgBlock{Transactions: []*wire.MsgTx{scanTx}}) + wrongEvent.Confirmed <- &chainntnfs.TxConfirmation{ + Tx: wrongOnlyTx, BlockHeight: 130, + Block: &wire.MsgBlock{Transactions: []*wire.MsgTx{wrongOnlyTx}}, + } + require.Equal(t, []uint32{100, 90}, notifier.heightHints()) + + // The callback can arrive after later blocks have already matured. Scan + // through the current known tip immediately rather than waiting for yet + // another epoch. + require.Eventually(t, func() bool { + second, err := service.Get(secondID) + return err == nil && second.OutPoint != nil && + second.OutPoint.Hash == scanTx.TxHash() && + second.ConfirmationHeight == 132 && + second.Status == StatusWaiting + }, time.Second, time.Millisecond) + second, err := service.Get(secondID) + require.NoError(t, err) + require.NotEqual(t, StatusFailed, second.Status) +} + +func TestMultipleExactOutputsAreAmbiguous(t *testing.T) { + t.Parallel() + + pkScript := []byte{txscript.OP_TRUE} + txA := wire.NewMsgTx(2) + txA.AddTxOut(&wire.TxOut{Value: 10_000, PkScript: pkScript}) + txB := wire.NewMsgTx(2) + txB.AddTxOut(&wire.TxOut{Value: 10_000, PkScript: pkScript}) + _, err := findExactOutput(&wire.MsgBlock{ + Transactions: []*wire.MsgTx{txA, txB}, + }, pkScript, 10_000) + require.ErrorContains(t, err, "multiple exact descriptor outputs") +} + +func TestResumeLeavesFailedRegistrationTerminal(t *testing.T) { + t.Parallel() + + sweeper := &restartSweeper{} + outpoint := wire.OutPoint{Index: 4} + failedWithoutOutput := &storedRecord{ + Record: Record{ID: RegistrationID{1}, Status: StatusFailed}, + } + failedWithOutput := &storedRecord{ + Record: Record{ + ID: RegistrationID{2}, Status: StatusFailed, + OutPoint: &outpoint, + }, + } + service := &Service{ + cfg: Config{Sweeper: sweeper}, + records: map[RegistrationID]*storedRecord{ + failedWithoutOutput.ID: failedWithoutOutput, + failedWithOutput.ID: failedWithOutput, + }, + quit: make(chan struct{}), + } + + require.NoError(t, service.resume(failedWithoutOutput.ID)) + require.NoError(t, service.resume(failedWithOutput.ID)) + require.Nil(t, sweeper.input) + require.Equal(t, StatusFailed, failedWithoutOutput.Status) + require.Equal(t, StatusFailed, failedWithOutput.Status) +} + +func TestAddPreimageRejectsUnrelatedData(t *testing.T) { + t.Parallel() + + descriptor, _, _ := testDescriptor(t, 500) + desc, err := descriptors.NewDescriptor(descriptor) + require.NoError(t, err) + record := &storedRecord{ + Record: Record{ + ID: registrationID(desc.String(), nil, 0, 50_000), + CanonicalDescriptor: desc.String(), + Status: StatusWaiting, + }, + Preimages: make(map[string][]byte), + } + service := &Service{ + cfg: Config{DB: testBackend(t)}, + records: map[RegistrationID]*storedRecord{record.ID: record}, + quit: make(chan struct{}), + } + + _, err = service.AddPreimage( + context.Background(), record.ID, bytes.Repeat([]byte{0x99}, 32), + ) + require.ErrorContains(t, err, "does not match") + require.Empty(t, record.Preimages) +} + +func TestStoreRejectsUnknownVersion(t *testing.T) { + t.Parallel() + + db := testBackend(t) + storage := newStore(db) + require.NoError(t, storage.init()) + id := RegistrationID{1} + err := kvdb.Update(db, func(tx kvdb.RwTx) error { + bucket := tx.ReadWriteBucket(descriptorSweepBucket) + return bucket.Put(id[:], []byte{descriptorSweepStoreVersion + 1}) + }, func() {}) + require.NoError(t, err) + + _, err = storage.list() + require.ErrorContains(t, err, "unknown descriptor sweep store version") +} + +func TestPersistTransitionIsCopyOnWrite(t *testing.T) { + t.Parallel() + + db := testBackend(t) + storage := newStore(db) + require.NoError(t, storage.init()) + id := RegistrationID{42} + record := &storedRecord{ + Record: Record{ID: id, Status: StatusWaiting}, + Preimages: map[string][]byte{}, + } + require.NoError(t, storage.put(record)) + + service := &Service{ + cfg: Config{DB: db}, + store: &failOnceStore{ + delegate: storage, + failures: 1, + }, + records: map[RegistrationID]*storedRecord{id: record}, + quit: make(chan struct{}), + } + err := service.persistTransition(id, func(next *storedRecord) error { + next.Status = StatusSweeping + return nil + }) + require.ErrorContains(t, err, "transient store failure") + require.True(t, isRetryable(err)) + require.Equal(t, StatusWaiting, service.records[id].Status) + + require.NoError(t, service.persistTransition( + id, func(next *storedRecord) error { + next.Status = StatusSweeping + return nil + }, + )) + require.Equal(t, StatusSweeping, service.records[id].Status) + loaded, err := storage.list() + require.NoError(t, err) + require.Len(t, loaded, 1) + require.Equal(t, StatusSweeping, loaded[0].Status) +} + +func TestRestoreSweepKeepsFrozenBranch(t *testing.T) { + t.Parallel() + + descriptor, preimage, keys := testDescriptor(t, 500) + desc, err := descriptors.NewDescriptor(descriptor) + require.NoError(t, err) + _, pkScript, witnessScript, err := descriptorScripts( + desc, &chaincfg.RegressionNetParams, 0, + ) + require.NoError(t, err) + locA := keychain.KeyLocator{Family: 4, Index: 1} + locB := keychain.KeyLocator{Family: 4, Index: 2} + hash := sha256.Sum256(preimage) + locktime := uint32(500) + outpoint := wire.OutPoint{Index: 1} + record := &storedRecord{ + Record: Record{ + ID: RegistrationID{3}, + CanonicalDescriptor: desc.String(), + KeyBindings: []KeyBinding{{ + DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + KeyLocator: locA, + }, { + DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + KeyLocator: locB, + }}, + PkScript: pkScript, WitnessScript: witnessScript, + OutPoint: &outpoint, Value: 50_000, + Status: StatusSweeping, Budget: 10_000, + }, + // A success preimage arriving in storage must not switch an already + // frozen timeout plan during restart. + Preimages: map[string][]byte{ + preimageKey("sha256", hash[:]): preimage, + }, + PlanLocktime: &locktime, + } + deadline := int32(520) + record.PlanDeadlineHeight = &deadline + record.DeadlineDelta = 99 + sweeper := &restartSweeper{} + service := &Service{ + cfg: Config{Sweeper: sweeper}, + records: map[RegistrationID]*storedRecord{record.ID: record}, + bestHeight: 800, + quit: make(chan struct{}), + } + + require.NoError(t, service.restoreSweep(record.ID)) + require.NotNil(t, sweeper.input) + require.Equal(t, locktime, + valueOrZero(sweeper.input.(*descriptorInput).locktime)) + require.Equal(t, deadline, + sweeper.params.DeadlineHeight.UnwrapOr(0)) + _, err = service.AddPreimage(context.Background(), record.ID, preimage) + require.ErrorContains(t, err, "already frozen") +} + +func TestFrozenPlanUsesOnlySelectedBranch(t *testing.T) { + t.Parallel() + + descriptor, preimage, keys := testDescriptor(t, 500) + desc, err := descriptors.NewDescriptor(descriptor) + require.NoError(t, err) + witnessScript, err := desc.ScriptCodeAt(0, 0) + require.NoError(t, err) + locA := keychain.KeyLocator{Family: 2, Index: 1} + locB := keychain.KeyLocator{Family: 2, Index: 2} + record := &storedRecord{ + Record: Record{ + CanonicalDescriptor: desc.String(), + KeyBindings: []KeyBinding{{ + DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + KeyLocator: locA, + }, { + DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + KeyLocator: locB, + }}, + WitnessScript: witnessScript, + }, + Preimages: map[string][]byte{}, + } + hash := sha256.Sum256(preimage) + record.Preimages[preimageKey("sha256", hash[:])] = preimage + + assets := makeAssets(record, 499) + plan, err := desc.PlanAt(0, 0, assets) + require.NoError(t, err) + require.Nil(t, plan.TxConstraints().AbsoluteLocktime, + "preimage branch must not inherit timeout branch CLTV") + + locktime := uint32(500) + record.Preimages = map[string][]byte{} + record.PlanLocktime = &locktime + timeoutPlan, err := desc.PlanAt(0, 0, makeFrozenAssets(record)) + require.NoError(t, err) + require.Equal(t, locktime, + *timeoutPlan.TxConstraints().AbsoluteLocktime) +} + +func TestDescriptorInputUsesSelectedCSV(t *testing.T) { + t.Parallel() + + key, err := btcec.NewPrivateKey() + require.NoError(t, err) + keyString := fmt.Sprintf("%x", key.PubKey().SerializeCompressed()) + desc, err := descriptors.NewDescriptor(fmt.Sprintf( + "wsh(and_v(v:pk(%s),older(5)))", keyString, + )) + require.NoError(t, err) + witnessScript, err := desc.ScriptCodeAt(0, 0) + require.NoError(t, err) + + sequence := uint32(5) + plan, err := desc.PlanAt(0, 0, descriptors.Assets{ + LookupEcdsaSig: func(string) bool { return true }, + RelativeLocktime: &sequence, + }) + require.NoError(t, err) + constraints := plan.TxConstraints() + require.Equal(t, uint32(2), constraints.MinTxVersion) + require.Equal(t, sequence, *constraints.RelativeLocktime) + require.False(t, constraints.RequiresNonFinalSequence) + + outpoint := wire.OutPoint{Index: 1} + record := &storedRecord{Record: Record{ + CanonicalDescriptor: desc.String(), + DerivationIndex: 0, + KeyBindings: []KeyBinding{{ + DescriptorKey: keyString, + KeyLocator: keychain.KeyLocator{ + Family: 17, Index: 2, + }, + }}, + PkScript: []byte{txscript.OP_TRUE}, + WitnessScript: witnessScript, + OutPoint: &outpoint, + Value: 50_000, + ConfirmationHeight: 100, + }, PlanSequence: constraints.RelativeLocktime} + + inp, err := newDescriptorInput(desc, plan, record) + require.NoError(t, err) + require.Equal(t, sequence, inp.BlocksToMaturity()) + require.Equal(t, uint32(100), inp.HeightHint()) + _, hasLocktime := inp.RequiredLockTime() + require.False(t, hasLocktime) +} + +func TestImmediatePlanWinsOverMatureTimeout(t *testing.T) { + t.Parallel() + + descriptor, preimage, keys := testDescriptor(t, 500) + desc, err := descriptors.NewDescriptor(descriptor) + require.NoError(t, err) + record := &storedRecord{ + Record: Record{KeyBindings: []KeyBinding{{ + DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + }, { + DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + }}}, + Preimages: make(map[string][]byte), + } + hash := sha256.Sum256(preimage) + record.Preimages[preimageKey("sha256", hash[:])] = preimage + + plan, err := desc.PlanAt(0, 0, makeAssets(record, 0)) + require.NoError(t, err) + require.Nil(t, plan.TxConstraints().AbsoluteLocktime) + + // Exposing the mature timeout simultaneously would let Miniscript pick + // its smaller witness, demonstrating why the service uses two passes. + plan, err = desc.PlanAt(0, 0, makeAssets(record, 600)) + require.NoError(t, err) + require.Equal(t, uint32(500), + *plan.TxConstraints().AbsoluteLocktime) +} + +func TestDescriptorWitness(t *testing.T) { + t.Parallel() + + descriptor, preimage, keys := testDescriptor(t, 500) + desc, err := descriptors.NewDescriptor(descriptor) + require.NoError(t, err) + witnessScript, err := desc.ScriptCodeAt(0, 0) + require.NoError(t, err) + hash := sha256.Sum256(preimage) + keyA := fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()) + assets := descriptors.Assets{ + LookupEcdsaSig: func(key string) bool { return key == keyA }, + LookupPreimage: func(string, []byte) bool { return true }, + } + plan, err := desc.PlanAt(0, 0, assets) + require.NoError(t, err) + + record := &storedRecord{ + Record: Record{ + KeyBindings: []KeyBinding{{DescriptorKey: keyA, + KeyLocator: keychain.KeyLocator{Family: 3, Index: 4}}}, + WitnessScript: witnessScript, + }, + Preimages: map[string][]byte{ + preimageKey("sha256", hash[:]): preimage, + }, + } + witnessType, err := newDescriptorWitnessType(desc, plan, record) + require.NoError(t, err) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{Sequence: 0}) + tx.AddTxOut(&wire.TxOut{Value: 5000, + PkScript: []byte{txscript.OP_TRUE}}) + output := &wire.TxOut{Value: 10_000, PkScript: []byte{txscript.OP_0}} + fetcher := txscript.NewCannedPrevOutputFetcher( + output.PkScript, output.Value, + ) + hashes := txscript.NewTxSigHashes(tx, fetcher) + signDesc := &input.SignDescriptor{ + WitnessScript: witnessScript, + Output: output, + HashType: txscript.SigHashAll, + PrevOutputFetcher: fetcher, + SigHashes: hashes, + } + script, err := witnessType.craft( + newTestSigner(keys), + signDesc, tx, 0, + ) + require.NoError(t, err) + require.Equal(t, witnessScript, script.Witness[len(script.Witness)-1]) + require.Contains(t, script.Witness, preimage) + + bound, _, err := witnessType.SizeUpperBound() + require.NoError(t, err) + var actual int + actual += wire.VarIntSerializeSize(uint64(len(script.Witness))) + for _, element := range script.Witness { + actual += wire.VarIntSerializeSize(uint64(len(element))) + len(element) + } + require.LessOrEqual(t, actual, int(bound)) +} diff --git a/sweep/descriptorsweep/store.go b/sweep/descriptorsweep/store.go new file mode 100644 index 00000000000..5031c8732c9 --- /dev/null +++ b/sweep/descriptorsweep/store.go @@ -0,0 +1,84 @@ +package descriptorsweep + +import ( + "bytes" + "encoding/gob" + "fmt" + + "github.com/lightningnetwork/lnd/kvdb" +) + +var descriptorSweepBucket = []byte("descriptor-sweep-registrations") + +const descriptorSweepStoreVersion byte = 2 + +type store struct { + db kvdb.Backend +} + +type recordStore interface { + init() error + put(*storedRecord) error + list() ([]*storedRecord, error) +} + +func newStore(db kvdb.Backend) *store { + return &store{db: db} +} + +func (s *store) init() error { + return kvdb.Update(s.db, func(tx kvdb.RwTx) error { + _, err := tx.CreateTopLevelBucket(descriptorSweepBucket) + if err == kvdb.ErrBucketExists { + return nil + } + return err + }, func() {}) +} + +func (s *store) put(record *storedRecord) error { + var value bytes.Buffer + if err := gob.NewEncoder(&value).Encode(record); err != nil { + return fmt.Errorf("encode descriptor sweep: %w", err) + } + encoded := append([]byte{descriptorSweepStoreVersion}, value.Bytes()...) + + return kvdb.Update(s.db, func(tx kvdb.RwTx) error { + bucket := tx.ReadWriteBucket(descriptorSweepBucket) + if bucket == nil { + return kvdb.ErrBucketNotFound + } + return bucket.Put(record.ID[:], encoded) + }, func() {}) +} + +func (s *store) list() ([]*storedRecord, error) { + var records []*storedRecord + err := kvdb.View(s.db, func(tx kvdb.RTx) error { + bucket := tx.ReadBucket(descriptorSweepBucket) + if bucket == nil { + return kvdb.ErrBucketNotFound + } + + return bucket.ForEach(func(_, value []byte) error { + if len(value) == 0 || value[0] != descriptorSweepStoreVersion { + return fmt.Errorf("unknown descriptor sweep store version") + } + var record storedRecord + if err := gob.NewDecoder(bytes.NewReader(value[1:])).Decode( + &record, + ); err != nil { + return fmt.Errorf("decode descriptor sweep: %w", err) + } + if record.Preimages == nil { + record.Preimages = make(map[string][]byte) + } + records = append(records, &record) + return nil + }) + }, func() { + records = nil + }) + + return records, err +} diff --git a/sweep/descriptorsweep/types.go b/sweep/descriptorsweep/types.go new file mode 100644 index 00000000000..9e9c1960a4a --- /dev/null +++ b/sweep/descriptorsweep/types.go @@ -0,0 +1,655 @@ +package descriptorsweep + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "math" + "sort" + "sync" + "time" + + "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/descriptors" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/kvdb" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/sweep" +) + +var ( + // ErrNotFound is returned when a descriptor registration is unknown. + ErrNotFound = errors.New("descriptor sweep registration not found") + + // ErrAlreadyExists is returned when a descriptor registration already + // exists. + ErrAlreadyExists = errors.New("descriptor sweep registration exists") +) + +// RegistrationID is a stable identifier derived from a registration's +// canonical descriptor and key bindings. +type RegistrationID [32]byte + +// String returns the hexadecimal registration identifier. +func (i RegistrationID) String() string { + return hex.EncodeToString(i[:]) +} + +// ParseRegistrationID parses a hexadecimal registration identifier. +func ParseRegistrationID(id string) (RegistrationID, error) { + var result RegistrationID + + raw, err := hex.DecodeString(id) + if err != nil { + return result, err + } + if len(raw) != len(result) { + return result, fmt.Errorf("registration id must be %d bytes", len(result)) + } + copy(result[:], raw) + + return result, nil +} + +// RegistrationIDFromBytes parses a raw 32-byte registration identifier. +func RegistrationIDFromBytes(id []byte) (RegistrationID, error) { + var result RegistrationID + if len(id) != len(result) { + return result, fmt.Errorf("registration id must be %d bytes", len(result)) + } + copy(result[:], id) + return result, nil +} + +// Bytes returns a copy of the raw registration identifier. +func (i RegistrationID) Bytes() []byte { + result := make([]byte, len(i)) + copy(result, i[:]) + return result +} + +// Status is the durable lifecycle state of a descriptor sweep. +type Status uint8 + +const ( + StatusRegistered Status = iota + StatusWatching + StatusFound + StatusWaiting + StatusSweeping + StatusSwept + StatusFailed +) + +// String returns a human-readable status name. +func (s Status) String() string { + switch s { + case StatusRegistered: + return "registered" + case StatusWatching: + return "watching" + case StatusFound: + return "found" + case StatusWaiting: + return "waiting" + case StatusSweeping: + return "sweeping" + case StatusSwept: + return "swept" + case StatusFailed: + return "failed" + default: + return "unknown" + } +} + +// KeyBinding binds one key expression in a descriptor to an lnd key locator. +// The binding is accepted only when the key ring derives the same public key. +type KeyBinding struct { + DescriptorKey string + KeyLocator keychain.KeyLocator +} + +// RegisterRequest describes one fixed-index native P2WSH output to watch and +// sweep. Ranged and multipath descriptors are deliberately excluded from the +// first version of the service. +type RegisterRequest struct { + Descriptor string + DerivationIndex uint32 + KeyBindings []KeyBinding + ExpectedValue btcutil.Amount + HeightHint uint32 + MinConfs uint32 + Budget btcutil.Amount + DeadlineDelta uint32 + Immediate bool + Label string + StartingFeeRate fn.Option[chainfee.SatPerKWeight] +} + +// Record is the public, immutable snapshot of a registration. +type Record struct { + ID RegistrationID + Descriptor string + CanonicalDescriptor string + DerivationIndex uint32 + KeyBindings []KeyBinding + Address string + PkScript []byte + WitnessScript []byte + ExpectedValue btcutil.Amount + HeightHint uint32 + MinConfs uint32 + Budget btcutil.Amount + DeadlineDelta uint32 + Immediate bool + Label string + Status Status + OutPoint *wire.OutPoint + Value int64 + ConfirmationHeight uint32 + SweepTxID *chainhash.Hash + Error string +} + +type storedRecord struct { + Record + WatchHeight uint32 + BlockScan bool + Preimages map[string][]byte + PlanLocktime *uint32 + PlanSequence *uint32 + PlanDeadlineHeight *int32 + HasStartingFeeRate bool + StartingFeeRate chainfee.SatPerKWeight +} + +func (r *storedRecord) snapshot() *Record { + result := r.Record + result.KeyBindings = append([]KeyBinding(nil), r.KeyBindings...) + result.PkScript = append([]byte(nil), r.PkScript...) + result.WitnessScript = append([]byte(nil), r.WitnessScript...) + if r.OutPoint != nil { + op := *r.OutPoint + result.OutPoint = &op + } + if r.SweepTxID != nil { + txid := *r.SweepTxID + result.SweepTxID = &txid + } + return &result +} + +// Sweeper is the subset of UtxoSweeper used by the service. +type Sweeper interface { + SweepInput(input.Input, sweep.Params) (chan sweep.Result, error) +} + +// BlockSource retrieves blocks by main-chain height. It is used after a +// wrong-value script match because lnd's script confirmation cache deliberately +// retains the first match and cannot be re-registered to discover address +// reuse safely. +type BlockSource interface { + GetBlockHash(blockHeight int64) (*chainhash.Hash, error) + GetBlock(blockHash *chainhash.Hash) (*wire.MsgBlock, error) +} + +// Config contains the dependencies of the descriptor sweep service. +type Config struct { + DB kvdb.Backend + Notifier chainntnfs.ChainNotifier + KeyRing keychain.KeyRing + Sweeper Sweeper + BlockSource BlockSource + ChainParams *chaincfg.Params + + // Ready is closed after both the chain notifier and UTXO sweeper have + // started. WalletKit itself starts before either dependency, so notifier + // registrations must be deferred until this explicit lifecycle signal. + Ready <-chan struct{} +} + +// Service durably watches descriptor outputs and hands immutable, satisfiable +// inputs to the existing UTXO sweeper. +type Service struct { + cfg Config + store recordStore + + mu sync.RWMutex + scanMu sync.Mutex + records map[RegistrationID]*storedRecord + watches map[RegistrationID]func() + pending map[RegistrationID]struct{} + // retrying maps an active retry worker to whether another retry request + // arrived while its task was running. + retrying map[retryKey]bool + + bestHeight uint32 + started bool + notifierReady bool + quit chan struct{} + wg sync.WaitGroup + retryInitial time.Duration + retryMax time.Duration +} + +type retryKey struct { + id RegistrationID + kind string +} + +const ( + defaultRetryInitial = 100 * time.Millisecond + defaultRetryMax = 5 * time.Second +) + +// New constructs a descriptor sweep service and initializes its bucket. +func New(cfg Config) (*Service, error) { + switch { + case cfg.DB == nil: + return nil, errors.New("descriptor sweep DB is required") + case cfg.Notifier == nil: + return nil, errors.New("descriptor sweep notifier is required") + case cfg.KeyRing == nil: + return nil, errors.New("descriptor sweep key ring is required") + case cfg.Sweeper == nil: + return nil, errors.New("descriptor sweep sweeper is required") + case cfg.BlockSource == nil: + return nil, errors.New("descriptor sweep block source is required") + case cfg.ChainParams == nil: + return nil, errors.New("descriptor sweep chain params are required") + case cfg.Ready == nil: + return nil, errors.New("descriptor sweep ready signal is required") + } + + storage := newStore(cfg.DB) + if err := storage.init(); err != nil { + return nil, err + } + + return &Service{ + cfg: cfg, + store: storage, + records: make(map[RegistrationID]*storedRecord), + watches: make(map[RegistrationID]func()), + pending: make(map[RegistrationID]struct{}), + retrying: make(map[retryKey]bool), + quit: make(chan struct{}), + retryInitial: defaultRetryInitial, + retryMax: defaultRetryMax, + }, nil +} + +func (s *Service) storage() recordStore { + if s.store != nil { + return s.store + } + return newStore(s.cfg.DB) +} + +// updateRecordLocked performs a durable copy-on-write transition. The caller +// must hold s.mu. The live record is replaced only after the next state has +// committed successfully. +func (s *Service) updateRecordLocked(id RegistrationID, + mutate func(*storedRecord) error) (*storedRecord, error) { + + current, ok := s.records[id] + if !ok { + return nil, ErrNotFound + } + next := current.cloneForInput() + if err := mutate(next); err != nil { + return nil, err + } + if err := s.storage().put(next); err != nil { + return nil, retryable(fmt.Errorf("persist descriptor sweep: %w", err)) + } + s.records[id] = next + return next, nil +} + +func registrationID(descriptor string, bindings []KeyBinding, + index uint32, expectedValue btcutil.Amount) RegistrationID { + + copyBindings := append([]KeyBinding(nil), bindings...) + sort.Slice(copyBindings, func(i, j int) bool { + return copyBindings[i].DescriptorKey < copyBindings[j].DescriptorKey + }) + + h := sha256.New() + _, _ = h.Write([]byte(descriptor)) + _, _ = fmt.Fprintf(h, "|%d|value:%d", index, expectedValue) + for _, binding := range copyBindings { + _, _ = fmt.Fprintf( + h, "|%s:%d:%d", binding.DescriptorKey, + binding.KeyLocator.Family, binding.KeyLocator.Index, + ) + } + + var id RegistrationID + copy(id[:], h.Sum(nil)) + return id +} + +func descriptorScripts(desc *descriptors.Descriptor, params *chaincfg.Params, + index uint32) (string, []byte, []byte, error) { + + addressString, err := desc.AddressAt(params, 0, index) + if err != nil { + return "", nil, nil, err + } + addr, err := address.DecodeAddress(addressString, params) + if err != nil { + return "", nil, nil, err + } + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + return "", nil, nil, err + } + witnessScript, err := desc.ScriptCodeAt(0, index) + if err != nil { + return "", nil, nil, err + } + + return addressString, pkScript, witnessScript, nil +} + +// Start restores durable registrations. Notifier registrations are installed +// asynchronously once both the chain notifier and UTXO sweeper are ready. +func (s *Service) Start() error { + s.mu.Lock() + if s.started { + s.mu.Unlock() + return nil + } + + records, err := s.storage().list() + if err != nil { + s.mu.Unlock() + return err + } + for _, record := range records { + s.records[record.ID] = record + s.pending[record.ID] = struct{}{} + } + s.started = true + s.wg.Add(1) + s.mu.Unlock() + + go s.waitForReady() + + return nil +} + +// Stop cancels all notifier registrations and waits for workers to exit. +func (s *Service) Stop() error { + s.mu.Lock() + if !s.started { + s.mu.Unlock() + return nil + } + select { + case <-s.quit: + default: + close(s.quit) + } + for _, cancel := range s.watches { + cancel() + } + s.watches = make(map[RegistrationID]func()) + s.pending = make(map[RegistrationID]struct{}) + s.retrying = make(map[retryKey]bool) + s.notifierReady = false + s.mu.Unlock() + + s.wg.Wait() + return nil +} + +// Register validates and persists one descriptor watch. +func (s *Service) Register(_ context.Context, + req RegisterRequest) (*Record, error) { + + if req.HeightHint == 0 { + return nil, errors.New("height hint must be non-zero") + } + if req.ExpectedValue <= 0 { + return nil, errors.New("expected output value must be positive") + } + if req.ExpectedValue > btcutil.MaxSatoshi { + return nil, errors.New("expected output value exceeds maximum money") + } + if req.Budget <= 0 { + return nil, errors.New("sweep budget must be positive") + } + if req.Budget > req.ExpectedValue { + return nil, errors.New("sweep budget must not exceed expected output value") + } + if req.MinConfs == 0 { + req.MinConfs = 1 + } + if req.MinConfs > chainntnfs.MaxNumConfs { + return nil, fmt.Errorf("min confirmations must not exceed %d", + chainntnfs.MaxNumConfs) + } + if req.DeadlineDelta > uint32(math.MaxInt32) { + return nil, errors.New("deadline delta exceeds maximum block height") + } + if len(req.Label) > 500 { + return nil, errors.New("label must not exceed 500 bytes") + } + + desc, err := descriptors.NewDescriptor(req.Descriptor) + if err != nil { + return nil, fmt.Errorf("parse descriptor: %w", err) + } + if desc.DescType() != descriptors.DescTypeWsh { + return nil, fmt.Errorf("only native wsh descriptors are supported") + } + if desc.MultipathLen() != 1 { + return nil, errors.New("multipath descriptors are not supported") + } + if req.DerivationIndex != 0 { + return nil, errors.New("ranged descriptors are not supported") + } + + if err := rejectTimeLocks(desc); err != nil { + return nil, err + } + if err := s.verifyBindings(desc, req.KeyBindings); err != nil { + return nil, err + } + + canonical := desc.String() + addr, pkScript, witnessScript, err := descriptorScripts( + desc, s.cfg.ChainParams, req.DerivationIndex, + ) + if err != nil { + return nil, fmt.Errorf("derive descriptor: %w", err) + } + + id := registrationID( + canonical, req.KeyBindings, req.DerivationIndex, + req.ExpectedValue, + ) + record := &storedRecord{ + Record: Record{ + ID: id, + Descriptor: req.Descriptor, + CanonicalDescriptor: canonical, + DerivationIndex: req.DerivationIndex, + KeyBindings: append([]KeyBinding(nil), req.KeyBindings...), + Address: addr, + PkScript: pkScript, + WitnessScript: witnessScript, + ExpectedValue: req.ExpectedValue, + HeightHint: req.HeightHint, + MinConfs: req.MinConfs, + Budget: req.Budget, + DeadlineDelta: req.DeadlineDelta, + Immediate: req.Immediate, + Label: req.Label, + Status: StatusRegistered, + }, + WatchHeight: req.HeightHint, + Preimages: make(map[string][]byte), + } + if req.StartingFeeRate.IsSome() { + record.HasStartingFeeRate = true + record.StartingFeeRate = req.StartingFeeRate.UnwrapOr(0) + } + + s.mu.Lock() + if _, ok := s.records[id]; ok { + s.mu.Unlock() + return nil, ErrAlreadyExists + } + if err := s.storage().put(record); err != nil { + s.mu.Unlock() + return nil, err + } + s.records[id] = record + notifierReady := s.notifierReady + if !notifierReady { + if s.pending == nil { + s.pending = make(map[RegistrationID]struct{}) + } + s.pending[id] = struct{}{} + } + s.mu.Unlock() + + if notifierReady { + if err := s.watchOutput(id); err != nil { + s.handleRegistrationError(id, err) + } + } + + return s.Get(id) +} + +// AddPreimage persists a 32-byte late-bound preimage and retries planning. +func (s *Service) AddPreimage(_ context.Context, id RegistrationID, + preimage []byte) (*Record, error) { + + if len(preimage) != 32 { + return nil, errors.New("preimage must be 32 bytes") + } + + s.mu.Lock() + record, ok := s.records[id] + if !ok { + s.mu.Unlock() + return nil, ErrNotFound + } + if record.Status == StatusSweeping || record.Status == StatusSwept || + record.Status == StatusFailed { + s.mu.Unlock() + return nil, errors.New("descriptor sweep branch is already frozen") + } + hash := sha256.Sum256(preimage) + desc, err := descriptors.NewDescriptor(record.CanonicalDescriptor) + if err != nil { + s.mu.Unlock() + return nil, err + } + policy, err := desc.Lift() + if err != nil { + s.mu.Unlock() + return nil, err + } + if !policyCommitsSHA256(policy, hash[:]) { + s.mu.Unlock() + return nil, errors.New("preimage does not match a descriptor sha256 commitment") + } + record, err = s.updateRecordLocked(id, func(next *storedRecord) error { + if next.Preimages == nil { + next.Preimages = make(map[string][]byte) + } + next.Preimages[preimageKey("sha256", hash[:])] = + append([]byte(nil), preimage...) + return nil + }) + if err != nil { + s.mu.Unlock() + return nil, err + } + notifierReady := s.notifierReady + if !notifierReady { + if s.pending == nil { + s.pending = make(map[RegistrationID]struct{}) + } + s.pending[id] = struct{}{} + } + s.mu.Unlock() + + if !notifierReady { + return s.Get(id) + } + if err := s.trySweep(id); err != nil { + s.handleRegistrationError(id, err) + if !isRetryable(err) { + return nil, err + } + } + return s.Get(id) +} + +func policyCommitsSHA256(policy *descriptors.SemanticPolicy, + digest []byte) bool { + + if policy == nil { + return false + } + if policy.Type == descriptors.SemanticPolicyTypeSha256 && + policy.Hash != nil { + + committed, err := hex.DecodeString(*policy.Hash) + if err == nil && bytes.Equal(committed, digest) { + return true + } + } + for _, child := range policy.Policies { + if policyCommitsSHA256(child, digest) { + return true + } + } + return false +} + +// Get returns one registration snapshot. +func (s *Service) Get(id RegistrationID) (*Record, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + record, ok := s.records[id] + if !ok { + return nil, ErrNotFound + } + return record.snapshot(), nil +} + +// List returns all registration snapshots sorted by identifier. +func (s *Service) List() []*Record { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]*Record, 0, len(s.records)) + for _, record := range s.records { + result = append(result, record.snapshot()) + } + sort.Slice(result, func(i, j int) bool { + return result[i].ID.String() < result[j].ID.String() + }) + return result +} From 180c0be86356a23d4c8fec7e0a772fa2afeccccb Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Thu, 13 Aug 2026 15:45:40 +0200 Subject: [PATCH 2/2] sweep: support taproot descriptor script paths --- .golangci.yml | 2 + cmd/commands/walletrpc_active.go | 94 ++- cmd/commands/walletrpc_active_test.go | 9 +- docs/release-notes/release-notes-0.22.0.md | 9 +- go.mod | 4 +- go.sum | 4 +- itest/lnd_descriptor_sweep_test.go | 203 ++++- lnrpc/walletrpc/driver.go | 12 +- lnrpc/walletrpc/walletkit.pb.go | 16 +- lnrpc/walletrpc/walletkit.proto | 22 +- lnrpc/walletrpc/walletkit.swagger.json | 9 +- lnrpc/walletrpc/walletkit_grpc.pb.go | 20 +- lnrpc/walletrpc/walletkit_server.go | 61 +- lnrpc/walletrpc/walletkit_server_test.go | 7 +- lntest/rpc/wallet_kit.go | 9 +- lnwallet/rpcwallet/rpcwallet.go | 104 ++- lnwallet/rpcwallet/rpcwallet_test.go | 66 ++ rpcserver.go | 2 + sweep/descriptorsweep/retry.go | 49 +- sweep/descriptorsweep/service.go | 601 ++++++++++++--- sweep/descriptorsweep/service_test.go | 849 +++++++++++++++++++-- sweep/descriptorsweep/store.go | 25 +- sweep/descriptorsweep/types.go | 141 ++-- 23 files changed, 1907 insertions(+), 411 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index e1dc06aeb80..cb093e208fa 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -172,6 +172,8 @@ linters: - google.golang.org/protobuf - github.com/lightningnetwork/lnd/sqldb - github.com/lightningnetwork/lightning-onion + # Temporary until the stacked btcd descriptor changes land upstream. + - github.com/btcsuite/btcd/descriptors replace-local: true gosec: diff --git a/cmd/commands/walletrpc_active.go b/cmd/commands/walletrpc_active.go index ffc67ae2b01..10c1f375e44 100644 --- a/cmd/commands/walletrpc_active.go +++ b/cmd/commands/walletrpc_active.go @@ -119,8 +119,11 @@ func parseAddrType(addrTypeStr string) (walletrpc.AddressType, error) { case "p2tr": return walletrpc.AddressType_TAPROOT_PUBKEY, nil default: - return 0, errors.New("invalid address type, supported address " + - "types are: p2wkh, p2tr, np2wkh, and np2wkh-p2wkh") + return 0, errors.New( + "invalid address type, supported address " + + "types are: p2wkh, p2tr, np2wkh, and " + + "np2wkh-p2wkh", + ) } } @@ -221,7 +224,11 @@ func pendingSweeps(ctx *cli.Context) error { var pendingSweepsResp = struct { PendingSweeps []*PendingSweep `json:"pending_sweeps"` }{ - PendingSweeps: make([]*PendingSweep, 0, len(resp.PendingSweeps)), + PendingSweeps: make( + []*PendingSweep, + 0, + len(resp.PendingSweeps), + ), } for _, protoPendingSweep := range resp.PendingSweeps { @@ -241,51 +248,53 @@ var registerSweepDescriptorCommand = cli.Command{ Usage: "Register a fixed output descriptor for automatic sweeping.", ArgsUsage: "descriptor", Description: ` - Register a public fixed-index P2WSH output descriptor. lnd watches for the - corresponding output and automatically offers it to the batching sweeper - once a Miniscript branch is satisfiable. - - Each key that lnd should sign for must be bound with a key_binding flag in - the form '=:'. The descriptor key is - the exact key expression used in the descriptor.`, + Register a fixed-index native P2WSH descriptor or a P2TR descriptor with + Miniscript script paths. lnd watches for the output and offers it to the + batching sweeper once a Miniscript branch can be spent. P2TR key-path + spending is disabled, so its internal key may remain unbound. + + Bind each signing key with a key_binding flag in this form: + '=:'. The descriptor key is the + exact key expression used in the descriptor.`, Flags: []cli.Flag{ cli.StringSliceFlag{ Name: "key_binding", - Usage: "Bind a descriptor key to an lnd key locator; may " + - "be repeated.", + Usage: "Bind a descriptor key to an lnd key " + + "locator; may be repeated.", }, cli.UintFlag{ - Name: "derivation_index", - Usage: "Descriptor derivation index (currently must be zero).", + Name: "derivation_index", + Usage: "Descriptor derivation index (must currently " + + "be zero).", }, cli.UintFlag{ Name: "height_hint", - Usage: "Required non-zero earliest block height at which the " + - "output may appear.", + Usage: "Required earliest non-zero block height at " + + "which the output may appear.", }, cli.UintFlag{ Name: "min_confs", Value: 1, - Usage: "Confirmations required before sweeping the output.", + Usage: "Confirmations required before sweeping the " + + "output.", }, cli.Uint64Flag{ - Name: "expected_value_sat", - Usage: "Required exact value of the watched output in " + - "satoshis.", + Name: "expected_value_sat", + Usage: "Watched-output value in satoshis (required).", }, cli.Uint64Flag{ - Name: "budget_sat", - Usage: "Required non-zero maximum sweep fee budget in " + - "satoshis.", + Name: "budget_sat", + Usage: "Sweep fee budget in satoshis (required).", }, cli.UintFlag{ Name: "deadline_delta", - Usage: "Blocks from offering a satisfiable output to the " + - "sweeper by which it should confirm.", + Usage: "Blocks from offering a satisfiable output to " + + "the sweeper by which it should confirm.", }, cli.BoolFlag{ - Name: "immediate", - Usage: "Sweep immediately when a branch becomes satisfiable.", + Name: "immediate", + Usage: "Sweep immediately when a branch becomes " + + "satisfiable.", }, cli.StringFlag{ Name: "label", @@ -342,6 +351,7 @@ func registerSweepDescriptor(ctx *cli.Context) error { } printRespJSON(resp) + return nil } @@ -362,17 +372,25 @@ func parseSweepDescriptorKeyBinding( family, err := strconv.ParseInt(locator[0], 10, 32) if err != nil { - return nil, fmt.Errorf("invalid key family in %q: %w", binding, err) + return nil, fmt.Errorf( + "invalid key family in %q: %w", binding, err, + ) } if family < 0 { - return nil, fmt.Errorf("invalid negative key family in %q", binding) + return nil, fmt.Errorf( + "invalid negative key family in %q", binding, + ) } index, err := strconv.ParseInt(locator[1], 10, 32) if err != nil { - return nil, fmt.Errorf("invalid key index in %q: %w", binding, err) + return nil, fmt.Errorf( + "invalid key index in %q: %w", binding, err, + ) } if index < 0 { - return nil, fmt.Errorf("invalid negative key index in %q", binding) + return nil, fmt.Errorf( + "invalid negative key index in %q", binding, + ) } return &walletrpc.SweepDescriptorKeyBinding{ @@ -402,11 +420,15 @@ func addSweepDescriptorData(ctx *cli.Context) error { return cli.ShowCommandHelp(ctx, "addsweepdescriptordata") } - id, err := decodeSweepDescriptorHex("registration ID", ctx.Args().First()) + id, err := decodeSweepDescriptorHex( + "registration ID", ctx.Args().First(), + ) if err != nil { return err } - preimage, err := decodeSweepDescriptorHex("preimage", ctx.String("preimage")) + preimage, err := decodeSweepDescriptorHex( + "preimage", ctx.String("preimage"), + ) if err != nil { return err } @@ -427,6 +449,7 @@ func addSweepDescriptorData(ctx *cli.Context) error { } printRespJSON(resp) + return nil } @@ -466,6 +489,7 @@ func listSweepDescriptors(ctx *cli.Context) error { } printRespJSON(resp) + return nil } @@ -2212,7 +2236,9 @@ func requiredReserve(ctx *cli.Context) error { defer cleanUp() req := &walletrpc.RequiredReserveRequest{ - AdditionalPublicChannels: uint32(ctx.Uint64("additional_channels")), + AdditionalPublicChannels: uint32( + ctx.Uint64("additional_channels"), + ), } resp, err := walletClient.RequiredReserve(ctxc, req) if err != nil { diff --git a/cmd/commands/walletrpc_active_test.go b/cmd/commands/walletrpc_active_test.go index 422cfc2abe2..564b4f51238 100644 --- a/cmd/commands/walletrpc_active_test.go +++ b/cmd/commands/walletrpc_active_test.go @@ -55,11 +55,12 @@ func TestParseSweepDescriptorKeyBinding(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() - binding, err := parseSweepDescriptorKeyBinding(test.binding) + binding, err := parseSweepDescriptorKeyBinding( + test.binding, + ) if test.wantErrText != "" { require.ErrorContains(t, err, test.wantErrText) return @@ -69,7 +70,9 @@ func TestParseSweepDescriptorKeyBinding(t *testing.T) { require.Equal(t, test.wantKey, binding.DescriptorKey) require.Equal(t, test.wantFamily, binding.KeyLocator.KeyFamily) - require.Equal(t, test.wantIndex, binding.KeyLocator.KeyIndex) + require.Equal( + t, test.wantIndex, binding.KeyLocator.KeyIndex, + ) }) } } diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index 8ffea64d469..ee2d1e2c160 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -59,10 +59,11 @@ ## Functional Enhancements -* WalletKit can now watch a fixed P2WSH output descriptor and submit its output - to the existing sweeper when a Miniscript branch becomes satisfiable. A - client can add an SHA256 preimage after registration, or let a height-based - CLTV or block-based CSV path mature automatically. +* [WalletKit can now watch](https://github.com/lightningnetwork/lnd/pull/11067) + a fixed native P2WSH descriptor or a P2TR descriptor with Miniscript script + paths, then submit the matching output to the existing sweeper when a branch + becomes satisfiable. A client can add an SHA256 preimage after registration, + or let a height-based CLTV or block-based CSV path mature automatically. ## RPC Additions diff --git a/go.mod b/go.mod index 82a3062ee38..892d744b503 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/btcsuite/btcd/btcutil/v2 v2.0.0 github.com/btcsuite/btcd/chaincfg/v2 v2.0.0 github.com/btcsuite/btcd/chainhash/v2 v2.0.0 - github.com/btcsuite/btcd/descriptors v0.0.0-20260813124334-2b2f1d4858bf + github.com/btcsuite/btcd/descriptors v0.0.0-20260813130254-877b61a38433 github.com/btcsuite/btcd/psbt/v2 v2.0.0 github.com/btcsuite/btcd/txscript/v2 v2.0.0 github.com/btcsuite/btcd/wire/v2 v2.0.0 @@ -212,7 +212,7 @@ replace github.com/lightningnetwork/lnd/sqldb => ./sqldb // TODO: Remove this replacement once the descriptor extensions required by // the descriptor sweep service land in btcsuite/btcd. -replace github.com/btcsuite/btcd/descriptors => github.com/sputn1ck/btcd/descriptors v0.0.0-20260813124334-2b2f1d4858bf +replace github.com/btcsuite/btcd/descriptors => github.com/sputn1ck/btcd/descriptors v0.0.0-20260813130254-877b61a38433 // We want to format raw bytes as hex instead of base64. The forked version // allows us to specify that as an option. diff --git a/go.sum b/go.sum index 3710e1c3fe6..ced4add7793 100644 --- a/go.sum +++ b/go.sum @@ -371,8 +371,8 @@ github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/sputn1ck/btcd/descriptors v0.0.0-20260813124334-2b2f1d4858bf h1:qcz8ikY7Y6qZjCampmcr8zk+aeDe4WKWQaP9izTZkj8= -github.com/sputn1ck/btcd/descriptors v0.0.0-20260813124334-2b2f1d4858bf/go.mod h1:XWHEMoYNwp6EZmKFffecNBLYPthgRIL0krbb15ItbkA= +github.com/sputn1ck/btcd/descriptors v0.0.0-20260813130254-877b61a38433 h1:wDzmhQANHN7nhloCjy0wNRYsWJNXKPyWf8GqWkxzfUI= +github.com/sputn1ck/btcd/descriptors v0.0.0-20260813130254-877b61a38433/go.mod h1:XWHEMoYNwp6EZmKFffecNBLYPthgRIL0krbb15ItbkA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= diff --git a/itest/lnd_descriptor_sweep_test.go b/itest/lnd_descriptor_sweep_test.go index acb1b368d88..6f815f59300 100644 --- a/itest/lnd_descriptor_sweep_test.go +++ b/itest/lnd_descriptor_sweep_test.go @@ -8,8 +8,11 @@ import ( "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/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/signrpc" @@ -25,7 +28,8 @@ const ( descriptorSweepValue = btcutil.Amount(1_000_000) ) -// testDescriptorSweep exercises both branches of a toy P2WSH HTLC descriptor: +// testDescriptorSweep exercises both branches of toy native P2WSH and P2TR +// HTLC descriptors: // // (key_success && sha256(preimage)) || // (key_timeout && after(cltv_height)) @@ -37,42 +41,64 @@ const ( func testDescriptorSweep(ht *lntest.HarnessTest) { tests := []struct { name string + taproot bool providePreimage bool keyFamilyOffset int32 }{ { - name: "preimage branch", + name: "p2wsh preimage branch", + taproot: false, providePreimage: true, keyFamilyOffset: 0, }, { - name: "cltv branch", + name: "p2wsh cltv branch", + taproot: false, providePreimage: false, keyFamilyOffset: 10, }, + { + name: "p2tr preimage branch", + taproot: true, + providePreimage: true, + keyFamilyOffset: 20, + }, + { + name: "p2tr cltv branch", + taproot: true, + providePreimage: false, + keyFamilyOffset: 30, + }, } for _, test := range tests { - test := test if !ht.Run(test.name, func(t *testing.T) { st := ht.Subtest(t) testDescriptorSweepBranch( - st, test.providePreimage, test.keyFamilyOffset, + st, test.taproot, test.providePreimage, + test.keyFamilyOffset, ) }) { + break } } } -func testDescriptorSweepBranch(ht *lntest.HarnessTest, providePreimage bool, - keyFamilyOffset int32) { +func testDescriptorSweepBranch(ht *lntest.HarnessTest, taproot, + providePreimage bool, keyFamilyOffset int32) { branchName := "preimage" if !providePreimage { branchName = "cltv" } - alice := ht.NewNodeWithCoins("descriptor-sweeper-"+branchName, nil) + descriptorType := "p2wsh" + if taproot { + descriptorType = "p2tr" + } + alice := ht.NewNodeWithCoins( + "descriptor-sweeper-"+descriptorType+"-"+branchName, nil, + ) successKey := alice.RPC.DeriveNextKey(&walletrpc.KeyReq{ KeyFamily: descriptorSweepSuccessKeyFamily + keyFamilyOffset, @@ -84,11 +110,9 @@ func testDescriptorSweepBranch(ht *lntest.HarnessTest, providePreimage bool, preimage := bytes.Repeat([]byte{byte(keyFamilyOffset + 1)}, 32) paymentHash := sha256.Sum256(preimage) cltvHeight := ht.CurrentHeight() + 6 - descriptor := fmt.Sprintf( - "wsh(or_i(and_v(v:pk(%s),sha256(%x)),"+ - "and_v(v:pk(%s),after(%d))))", - hex.EncodeToString(successKey.RawKeyBytes), paymentHash, - hex.EncodeToString(timeoutKey.RawKeyBytes), cltvHeight, + descriptor, keyBindings := descriptorSweepHTLC( + ht, taproot, successKey, timeoutKey, paymentHash, cltvHeight, + keyFamilyOffset, ) registerResp := alice.RPC.RegisterSweepDescriptor( @@ -97,19 +121,25 @@ func testDescriptorSweepBranch(ht *lntest.HarnessTest, providePreimage bool, HeightHint: ht.CurrentHeight(), MinConfs: 1, ExpectedValueSat: uint64(descriptorSweepValue), - KeyBindings: []*walletrpc.SweepDescriptorKeyBinding{ - descriptorSweepKeyBinding(successKey), - descriptorSweepKeyBinding(timeoutKey), - }, - BudgetSat: 100_000, - DeadlineDelta: 10, - Immediate: true, - Label: "itest toy htlc", + KeyBindings: keyBindings, + BudgetSat: 100_000, + DeadlineDelta: 10, + Immediate: true, + Label: "itest toy htlc", }, ) require.Len(ht, registerResp.RegistrationId, 32) require.NotEmpty(ht, registerResp.Address) require.NotEmpty(ht, registerResp.PkScript) + if taproot { + require.True(ht, txscript.IsPayToTaproot(registerResp.PkScript)) + } else { + require.True( + ht, txscript.IsPayToWitnessScriptHash( + registerResp.PkScript, + ), + ) + } // Fund the descriptor only after the chain watch has been installed. fundResp := alice.RPC.SendCoins(&lnrpc.SendCoinsRequest{ @@ -134,7 +164,8 @@ func testDescriptorSweepBranch(ht *lntest.HarnessTest, providePreimage bool, alice.RPC.AddSweepDescriptorData( &walletrpc.AddSweepDescriptorDataRequest{ RegistrationId: registerResp.RegistrationId, - Data: &walletrpc.AddSweepDescriptorDataRequest_Preimage{ + Data: &walletrpc. + AddSweepDescriptorDataRequest_Preimage{ Preimage: preimage, }, }, @@ -153,19 +184,29 @@ func testDescriptorSweepBranch(ht *lntest.HarnessTest, providePreimage bool, pendingSweep.Outpoint.TxidStr) require.Equal(ht, fundingOutpoint.Index, pendingSweep.Outpoint.OutputIndex) - require.Equal(ht, walletrpc.WitnessType_DESCRIPTOR_WSH, - pendingSweep.WitnessType) + witnessType := walletrpc.WitnessType_DESCRIPTOR_WSH + if taproot { + witnessType = walletrpc.WitnessType_DESCRIPTOR_TR + } + require.Equal(ht, witnessType, pendingSweep.WitnessType) + assertDescriptorSweepWitness( + ht, descriptorInput.Witness, registerResp.PkScript, taproot, + ) if providePreimage { // lnd uses the current height as the default transaction // locktime. What matters here is that the success branch does // not inherit the future CLTV from the timeout branch. require.Less(ht, sweepTx.LockTime, cltvHeight) - require.True(ht, witnessContains(descriptorInput.Witness, preimage), - "success witness does not contain the supplied preimage") + require.True( + ht, witnessContains(descriptorInput.Witness, preimage), + "success witness is missing the supplied preimage", + ) } else { require.Equal(ht, cltvHeight, sweepTx.LockTime) - require.False(ht, witnessContains(descriptorInput.Witness, preimage), - "timeout witness unexpectedly contains the preimage") + require.False( + ht, witnessContains(descriptorInput.Witness, preimage), + "timeout witness unexpectedly contains the preimage", + ) } ht.MineBlockWithTx(sweepTx) @@ -175,15 +216,106 @@ func testDescriptorSweepBranch(ht *lntest.HarnessTest, providePreimage bool, ) } -func descriptorSweepKeyBinding( - key *signrpc.KeyDescriptor) *walletrpc.SweepDescriptorKeyBinding { +func descriptorSweepHTLC(ht *lntest.HarnessTest, taproot bool, + successKey, timeoutKey *signrpc.KeyDescriptor, + paymentHash [sha256.Size]byte, cltvHeight uint32, + keyFamilyOffset int32) (string, + []*walletrpc.SweepDescriptorKeyBinding) { + + if !taproot { + success := hex.EncodeToString(successKey.RawKeyBytes) + timeout := hex.EncodeToString(timeoutKey.RawKeyBytes) + return fmt.Sprintf( + "wsh(or_i(and_v(v:pk(%s),sha256(%x)),"+ + "and_v(v:pk(%s),after(%d))))", + success, paymentHash, timeout, cltvHeight, + ), []*walletrpc.SweepDescriptorKeyBinding{ + descriptorSweepKeyBinding( + ht, successKey, false, + ), + descriptorSweepKeyBinding( + ht, timeoutKey, false, + ), + } + } + + _, internalKey := btcec.PrivKeyFromBytes( + bytes.Repeat([]byte{byte(keyFamilyOffset + 2)}, 32), + ) + internal := hex.EncodeToString(schnorr.SerializePubKey(internalKey)) + success := descriptorSweepKeyString(ht, successKey, true) + timeout := descriptorSweepKeyString(ht, timeoutKey, true) + + // Put the two alternatives in distinct leaves. A successful spend must + // therefore reveal both the selected leaf and its Merkle control path. + // The deterministic internal key remains external and unbound, so lnd + // can use only the script paths. + descriptor := fmt.Sprintf( + "tr(%s,{and_v(v:pk(%s),sha256(%x)),"+ + "and_v(v:pk(%s),after(%d))})", + internal, success, paymentHash, timeout, cltvHeight, + ) + + return descriptor, []*walletrpc.SweepDescriptorKeyBinding{ + descriptorSweepKeyBinding(ht, successKey, true), + descriptorSweepKeyBinding(ht, timeoutKey, true), + } +} + +func descriptorSweepKeyBinding(ht *lntest.HarnessTest, + key *signrpc.KeyDescriptor, + taproot bool) *walletrpc.SweepDescriptorKeyBinding { return &walletrpc.SweepDescriptorKeyBinding{ - DescriptorKey: hex.EncodeToString(key.RawKeyBytes), + DescriptorKey: descriptorSweepKeyString(ht, key, taproot), KeyLocator: key.KeyLoc, } } +func descriptorSweepKeyString(ht *lntest.HarnessTest, + key *signrpc.KeyDescriptor, taproot bool) string { + + if !taproot { + return hex.EncodeToString(key.RawKeyBytes) + } + + pubKey, err := btcec.ParsePubKey(key.RawKeyBytes) + require.NoError(ht, err) + + return hex.EncodeToString(schnorr.SerializePubKey(pubKey)) +} + +func assertDescriptorSweepWitness(ht *lntest.HarnessTest, + witness wire.TxWitness, pkScript []byte, taproot bool) { + + require.NotEmpty(ht, witness) + version, program, err := txscript.ExtractWitnessProgramInfo(pkScript) + require.NoError(ht, err) + + if !taproot { + require.Equal(ht, 0, version) + witnessScript := witness[len(witness)-1] + scriptHash := sha256.Sum256(witnessScript) + require.Equal(ht, program, scriptHash[:]) + + return + } + + require.Equal(ht, 1, version) + require.GreaterOrEqual(ht, len(witness), 3) + revealedScript := witness[len(witness)-2] + controlBlockBytes := witness[len(witness)-1] + require.NotEmpty(ht, revealedScript) + require.Len(ht, controlBlockBytes, + txscript.ControlBlockBaseSize+txscript.ControlBlockNodeSize) + + controlBlock, err := txscript.ParseControlBlock(controlBlockBytes) + require.NoError(ht, err) + require.NoError(ht, txscript.VerifyTaprootLeafCommitment( + controlBlock, program, revealedScript, + )) +} + func findDescriptorOutput(ht *lntest.HarnessTest, tx *wire.MsgTx, pkScript []byte) wire.OutPoint { @@ -197,6 +329,7 @@ func findDescriptorOutput(ht *lntest.HarnessTest, tx *wire.MsgTx, } require.Fail(ht, "descriptor output not found in funding transaction") + return wire.OutPoint{} } @@ -209,8 +342,11 @@ func findDescriptorInput(ht *lntest.HarnessTest, tx *wire.MsgTx, } } - require.Failf(ht, "descriptor output not swept", "transaction %v does not "+ - "spend %v", tx.TxHash(), want) + require.Failf( + ht, "descriptor output not swept", + "transaction %v does not spend %v", tx.TxHash(), want, + ) + return nil } @@ -234,6 +370,7 @@ func waitSweepDescriptorState(ht *lntest.HarnessTest, RegistrationId: registrationID, }, ) + return len(resp.Descriptors) == 1 && resp.Descriptors[0].State == want }, lntest.DefaultTimeout, 100*time.Millisecond, diff --git a/lnrpc/walletrpc/driver.go b/lnrpc/walletrpc/driver.go index d06069858a3..fc096b70493 100644 --- a/lnrpc/walletrpc/driver.go +++ b/lnrpc/walletrpc/driver.go @@ -60,16 +60,16 @@ func createNewSubServer(configRegistry lnrpc.SubServerConfigDispatcher) ( "WalletKit RPC server") case config.ChainNotifier == nil: - return nil, nil, fmt.Errorf("ChainNotifier must be set to create " + - "WalletKit RPC server") + return nil, nil, fmt.Errorf("ChainNotifier must be set to " + + "create WalletKit RPC server") case config.DescriptorSweepDB == nil: - return nil, nil, fmt.Errorf("DescriptorSweepDB must be set to " + - "create WalletKit RPC server") + return nil, nil, fmt.Errorf("DescriptorSweepDB must be set " + + "to create WalletKit RPC server") case config.DescriptorSweepReady == nil: - return nil, nil, fmt.Errorf("DescriptorSweepReady must be set to " + - "create WalletKit RPC server") + return nil, nil, fmt.Errorf("DescriptorSweepReady must be " + + "set to create WalletKit RPC server") case config.Chain == nil: return nil, nil, fmt.Errorf("Chain must be set to create " + diff --git a/lnrpc/walletrpc/walletkit.pb.go b/lnrpc/walletrpc/walletkit.pb.go index 087aaa42d55..d2d3a07ec3f 100644 --- a/lnrpc/walletrpc/walletkit.pb.go +++ b/lnrpc/walletrpc/walletkit.pb.go @@ -250,6 +250,9 @@ const ( // A witness constructed from a registered native P2WSH output descriptor and // its selected Miniscript satisfaction plan. WitnessType_DESCRIPTOR_WSH WitnessType = 43 + // A script-path witness constructed from a registered P2TR output descriptor + // and its selected Miniscript satisfaction plan. + WitnessType_DESCRIPTOR_TR WitnessType = 44 ) // Enum value maps for WitnessType. @@ -299,6 +302,7 @@ var ( 41: "TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS_FINAL", 42: "TAPROOT_COMMITMENT_REVOKE_FINAL", 43: "DESCRIPTOR_WSH", + 44: "DESCRIPTOR_TR", } WitnessType_value = map[string]int32{ "UNKNOWN_WITNESS": 0, @@ -345,6 +349,7 @@ var ( "TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS_FINAL": 41, "TAPROOT_COMMITMENT_REVOKE_FINAL": 42, "DESCRIPTOR_WSH": 43, + "DESCRIPTOR_TR": 44, } ) @@ -4798,8 +4803,10 @@ func (x *SweepDescriptorKeyBinding) GetKeyLocator() *signrpc.KeyLocator { type RegisterSweepDescriptorRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The output descriptor to watch and sweep. Private descriptors (WIF or xprv) - // are rejected. The initial implementation accepts fixed-index wsh() - // descriptors only. + // are rejected. The implementation accepts fixed-index native wsh() + // descriptors and tr() descriptors with Miniscript script paths. A tr() + // descriptor is always swept through a script path, so its internal key does + // not need an lnd key binding. OutputDescriptor string `protobuf:"bytes,1,opt,name=output_descriptor,json=outputDescriptor,proto3" json:"output_descriptor,omitempty"` // The derivation index to use for wildcard descriptors. This must be zero in // the initial fixed-index implementation. @@ -5791,7 +5798,7 @@ const file_walletrpc_walletkit_proto_rawDesc = "" + "\x13WITNESS_PUBKEY_HASH\x10\x01\x12\x1e\n" + "\x1aNESTED_WITNESS_PUBKEY_HASH\x10\x02\x12%\n" + "!HYBRID_NESTED_WITNESS_PUBKEY_HASH\x10\x03\x12\x12\n" + - "\x0eTAPROOT_PUBKEY\x10\x04*\xcb\f\n" + + "\x0eTAPROOT_PUBKEY\x10\x04*\xde\f\n" + "\vWitnessType\x12\x13\n" + "\x0fUNKNOWN_WITNESS\x10\x00\x12\x18\n" + "\x14COMMITMENT_TIME_LOCK\x10\x01\x12\x17\n" + @@ -5837,7 +5844,8 @@ const file_walletrpc_walletkit_proto_rawDesc = "" + ")TAPROOT_HTLC_OFFERED_REMOTE_TIMEOUT_FINAL\x10(\x12.\n" + "*TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS_FINAL\x10)\x12#\n" + "\x1fTAPROOT_COMMITMENT_REVOKE_FINAL\x10*\x12\x12\n" + - "\x0eDESCRIPTOR_WSH\x10+*V\n" + + "\x0eDESCRIPTOR_WSH\x10+\x12\x11\n" + + "\rDESCRIPTOR_TR\x10,*V\n" + "\x11ChangeAddressType\x12#\n" + "\x1fCHANGE_ADDRESS_TYPE_UNSPECIFIED\x10\x00\x12\x1c\n" + "\x18CHANGE_ADDRESS_TYPE_P2TR\x10\x01*\xb6\x02\n" + diff --git a/lnrpc/walletrpc/walletkit.proto b/lnrpc/walletrpc/walletkit.proto index fab7cb31687..a0eb0772c46 100644 --- a/lnrpc/walletrpc/walletkit.proto +++ b/lnrpc/walletrpc/walletkit.proto @@ -261,10 +261,12 @@ service WalletKit { material. Any keys that should be signed by lnd must be explicitly bound to a wallet key locator. - The first version of this RPC supports fixed-index P2WSH descriptors. The - registration remains active while lnd waits for the output and for a - satisfiable Miniscript branch. Additional satisfaction data, such as a - hash preimage, can be supplied through AddSweepDescriptorData. + This RPC supports fixed-index native P2WSH descriptors and P2TR descriptors + with Miniscript script paths. A P2TR registration never uses its key path, + so its internal key may remain unbound. The registration remains active + while lnd waits for the output and for a satisfiable Miniscript branch. + Additional satisfaction data, such as a hash preimage, can be supplied + through AddSweepDescriptorData. */ rpc RegisterSweepDescriptor (RegisterSweepDescriptorRequest) returns (RegisterSweepDescriptorResponse); @@ -1240,6 +1242,12 @@ enum WitnessType { its selected Miniscript satisfaction plan. */ DESCRIPTOR_WSH = 43; + + /* + A script-path witness constructed from a registered P2TR output descriptor + and its selected Miniscript satisfaction plan. + */ + DESCRIPTOR_TR = 44; } message PendingSweep { @@ -1778,8 +1786,10 @@ message SweepDescriptorKeyBinding { message RegisterSweepDescriptorRequest { /* The output descriptor to watch and sweep. Private descriptors (WIF or xprv) - are rejected. The initial implementation accepts fixed-index wsh() - descriptors only. + are rejected. The implementation accepts fixed-index native wsh() + descriptors and tr() descriptors with Miniscript script paths. A tr() + descriptor is always swept through a script path, so its internal key does + not need an lnd key binding. */ string output_descriptor = 1; diff --git a/lnrpc/walletrpc/walletkit.swagger.json b/lnrpc/walletrpc/walletkit.swagger.json index 42f6c191c57..e390eb64b99 100644 --- a/lnrpc/walletrpc/walletkit.swagger.json +++ b/lnrpc/walletrpc/walletkit.swagger.json @@ -672,7 +672,7 @@ }, "post": { "summary": "lncli: `wallet registersweepdescriptor`\nRegisterSweepDescriptor registers a fixed output descriptor for on-chain\ndiscovery and automatic sweeping. The descriptor must use only public key\nmaterial. Any keys that should be signed by lnd must be explicitly bound to\na wallet key locator.", - "description": "The first version of this RPC supports fixed-index P2WSH descriptors. The\nregistration remains active while lnd waits for the output and for a\nsatisfiable Miniscript branch. Additional satisfaction data, such as a\nhash preimage, can be supplied through AddSweepDescriptorData.", + "description": "This RPC supports fixed-index native P2WSH descriptors and P2TR descriptors\nwith Miniscript script paths. A P2TR registration never uses its key path,\nso its internal key may remain unbound. The registration remains active\nwhile lnd waits for the output and for a satisfiable Miniscript branch.\nAdditional satisfaction data, such as a hash preimage, can be supplied\nthrough AddSweepDescriptorData.", "operationId": "WalletKit_RegisterSweepDescriptor", "responses": { "200": { @@ -2215,7 +2215,7 @@ "properties": { "output_descriptor": { "type": "string", - "description": "The output descriptor to watch and sweep. Private descriptors (WIF or xprv)\nare rejected. The initial implementation accepts fixed-index wsh()\ndescriptors only." + "description": "The output descriptor to watch and sweep. Private descriptors (WIF or xprv)\nare rejected. The implementation accepts fixed-index native wsh()\ndescriptors and tr() descriptors with Miniscript script paths. A tr()\ndescriptor is always swept through a script path, so its internal key does\nnot need an lnd key binding." }, "derivation_index": { "type": "integer", @@ -2778,10 +2778,11 @@ "TAPROOT_HTLC_OFFERED_REMOTE_TIMEOUT_FINAL", "TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS_FINAL", "TAPROOT_COMMITMENT_REVOKE_FINAL", - "DESCRIPTOR_WSH" + "DESCRIPTOR_WSH", + "DESCRIPTOR_TR" ], "default": "UNKNOWN_WITNESS", - "description": " - COMMITMENT_TIME_LOCK: A witness that allows us to spend the output of a commitment transaction\nafter a relative lock-time lockout.\n - COMMITMENT_NO_DELAY: A witness that allows us to spend a settled no-delay output immediately on a\ncounterparty's commitment transaction.\n - COMMITMENT_REVOKE: A witness that allows us to sweep the settled output of a malicious\ncounterparty's who broadcasts a revoked commitment transaction.\n - HTLC_OFFERED_REVOKE: A witness that allows us to sweep an HTLC which we offered to the remote\nparty in the case that they broadcast a revoked commitment state.\n - HTLC_ACCEPTED_REVOKE: A witness that allows us to sweep an HTLC output sent to us in the case that\nthe remote party broadcasts a revoked commitment state.\n - HTLC_OFFERED_TIMEOUT_SECOND_LEVEL: A witness that allows us to sweep an HTLC output that we extended to a\nparty, but was never fulfilled. This HTLC output isn't directly on the\ncommitment transaction, but is the result of a confirmed second-level HTLC\ntransaction. As a result, we can only spend this after a CSV delay.\n - HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL: A witness that allows us to sweep an HTLC output that was offered to us, and\nfor which we have a payment preimage. This HTLC output isn't directly on our\ncommitment transaction, but is the result of confirmed second-level HTLC\ntransaction. As a result, we can only spend this after a CSV delay.\n - HTLC_OFFERED_REMOTE_TIMEOUT: A witness that allows us to sweep an HTLC that we offered to the remote\nparty which lies in the commitment transaction of the remote party. We can\nspend this output after the absolute CLTV timeout of the HTLC as passed.\n - HTLC_ACCEPTED_REMOTE_SUCCESS: A witness that allows us to sweep an HTLC that was offered to us by the\nremote party. We use this witness in the case that the remote party goes to\nchain, and we know the pre-image to the HTLC. We can sweep this without any\nadditional timeout.\n - HTLC_SECOND_LEVEL_REVOKE: A witness that allows us to sweep an HTLC from the remote party's commitment\ntransaction in the case that the broadcast a revoked commitment, but then\nalso immediately attempt to go to the second level to claim the HTLC.\n - WITNESS_KEY_HASH: A witness type that allows us to spend a regular p2wkh output that's sent to\nan output which is under complete control of the backing wallet.\n - NESTED_WITNESS_KEY_HASH: A witness type that allows us to sweep an output that sends to a nested P2SH\nscript that pays to a key solely under our control.\n - COMMITMENT_ANCHOR: A witness type that allows us to spend our anchor on the commitment\ntransaction.\n - COMMITMENT_NO_DELAY_TWEAKLESS: A witness type that is similar to the COMMITMENT_NO_DELAY type,\nbut it omits the tweak that randomizes the key we need to\nspend with a channel peer supplied set of randomness.\n - COMMITMENT_TO_REMOTE_CONFIRMED: A witness type that allows us to spend our output on the counterparty's\ncommitment transaction after a confirmation.\n - HTLC_OFFERED_TIMEOUT_SECOND_LEVEL_INPUT_CONFIRMED: A witness type that allows us to sweep an HTLC output that we extended\nto a party, but was never fulfilled. This _is_ the HTLC output directly\non our commitment transaction, and the input to the second-level HTLC\ntimeout transaction. It can only be spent after CLTV expiry, and\ncommitment confirmation.\n - HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL_INPUT_CONFIRMED: A witness type that allows us to sweep an HTLC output that was offered\nto us, and for which we have a payment preimage. This _is_ the HTLC\noutput directly on our commitment transaction, and the input to the\nsecond-level HTLC success transaction. It can only be spent after the\ncommitment has confirmed.\n - LEASE_COMMITMENT_TIME_LOCK: A witness type that allows us to spend our output on our local\ncommitment transaction after a relative and absolute lock-time lockout as\npart of the script enforced lease commitment type.\n - LEASE_COMMITMENT_TO_REMOTE_CONFIRMED: A witness type that allows us to spend our output on the counterparty's\ncommitment transaction after a confirmation and absolute locktime as part\nof the script enforced lease commitment type.\n - LEASE_HTLC_OFFERED_TIMEOUT_SECOND_LEVEL: A witness type that allows us to sweep an HTLC output that we extended\nto a party, but was never fulfilled. This HTLC output isn't directly on\nthe commitment transaction, but is the result of a confirmed second-level\nHTLC transaction. As a result, we can only spend this after a CSV delay\nand CLTV locktime as part of the script enforced lease commitment type.\n - LEASE_HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL: A witness type that allows us to sweep an HTLC output that was offered\nto us, and for which we have a payment preimage. This HTLC output isn't\ndirectly on our commitment transaction, but is the result of confirmed\nsecond-level HTLC transaction. As a result, we can only spend this after\na CSV delay and CLTV locktime as part of the script enforced lease\ncommitment type.\n - TAPROOT_PUB_KEY_SPEND: A witness type that allows us to spend a regular p2tr output that's sent\nto an output which is under complete control of the backing wallet.\n - TAPROOT_LOCAL_COMMIT_SPEND: A witness type that allows us to spend our settled local commitment after a\nCSV delay when we force close the channel.\n - TAPROOT_REMOTE_COMMIT_SPEND: A witness type that allows us to spend our settled local commitment after\na CSV delay when the remote party has force closed the channel.\n - TAPROOT_ANCHOR_SWEEP_SPEND: A witness type that we'll use for spending our own anchor output.\n - TAPROOT_HTLC_OFFERED_TIMEOUT_SECOND_LEVEL: A witness that allows us to timeout an HTLC we offered to the remote party\non our commitment transaction. We use this when we need to go on chain to\ntime out an HTLC.\n - TAPROOT_HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL: A witness type that allows us to sweep an HTLC we accepted on our commitment\ntransaction after we go to the second level on chain.\n - TAPROOT_HTLC_SECOND_LEVEL_REVOKE: A witness that allows us to sweep an HTLC on the revoked transaction of the\nremote party that goes to the second level.\n - TAPROOT_HTLC_ACCEPTED_REVOKE: A witness that allows us to sweep an HTLC sent to us by the remote party\nin the event that they broadcast a revoked state.\n - TAPROOT_HTLC_OFFERED_REVOKE: A witness that allows us to sweep an HTLC we offered to the remote party if\nthey broadcast a revoked commitment.\n - TAPROOT_HTLC_OFFERED_REMOTE_TIMEOUT: A witness that allows us to sweep an HTLC we offered to the remote party\nthat lies on the commitment transaction for the remote party. We can spend\nthis output after the absolute CLTV timeout of the HTLC as passed.\n - TAPROOT_HTLC_LOCAL_OFFERED_TIMEOUT: A witness type that allows us to sign the second level HTLC timeout\ntransaction when spending from an HTLC residing on our local commitment\ntransaction.\nThis is used by the sweeper to re-sign inputs if it needs to aggregate\nseveral second level HTLCs.\n - TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS: A witness that allows us to sweep an HTLC that was offered to us by the\nremote party for a taproot channels. We use this witness in the case that\nthe remote party goes to chain, and we know the pre-image to the HTLC. We\ncan sweep this without any additional timeout.\n - TAPROOT_HTLC_ACCEPTED_LOCAL_SUCCESS: A witness type that allows us to sweep the HTLC offered to us on our local\ncommitment transaction. We'll use this when we need to go on chain to sweep\nthe HTLC. In this case, this is the second level HTLC success transaction.\n - TAPROOT_COMMITMENT_REVOKE: A witness that allows us to sweep the settled output of a malicious\ncounterparty's who broadcasts a revoked taproot commitment transaction.\n - TAPROOT_LOCAL_COMMIT_SPEND_FINAL: A witness type that allows us to spend our settled local commitment after a\nCSV delay when we force close a production taproot channel.\n - TAPROOT_REMOTE_COMMIT_SPEND_FINAL: A witness type that allows us to spend our settled local commitment after\na CSV delay when the remote party has force closed a production taproot\nchannel.\n - TAPROOT_HTLC_OFFERED_TIMEOUT_SECOND_LEVEL_FINAL: A witness that allows us to timeout an HTLC we offered to the remote party\non our production taproot commitment transaction. We use this when we need\nto go on chain to time out an HTLC.\n - TAPROOT_HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL_FINAL: A witness type that allows us to sweep an HTLC we accepted on our\nproduction taproot commitment transaction after we go to the second level\non chain.\n - TAPROOT_HTLC_OFFERED_REMOTE_TIMEOUT_FINAL: A witness that allows us to sweep an HTLC we offered to the remote party\nthat lies on the production taproot commitment transaction for the remote\nparty. We can spend this output after the absolute CLTV timeout of the\nHTLC as passed.\n - TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS_FINAL: A witness that allows us to sweep an HTLC that was offered to us by the\nremote party for a production taproot channel. We use this witness in the\ncase that the remote party goes to chain, and we know the pre-image to the\nHTLC. We can sweep this without any additional timeout.\n - TAPROOT_COMMITMENT_REVOKE_FINAL: A witness type that allows us to sweep the settled output of a malicious\ncounterparty's who broadcasts a revoked production taproot commitment\ntransaction.\n - DESCRIPTOR_WSH: A witness constructed from a registered native P2WSH output descriptor and\nits selected Miniscript satisfaction plan." + "description": " - COMMITMENT_TIME_LOCK: A witness that allows us to spend the output of a commitment transaction\nafter a relative lock-time lockout.\n - COMMITMENT_NO_DELAY: A witness that allows us to spend a settled no-delay output immediately on a\ncounterparty's commitment transaction.\n - COMMITMENT_REVOKE: A witness that allows us to sweep the settled output of a malicious\ncounterparty's who broadcasts a revoked commitment transaction.\n - HTLC_OFFERED_REVOKE: A witness that allows us to sweep an HTLC which we offered to the remote\nparty in the case that they broadcast a revoked commitment state.\n - HTLC_ACCEPTED_REVOKE: A witness that allows us to sweep an HTLC output sent to us in the case that\nthe remote party broadcasts a revoked commitment state.\n - HTLC_OFFERED_TIMEOUT_SECOND_LEVEL: A witness that allows us to sweep an HTLC output that we extended to a\nparty, but was never fulfilled. This HTLC output isn't directly on the\ncommitment transaction, but is the result of a confirmed second-level HTLC\ntransaction. As a result, we can only spend this after a CSV delay.\n - HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL: A witness that allows us to sweep an HTLC output that was offered to us, and\nfor which we have a payment preimage. This HTLC output isn't directly on our\ncommitment transaction, but is the result of confirmed second-level HTLC\ntransaction. As a result, we can only spend this after a CSV delay.\n - HTLC_OFFERED_REMOTE_TIMEOUT: A witness that allows us to sweep an HTLC that we offered to the remote\nparty which lies in the commitment transaction of the remote party. We can\nspend this output after the absolute CLTV timeout of the HTLC as passed.\n - HTLC_ACCEPTED_REMOTE_SUCCESS: A witness that allows us to sweep an HTLC that was offered to us by the\nremote party. We use this witness in the case that the remote party goes to\nchain, and we know the pre-image to the HTLC. We can sweep this without any\nadditional timeout.\n - HTLC_SECOND_LEVEL_REVOKE: A witness that allows us to sweep an HTLC from the remote party's commitment\ntransaction in the case that the broadcast a revoked commitment, but then\nalso immediately attempt to go to the second level to claim the HTLC.\n - WITNESS_KEY_HASH: A witness type that allows us to spend a regular p2wkh output that's sent to\nan output which is under complete control of the backing wallet.\n - NESTED_WITNESS_KEY_HASH: A witness type that allows us to sweep an output that sends to a nested P2SH\nscript that pays to a key solely under our control.\n - COMMITMENT_ANCHOR: A witness type that allows us to spend our anchor on the commitment\ntransaction.\n - COMMITMENT_NO_DELAY_TWEAKLESS: A witness type that is similar to the COMMITMENT_NO_DELAY type,\nbut it omits the tweak that randomizes the key we need to\nspend with a channel peer supplied set of randomness.\n - COMMITMENT_TO_REMOTE_CONFIRMED: A witness type that allows us to spend our output on the counterparty's\ncommitment transaction after a confirmation.\n - HTLC_OFFERED_TIMEOUT_SECOND_LEVEL_INPUT_CONFIRMED: A witness type that allows us to sweep an HTLC output that we extended\nto a party, but was never fulfilled. This _is_ the HTLC output directly\non our commitment transaction, and the input to the second-level HTLC\ntimeout transaction. It can only be spent after CLTV expiry, and\ncommitment confirmation.\n - HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL_INPUT_CONFIRMED: A witness type that allows us to sweep an HTLC output that was offered\nto us, and for which we have a payment preimage. This _is_ the HTLC\noutput directly on our commitment transaction, and the input to the\nsecond-level HTLC success transaction. It can only be spent after the\ncommitment has confirmed.\n - LEASE_COMMITMENT_TIME_LOCK: A witness type that allows us to spend our output on our local\ncommitment transaction after a relative and absolute lock-time lockout as\npart of the script enforced lease commitment type.\n - LEASE_COMMITMENT_TO_REMOTE_CONFIRMED: A witness type that allows us to spend our output on the counterparty's\ncommitment transaction after a confirmation and absolute locktime as part\nof the script enforced lease commitment type.\n - LEASE_HTLC_OFFERED_TIMEOUT_SECOND_LEVEL: A witness type that allows us to sweep an HTLC output that we extended\nto a party, but was never fulfilled. This HTLC output isn't directly on\nthe commitment transaction, but is the result of a confirmed second-level\nHTLC transaction. As a result, we can only spend this after a CSV delay\nand CLTV locktime as part of the script enforced lease commitment type.\n - LEASE_HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL: A witness type that allows us to sweep an HTLC output that was offered\nto us, and for which we have a payment preimage. This HTLC output isn't\ndirectly on our commitment transaction, but is the result of confirmed\nsecond-level HTLC transaction. As a result, we can only spend this after\na CSV delay and CLTV locktime as part of the script enforced lease\ncommitment type.\n - TAPROOT_PUB_KEY_SPEND: A witness type that allows us to spend a regular p2tr output that's sent\nto an output which is under complete control of the backing wallet.\n - TAPROOT_LOCAL_COMMIT_SPEND: A witness type that allows us to spend our settled local commitment after a\nCSV delay when we force close the channel.\n - TAPROOT_REMOTE_COMMIT_SPEND: A witness type that allows us to spend our settled local commitment after\na CSV delay when the remote party has force closed the channel.\n - TAPROOT_ANCHOR_SWEEP_SPEND: A witness type that we'll use for spending our own anchor output.\n - TAPROOT_HTLC_OFFERED_TIMEOUT_SECOND_LEVEL: A witness that allows us to timeout an HTLC we offered to the remote party\non our commitment transaction. We use this when we need to go on chain to\ntime out an HTLC.\n - TAPROOT_HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL: A witness type that allows us to sweep an HTLC we accepted on our commitment\ntransaction after we go to the second level on chain.\n - TAPROOT_HTLC_SECOND_LEVEL_REVOKE: A witness that allows us to sweep an HTLC on the revoked transaction of the\nremote party that goes to the second level.\n - TAPROOT_HTLC_ACCEPTED_REVOKE: A witness that allows us to sweep an HTLC sent to us by the remote party\nin the event that they broadcast a revoked state.\n - TAPROOT_HTLC_OFFERED_REVOKE: A witness that allows us to sweep an HTLC we offered to the remote party if\nthey broadcast a revoked commitment.\n - TAPROOT_HTLC_OFFERED_REMOTE_TIMEOUT: A witness that allows us to sweep an HTLC we offered to the remote party\nthat lies on the commitment transaction for the remote party. We can spend\nthis output after the absolute CLTV timeout of the HTLC as passed.\n - TAPROOT_HTLC_LOCAL_OFFERED_TIMEOUT: A witness type that allows us to sign the second level HTLC timeout\ntransaction when spending from an HTLC residing on our local commitment\ntransaction.\nThis is used by the sweeper to re-sign inputs if it needs to aggregate\nseveral second level HTLCs.\n - TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS: A witness that allows us to sweep an HTLC that was offered to us by the\nremote party for a taproot channels. We use this witness in the case that\nthe remote party goes to chain, and we know the pre-image to the HTLC. We\ncan sweep this without any additional timeout.\n - TAPROOT_HTLC_ACCEPTED_LOCAL_SUCCESS: A witness type that allows us to sweep the HTLC offered to us on our local\ncommitment transaction. We'll use this when we need to go on chain to sweep\nthe HTLC. In this case, this is the second level HTLC success transaction.\n - TAPROOT_COMMITMENT_REVOKE: A witness that allows us to sweep the settled output of a malicious\ncounterparty's who broadcasts a revoked taproot commitment transaction.\n - TAPROOT_LOCAL_COMMIT_SPEND_FINAL: A witness type that allows us to spend our settled local commitment after a\nCSV delay when we force close a production taproot channel.\n - TAPROOT_REMOTE_COMMIT_SPEND_FINAL: A witness type that allows us to spend our settled local commitment after\na CSV delay when the remote party has force closed a production taproot\nchannel.\n - TAPROOT_HTLC_OFFERED_TIMEOUT_SECOND_LEVEL_FINAL: A witness that allows us to timeout an HTLC we offered to the remote party\non our production taproot commitment transaction. We use this when we need\nto go on chain to time out an HTLC.\n - TAPROOT_HTLC_ACCEPTED_SUCCESS_SECOND_LEVEL_FINAL: A witness type that allows us to sweep an HTLC we accepted on our\nproduction taproot commitment transaction after we go to the second level\non chain.\n - TAPROOT_HTLC_OFFERED_REMOTE_TIMEOUT_FINAL: A witness that allows us to sweep an HTLC we offered to the remote party\nthat lies on the production taproot commitment transaction for the remote\nparty. We can spend this output after the absolute CLTV timeout of the\nHTLC as passed.\n - TAPROOT_HTLC_ACCEPTED_REMOTE_SUCCESS_FINAL: A witness that allows us to sweep an HTLC that was offered to us by the\nremote party for a production taproot channel. We use this witness in the\ncase that the remote party goes to chain, and we know the pre-image to the\nHTLC. We can sweep this without any additional timeout.\n - TAPROOT_COMMITMENT_REVOKE_FINAL: A witness type that allows us to sweep the settled output of a malicious\ncounterparty's who broadcasts a revoked production taproot commitment\ntransaction.\n - DESCRIPTOR_WSH: A witness constructed from a registered native P2WSH output descriptor and\nits selected Miniscript satisfaction plan.\n - DESCRIPTOR_TR: A script-path witness constructed from a registered P2TR output descriptor\nand its selected Miniscript satisfaction plan." } } } diff --git a/lnrpc/walletrpc/walletkit_grpc.pb.go b/lnrpc/walletrpc/walletkit_grpc.pb.go index 906f59ae6f6..23770c5730c 100644 --- a/lnrpc/walletrpc/walletkit_grpc.pb.go +++ b/lnrpc/walletrpc/walletkit_grpc.pb.go @@ -197,10 +197,12 @@ type WalletKitClient interface { // material. Any keys that should be signed by lnd must be explicitly bound to // a wallet key locator. // - // The first version of this RPC supports fixed-index P2WSH descriptors. The - // registration remains active while lnd waits for the output and for a - // satisfiable Miniscript branch. Additional satisfaction data, such as a - // hash preimage, can be supplied through AddSweepDescriptorData. + // This RPC supports fixed-index native P2WSH descriptors and P2TR descriptors + // with Miniscript script paths. A P2TR registration never uses its key path, + // so its internal key may remain unbound. The registration remains active + // while lnd waits for the output and for a satisfiable Miniscript branch. + // Additional satisfaction data, such as a hash preimage, can be supplied + // through AddSweepDescriptorData. RegisterSweepDescriptor(ctx context.Context, in *RegisterSweepDescriptorRequest, opts ...grpc.CallOption) (*RegisterSweepDescriptorResponse, error) // lncli: `wallet addsweepdescriptordata` // AddSweepDescriptorData supplies data that became available after a sweep @@ -792,10 +794,12 @@ type WalletKitServer interface { // material. Any keys that should be signed by lnd must be explicitly bound to // a wallet key locator. // - // The first version of this RPC supports fixed-index P2WSH descriptors. The - // registration remains active while lnd waits for the output and for a - // satisfiable Miniscript branch. Additional satisfaction data, such as a - // hash preimage, can be supplied through AddSweepDescriptorData. + // This RPC supports fixed-index native P2WSH descriptors and P2TR descriptors + // with Miniscript script paths. A P2TR registration never uses its key path, + // so its internal key may remain unbound. The registration remains active + // while lnd waits for the output and for a satisfiable Miniscript branch. + // Additional satisfaction data, such as a hash preimage, can be supplied + // through AddSweepDescriptorData. RegisterSweepDescriptor(context.Context, *RegisterSweepDescriptorRequest) (*RegisterSweepDescriptorResponse, error) // lncli: `wallet addsweepdescriptordata` // AddSweepDescriptorData supplies data that became available after a sweep diff --git a/lnrpc/walletrpc/walletkit_server.go b/lnrpc/walletrpc/walletkit_server.go index 064c54a3270..97e7b76e962 100644 --- a/lnrpc/walletrpc/walletkit_server.go +++ b/lnrpc/walletrpc/walletkit_server.go @@ -382,31 +382,41 @@ func (w *WalletKit) DescriptorSweeper() *descriptorsweep.Service { // RegisterSweepDescriptor registers a fixed descriptor for discovery and // automatic sweeping. -func (w *WalletKit) RegisterSweepDescriptor(ctx context.Context, - req *RegisterSweepDescriptorRequest) (*RegisterSweepDescriptorResponse, - error) { +func (w *WalletKit) RegisterSweepDescriptor( + ctx context.Context, req *RegisterSweepDescriptorRequest, +) (*RegisterSweepDescriptorResponse, error) { service, err := w.requireDescriptorSweeper() if err != nil { return nil, err } if req.BudgetSat > math.MaxInt64 { - return nil, errors.New("sweep budget exceeds maximum satoshi amount") + return nil, errors.New( + "sweep budget exceeds maximum satoshi amount", + ) } if req.ExpectedValueSat > math.MaxInt64 { - return nil, errors.New("expected value exceeds maximum satoshi amount") + return nil, errors.New( + "expected value exceeds maximum satoshi amount", + ) } bindings := make([]descriptorsweep.KeyBinding, len(req.KeyBindings)) for i, binding := range req.KeyBindings { if binding == nil || binding.KeyLocator == nil { - return nil, fmt.Errorf("key binding %d has no key locator", i) + return nil, fmt.Errorf( + "key binding %d has no key locator", i, + ) } if binding.KeyLocator.KeyFamily < 0 { - return nil, fmt.Errorf("key binding %d has negative key family", i) + return nil, fmt.Errorf( + "key binding %d has negative key family", i, + ) } if binding.KeyLocator.KeyIndex < 0 { - return nil, fmt.Errorf("key binding %d has negative key index", i) + return nil, fmt.Errorf( + "key binding %d has negative key index", i, + ) } bindings[i] = descriptorsweep.KeyBinding{ @@ -446,9 +456,9 @@ func (w *WalletKit) RegisterSweepDescriptor(ctx context.Context, // AddSweepDescriptorData supplies satisfaction data that became available // after descriptor registration. -func (w *WalletKit) AddSweepDescriptorData(ctx context.Context, - req *AddSweepDescriptorDataRequest) (*AddSweepDescriptorDataResponse, - error) { +func (w *WalletKit) AddSweepDescriptorData( + ctx context.Context, req *AddSweepDescriptorDataRequest, +) (*AddSweepDescriptorDataResponse, error) { service, err := w.requireDescriptorSweeper() if err != nil { @@ -477,8 +487,9 @@ func (w *WalletKit) AddSweepDescriptorData(ctx context.Context, // ListSweepDescriptors lists descriptor sweep registrations, optionally // selecting a single registration by ID. -func (w *WalletKit) ListSweepDescriptors(_ context.Context, - req *ListSweepDescriptorsRequest) (*ListSweepDescriptorsResponse, error) { +func (w *WalletKit) ListSweepDescriptors( + _ context.Context, req *ListSweepDescriptorsRequest, +) (*ListSweepDescriptorsResponse, error) { service, err := w.requireDescriptorSweeper() if err != nil { @@ -516,7 +527,9 @@ func (w *WalletKit) requireDescriptorSweeper() (*descriptorsweep.Service, error) { if w.descriptorSweeper == nil { - return nil, errors.New("descriptor sweep service is unavailable") + return nil, errors.New( + "descriptor sweep service is unavailable", + ) } return w.descriptorSweeper, nil @@ -638,8 +651,9 @@ func (r *ServerShell) RegisterWithRestServer(ctx context.Context, // methods routed towards it. // // NOTE: This is part of the lnrpc.GrpcHandler interface. -func (r *ServerShell) CreateSubServer(configRegistry lnrpc.SubServerConfigDispatcher) ( - lnrpc.SubServer, lnrpc.MacaroonPerms, error) { +func (r *ServerShell) CreateSubServer( + configRegistry lnrpc.SubServerConfigDispatcher, +) (lnrpc.SubServer, lnrpc.MacaroonPerms, error) { subServer, macPermissions, err := createNewSubServer(configRegistry) if err != nil { @@ -1226,9 +1240,18 @@ func (w *WalletKit) PendingSweeps(ctx context.Context, rpcPendingSweeps := make([]*PendingSweep, 0, len(inputsMap)) for _, inp := range inputsMap { witnessType, ok := allWitnessTypes[inp.WitnessType] - if !ok && descriptorsweep.IsWitnessType(inp.WitnessType) { - witnessType = WitnessType_DESCRIPTOR_WSH - ok = true + if !ok { + switch { + case descriptorsweep.IsTaprootWitnessType( + inp.WitnessType, + ): + witnessType = WitnessType_DESCRIPTOR_TR + ok = true + + case descriptorsweep.IsWitnessType(inp.WitnessType): + witnessType = WitnessType_DESCRIPTOR_WSH + ok = true + } } if !ok { return nil, fmt.Errorf("unhandled witness type %v for "+ diff --git a/lnrpc/walletrpc/walletkit_server_test.go b/lnrpc/walletrpc/walletkit_server_test.go index 2e090bfce9f..a9fc13cc3cb 100644 --- a/lnrpc/walletrpc/walletkit_server_test.go +++ b/lnrpc/walletrpc/walletkit_server_test.go @@ -31,10 +31,11 @@ func TestWitnessTypeMapping(t *testing.T) { t.Parallel() // Tests that both enum types have the same length except the - // UNKNOWN_WITNESS and DESCRIPTOR_WSH types, which don't correspond to - // concrete entries in the statically numbered input witness type enum. + // UNKNOWN_WITNESS, DESCRIPTOR_WSH and DESCRIPTOR_TR types, which don't + // correspond to concrete entries in the statically numbered input + // witness type enum. require.Equal( - t, len(allWitnessTypes), len(WitnessType_name)-2, + t, len(allWitnessTypes), len(WitnessType_name)-3, "number of witness types should match proto definition", ) diff --git a/lntest/rpc/wallet_kit.go b/lntest/rpc/wallet_kit.go index 18678822c9e..072b6ed05cf 100644 --- a/lntest/rpc/wallet_kit.go +++ b/lntest/rpc/wallet_kit.go @@ -277,7 +277,8 @@ func (h *HarnessRPC) BumpFeeAssertErr(req *walletrpc.BumpFeeRequest) error { // RegisterSweepDescriptor registers a descriptor sweep and asserts success. func (h *HarnessRPC) RegisterSweepDescriptor( - req *walletrpc.RegisterSweepDescriptorRequest) *walletrpc.RegisterSweepDescriptorResponse { + req *walletrpc.RegisterSweepDescriptorRequest, +) *walletrpc.RegisterSweepDescriptorResponse { ctxt, cancel := context.WithTimeout(h.runCtx, DefaultTimeout) defer cancel() @@ -290,7 +291,8 @@ func (h *HarnessRPC) RegisterSweepDescriptor( // AddSweepDescriptorData adds late satisfaction data and asserts success. func (h *HarnessRPC) AddSweepDescriptorData( - req *walletrpc.AddSweepDescriptorDataRequest) *walletrpc.AddSweepDescriptorDataResponse { + req *walletrpc.AddSweepDescriptorDataRequest, +) *walletrpc.AddSweepDescriptorDataResponse { ctxt, cancel := context.WithTimeout(h.runCtx, DefaultTimeout) defer cancel() @@ -303,7 +305,8 @@ func (h *HarnessRPC) AddSweepDescriptorData( // ListSweepDescriptors lists descriptor sweeps and asserts success. func (h *HarnessRPC) ListSweepDescriptors( - req *walletrpc.ListSweepDescriptorsRequest) *walletrpc.ListSweepDescriptorsResponse { + req *walletrpc.ListSweepDescriptorsRequest, +) *walletrpc.ListSweepDescriptorsResponse { ctxt, cancel := context.WithTimeout(h.runCtx, DefaultTimeout) defer cancel() diff --git a/lnwallet/rpcwallet/rpcwallet.go b/lnwallet/rpcwallet/rpcwallet.go index 8aea3cf0f16..0d50eb8714a 100644 --- a/lnwallet/rpcwallet/rpcwallet.go +++ b/lnwallet/rpcwallet/rpcwallet.go @@ -956,7 +956,9 @@ func (r *RPCKeyRing) remoteSign(tx *wire.MsgTx, signDesc *input.SignDescriptor, ) // Catch incorrect signing input index, just in case. - if signDesc.InputIndex < 0 || signDesc.InputIndex >= len(packet.Inputs) { + if signDesc.InputIndex < 0 || + signDesc.InputIndex >= len(packet.Inputs) { + return nil, fmt.Errorf("invalid input index in sign descriptor") } in := &packet.Inputs[signDesc.InputIndex] @@ -1159,24 +1161,11 @@ func (r *RPCKeyRing) remoteSign(tx *wire.MsgTx, signDesc *input.SignDescriptor, Bip32Path: d.Bip32Path, }} - // We also need to supply a control block. But because we don't - // know the internal key nor the merkle proofs (both is not - // supplied through the SignOutputRaw RPC) and is technically - // not really needed by the signer (since we only want a - // signature, the full witness stack is assembled by the caller - // of this RPC), we can get by with faking certain information - // that we don't have. - fakeInternalKey, _ := btcec.ParsePubKey(d.PubKey) - fakeKeyIsOdd := d.PubKey[0] == input.PubKeyFormatCompressedOdd - controlBlock := txscript.ControlBlock{ - InternalKey: fakeInternalKey, - OutputKeyYIsOdd: fakeKeyIsOdd, - LeafVersion: leaf.LeafVersion, - } - blockBytes, err := controlBlock.ToBytes() + blockBytes, err := taprootScriptControlBlock( + signDesc, d.PubKey, leaf, + ) if err != nil { - return nil, fmt.Errorf("error serializing control "+ - "block: %v", err) + return nil, err } in.TaprootLeafScript = []*psbt.TaprootTapLeafScript{{ @@ -1220,6 +1209,85 @@ func (r *RPCKeyRing) remoteSign(tx *wire.MsgTx, signDesc *input.SignDescriptor, return extractSignature(in, signDesc.SignMethod) } +// taprootScriptControlBlock returns the real control block when the caller +// supplied one. Older SignOutputRaw callers don't provide the tree proof, so +// the historical synthetic single-leaf block remains as a fallback: the +// remote signer only needs it to identify the leaf being signed. Strict and +// hardware signers can validate the actual output commitment when it is +// available. +func taprootScriptControlBlock(signDesc *input.SignDescriptor, + derivationPubKey []byte, leaf txscript.TapLeaf) ([]byte, error) { + + if len(signDesc.ControlBlock) != 0 { + controlBlock, err := txscript.ParseControlBlock( + signDesc.ControlBlock, + ) + if err != nil { + return nil, fmt.Errorf( + "invalid taproot control block: %w", err, + ) + } + if controlBlock.LeafVersion != leaf.LeafVersion { + return nil, fmt.Errorf( + "taproot control block leaf version %v "+ + "does not match leaf version %v", + controlBlock.LeafVersion, leaf.LeafVersion) + } + if signDesc.Output == nil { + return nil, fmt.Errorf( + "taproot script signing output is missing", + ) + } + version, program, err := txscript.ExtractWitnessProgramInfo( + signDesc.Output.PkScript, + ) + if err != nil || version != 1 || + len(program) != schnorr.PubKeyBytesLen { + + return nil, fmt.Errorf( + "taproot script signing output has invalid " + + "witness program", + ) + } + if err := txscript.VerifyTaprootLeafCommitment( + controlBlock, program, leaf.Script, + ); err != nil { + return nil, fmt.Errorf( + "control block does not commit leaf to "+ + "output: %w", + err, + ) + } + + return bytes.Clone(signDesc.ControlBlock), nil + } + + // SignDescriptor historically did not always carry the internal key or + // Merkle proof. Preserve that compatibility by constructing a valid + // placeholder around the derivation key. + fakeInternalKey, err := btcec.ParsePubKey(derivationPubKey) + if err != nil { + return nil, fmt.Errorf( + "parse derivation key for taproot control block: %w", + err, + ) + } + controlBlock := txscript.ControlBlock{ + InternalKey: fakeInternalKey, + OutputKeyYIsOdd: derivationPubKey[0] == + input.PubKeyFormatCompressedOdd, + LeafVersion: leaf.LeafVersion, + } + blockBytes, err := controlBlock.ToBytes() + if err != nil { + return nil, fmt.Errorf( + "serialize taproot control block: %w", err, + ) + } + + return blockBytes, nil +} + // extractSignature attempts to extract the signature from the PSBT input, // looking at different fields depending on the signing method that was used. func extractSignature(in *psbt.PInput, diff --git a/lnwallet/rpcwallet/rpcwallet_test.go b/lnwallet/rpcwallet/rpcwallet_test.go index 63dadf32699..653b94a294b 100644 --- a/lnwallet/rpcwallet/rpcwallet_test.go +++ b/lnwallet/rpcwallet/rpcwallet_test.go @@ -5,6 +5,8 @@ import ( "errors" "testing" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/psbt/v2" "github.com/btcsuite/btcd/txscript/v2" @@ -229,3 +231,67 @@ func TestPopulateNonSignedInputWitnessUtxosEmptyPkScript(t *testing.T) { require.Nil(t, packet.Inputs[0].WitnessUtxo) } + +func TestTaprootScriptControlBlock(t *testing.T) { + t.Parallel() + + internalKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + leafA := txscript.NewBaseTapLeaf([]byte{txscript.OP_TRUE}) + leafB := txscript.NewBaseTapLeaf([]byte{txscript.OP_2}) + tree := txscript.AssembleTaprootScriptTree(leafA, leafB) + proof := tree.LeafMerkleProofs[0] + controlBlock := proof.ToControlBlock(internalKey.PubKey()) + controlBlockBytes, err := controlBlock.ToBytes() + require.NoError(t, err) + rootHash := tree.RootNode.TapHash() + outputKey := txscript.ComputeTaprootOutputKey( + internalKey.PubKey(), rootHash[:], + ) + pkScript, err := txscript.PayToTaprootScript(outputKey) + require.NoError(t, err) + derivationKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + signDesc := &input.SignDescriptor{ + ControlBlock: controlBlockBytes, + Output: &wire.TxOut{ + Value: 50_000, + PkScript: pkScript, + }, + } + + got, err := taprootScriptControlBlock( + signDesc, derivationKey.PubKey().SerializeCompressed(), leafA, + ) + require.NoError(t, err) + require.Equal(t, controlBlockBytes, got) + got[0] ^= 1 + require.NotEqual(t, got, signDesc.ControlBlock, + "helper must not return SignDescriptor-owned storage") + + wrongLeaf := leafB + _, err = taprootScriptControlBlock( + signDesc, derivationKey.PubKey().SerializeCompressed(), + wrongLeaf, + ) + require.ErrorContains(t, err, "does not commit") + + bad := *signDesc + bad.ControlBlock = []byte{1} + _, err = taprootScriptControlBlock( + &bad, derivationKey.PubKey().SerializeCompressed(), leafA, + ) + require.ErrorContains(t, err, "invalid taproot control block") + + // Existing callers without a real proof retain the synthetic fallback. + fallback := &input.SignDescriptor{} + got, err = taprootScriptControlBlock( + fallback, derivationKey.PubKey().SerializeCompressed(), leafA, + ) + require.NoError(t, err) + parsed, err := txscript.ParseControlBlock(got) + require.NoError(t, err) + require.Empty(t, parsed.InclusionProof) + require.Equal(t, schnorr.SerializePubKey(derivationKey.PubKey()), + schnorr.SerializePubKey(parsed.InternalKey)) +} diff --git a/rpcserver.go b/rpcserver.go index a747101c351..59aa582d9d5 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -680,6 +680,8 @@ func newRPCServer(cfg *Config, interceptorChain *rpcperms.InterceptorChain, // addDeps populates all dependencies needed by the RPC server, and any // of the sub-servers that it maintains. When this is done, the RPC server can // be started, and start accepting RPC calls. +// +//nolint:funlen // Keep the daemon's dependency wiring in one auditable place. func (r *rpcServer) addDeps(ctx context.Context, s *server, macService *macaroons.Service, subServerCgs *subRPCServerConfigs, atpl *autopilot.Manager, diff --git a/sweep/descriptorsweep/retry.go b/sweep/descriptorsweep/retry.go index b8fb72f9621..dccc53452ec 100644 --- a/sweep/descriptorsweep/retry.go +++ b/sweep/descriptorsweep/retry.go @@ -21,6 +21,7 @@ func deterministic(err error) error { if err == nil { return nil } + return &deterministicError{err: err} } @@ -36,6 +37,7 @@ func retryable(err error) error { if err == nil || isRetryable(err) { return err } + return &retryableError{err: err} } @@ -52,12 +54,13 @@ func (s *Service) retryBounds() (time.Duration, time.Duration) { if maximum < initial { maximum = initial } + return initial, maximum } // scheduleRetry runs at most one retry worker for a registration and operation -// kind. Delays grow exponentially but are capped, and every wait is interruptible -// by Stop. +// kind. Delays grow exponentially but are capped, and every wait can be +// interrupted by Stop. func (s *Service) scheduleRetry(key retryKey, task func() error, onDeterministic func(error)) { @@ -72,9 +75,9 @@ func (s *Service) scheduleRetry(key retryKey, task func() error, s.retrying = make(map[retryKey]bool) } if _, ok := s.retrying[key]; ok { - // The active worker will run the task again even if its current call - // succeeds. This closes the race where a newly attached result stream - // fails before the worker that attached it has exited. + // The active worker will run the task again even if its current + // call succeeds. This closes the race where a newly attached + // result stream fails before the worker that attached it exits. s.retrying[key] = true s.mu.Unlock() return @@ -99,6 +102,7 @@ func (s *Service) scheduleRetry(key retryKey, task func() error, s.mu.Lock() delete(s.retrying, key) s.mu.Unlock() + return } @@ -110,10 +114,12 @@ func (s *Service) scheduleRetry(key retryKey, task func() error, s.retrying[key] = false s.mu.Unlock() backoff = initial + continue } delete(s.retrying, key) s.mu.Unlock() + return } if !isRetryable(err) { @@ -123,6 +129,7 @@ func (s *Service) scheduleRetry(key retryKey, task func() error, if onDeterministic != nil { onDeterministic(err) } + return } s.mu.Lock() @@ -146,13 +153,10 @@ func (s *Service) handleRegistrationError(id RegistrationID, err error) { if err == nil { return } - if !isRetryable(err) { - if !isDeterministic(err) { - err = retryable(err) - } else { - s.failDurably(id, err) - return - } + if isDeterministic(err) { + s.failDurably(id, err) + + return } s.scheduleRetry(retryKey{id: id, kind: "resume"}, func() error { @@ -172,13 +176,18 @@ func (s *Service) failDurably(id RegistrationID, failure error) { return } - s.scheduleRetry(retryKey{id: id, kind: "persist-failure"}, func() error { - return s.persistTransition(id, func(next *storedRecord) error { - next.Status = StatusFailed - next.Error = failure.Error() - return nil - }) - }, nil) + s.scheduleRetry( + retryKey{id: id, kind: "persist-failure"}, func() error { + return s.persistTransition( + id, func(next *storedRecord) error { + next.Status = StatusFailed + next.Error = failure.Error() + + return nil + }, + ) + }, nil, + ) } func (s *Service) persistTransition(id RegistrationID, @@ -187,6 +196,7 @@ func (s *Service) persistTransition(id RegistrationID, s.mu.Lock() defer s.mu.Unlock() _, err := s.updateRecordLocked(id, mutate) + return err } @@ -204,6 +214,7 @@ func (s *Service) launch(worker func()) bool { s.wg.Add(1) s.mu.Unlock() go worker() + return true } } diff --git a/sweep/descriptorsweep/service.go b/sweep/descriptorsweep/service.go index 17c410e2193..abf6686cbbf 100644 --- a/sweep/descriptorsweep/service.go +++ b/sweep/descriptorsweep/service.go @@ -9,6 +9,8 @@ import ( "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/descriptors" "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" @@ -25,38 +27,47 @@ func (s *Service) verifyBindings(desc *descriptors.Descriptor, bindings []KeyBinding) error { keys := desc.Keys() - if len(bindings) != len(keys) { - return fmt.Errorf("descriptor has %d keys, got %d bindings", - len(keys), len(bindings)) - } - remaining := make(map[string]struct{}, len(keys)) for _, key := range keys { + // Validate every descriptor key, including keys that aren't + // bound to lnd. Otherwise an unbound Taproot internal key could + // smuggle a + // private or ranged key expression into durable RPC state. + if _, err := descriptorPubKey(key); err != nil { + return fmt.Errorf("descriptor key %q: %w", key, err) + } remaining[key] = struct{}{} } for _, binding := range bindings { if _, ok := remaining[binding.DescriptorKey]; !ok { - return fmt.Errorf("unknown or duplicate descriptor key %q", - binding.DescriptorKey) + return fmt.Errorf( + "unknown or duplicate descriptor key %q", + binding.DescriptorKey, + ) } derived, err := s.cfg.KeyRing.DeriveKey(binding.KeyLocator) if err != nil { - return fmt.Errorf("derive key %q: %w", binding.DescriptorKey, err) + return fmt.Errorf( + "derive key %q: %w", + binding.DescriptorKey, + err, + ) } if derived.PubKey == nil { return fmt.Errorf("derived key %q has no public key", binding.DescriptorKey) } - want, err := descriptorPubKey(binding.DescriptorKey) - if err != nil { - return fmt.Errorf("descriptor key %q: %w", - binding.DescriptorKey, err) - } - if !bytes.Equal(want, derived.PubKey.SerializeCompressed()) { - return fmt.Errorf("descriptor key %q does not match locator", - binding.DescriptorKey) + if !descriptorKeyMatches( + binding.DescriptorKey, derived.PubKey, + desc.DescType() == descriptors.DescTypeTr, + ) { + + return fmt.Errorf( + "descriptor key %q does not match locator", + binding.DescriptorKey, + ) } delete(remaining, binding.DescriptorKey) @@ -65,22 +76,56 @@ func (s *Service) verifyBindings(desc *descriptors.Descriptor, return nil } -func descriptorPubKey(key string) ([]byte, error) { - // Fixed-index MVP bindings intentionally only accept a raw compressed key. - // Extended keys and origin paths are range-capable and need a derivation - // aware binding format before they can be safely accepted. - if len(key) != 66 { - return nil, errors.New("only raw compressed public keys are supported") +func descriptorKeyMatches(key string, pubKey *btcec.PublicKey, + taproot bool) bool { + + want, err := descriptorPubKey(key) + if err != nil || pubKey == nil { + return false } + + // All raw keys in tr() are converted to x-only by BIP386, including + // compressed descriptor keys. Compare only the x coordinate in that + // context while retaining the wallet's real parity for remote signing. + if taproot || len(key) == 64 { + return bytes.Equal(want[1:], schnorr.SerializePubKey(pubKey)) + } + + return bytes.Equal(want, pubKey.SerializeCompressed()) +} + +func descriptorPubKey(key string) ([]byte, error) { + // Fixed-index MVP bindings intentionally only accept raw compressed or + // x-only keys. Extended keys and origin paths are range-capable and + // need a + // derivation-aware binding format before they can be safely accepted. raw, err := hex.DecodeString(key) if err != nil { return nil, err } - pubKey, err := btcec.ParsePubKey(raw) - if err != nil { - return nil, err + switch len(raw) { + case schnorr.PubKeyBytesLen: + pubKey, err := schnorr.ParsePubKey(raw) + if err != nil { + return nil, err + } + + return pubKey.SerializeCompressed(), nil + + case btcec.PubKeyBytesLenCompressed: + pubKey, err := btcec.ParsePubKey(raw) + if err != nil { + return nil, err + } + + return pubKey.SerializeCompressed(), nil + + default: + return nil, errors.New( + "only raw compressed or x-only public keys are " + + "supported", + ) } - return pubKey.SerializeCompressed(), nil } func rejectTimeLocks(desc *descriptors.Descriptor) error { @@ -92,15 +137,63 @@ func rejectTimeLocks(desc *descriptors.Descriptor) error { switch timelock.Type { case descriptors.TimelockTypeAbsolute: if timelock.Value >= txscript.LockTimeThreshold { - return errors.New("time-based CLTV is not supported") + return errors.New( + "time-based CLTV is not supported", + ) } case descriptors.TimelockTypeRelative: if timelock.Value&wire.SequenceLockTimeIsSeconds != 0 { - return errors.New("time-based CSV is not supported") + return errors.New( + "time-based CSV is not supported", + ) } } } + + return nil +} + +func validateSupportedPaths(desc *descriptors.Descriptor, + bindings []KeyBinding) error { + + availableKeys := make(map[string]struct{}, len(bindings)) + for _, binding := range bindings { + availableKeys[binding.DescriptorKey] = struct{}{} + } + absolute := uint32(txscript.LockTimeThreshold - 1) + relative := uint32(wire.SequenceLockTimeMask) + plan, err := desc.PlanAt(0, 0, descriptors.Assets{ + LookupEcdsaSig: func(key string) bool { + _, ok := availableKeys[key] + return ok + }, + LookupTapLeafScriptSig: func(key, _ string) (uint32, bool) { + _, ok := availableKeys[key] + return schnorr.SignatureSize, ok + }, + LookupPreimage: func(hashFunc string, _ []byte) bool { + return hashFunc == "sha256" + }, + AbsoluteLocktime: &absolute, + RelativeLocktime: &relative, + }) + if err != nil { + return errors.New( + "descriptor has no path satisfiable by the " + + "registered keys and supported data", + ) + } + if desc.DescType() != descriptors.DescTypeTr { + return nil + } + spendInfo, ok := plan.TaprootSpendInfo() + if !ok || spendInfo.Kind != descriptors.TaprootSpendScriptPath { + return errors.New( + "taproot key-path-only descriptors are not supported", + ) + } + return nil } @@ -131,6 +224,7 @@ func (s *Service) resume(id RegistrationID) error { if err == nil || isRetryable(err) { return err } + return deterministic(err) default: if record.OutPoint != nil { @@ -140,8 +234,10 @@ func (s *Service) resume(id RegistrationID) error { s.mu.RLock() bestHeight := s.bestHeight s.mu.RUnlock() + return s.scanMatureBlocks(id, bestHeight) } + return s.watchOutput(id) } } @@ -170,16 +266,20 @@ func (s *Service) waitForReady() { var err error epochs, err = s.cfg.Notifier.RegisterBlockEpochNtfn(nil) if err == nil { - // A nil best block asks the notifier to send its current tip - // immediately. Consume that tip before restoring registrations so - // CLTV/CSV scheduling and frozen fee deadlines never start from + // A nil best block asks the notifier to send its + // current tip immediately. Consume that tip before + // restoring registrations so CLTV/CSV scheduling and + // frozen fee deadlines never start from // height zero after a restart. for { select { case epoch, ok := <-epochs.Epochs: if !ok { epochs.Cancel() - err = errors.New("block epoch stream closed before current tip") + err = errors.New( + "epoch stream closed", + ) + break } if epoch == nil || epoch.Height < 0 { @@ -195,6 +295,7 @@ func (s *Service) waitForReady() { epochs.Cancel() return } + break } if err == nil { @@ -210,9 +311,10 @@ func (s *Service) waitForReady() { } // Keep notifierReady false while draining registrations. Register and - // AddPreimage only persist and mark the ID pending in that state. Taking - // and clearing pending under the same lock used to publish readiness - // ensures data added while an ID is being resumed triggers another pass. + // AddPreimage only persist and mark the ID pending in that state. + // Taking and clearing pending under the same lock used to publish + // readiness ensures data added while an ID is being resumed triggers + // another pass. for { s.mu.Lock() if len(s.pending) == 0 { @@ -280,9 +382,16 @@ func (s *Service) restoreSweep(id RegistrationID) error { "restored descriptor plan changed frozen branch", )) } + if err := verifyFrozenTaprootPlan(desc, plan, frozen); err != nil { + return deterministic(err) + } - inp, err := newDescriptorInput(desc, plan, frozen) + inp, err := newDescriptorInput(s.cfg.KeyRing, desc, plan, frozen) if err != nil { + if isRetryable(err) { + return err + } + return deterministic(err) } params := sweep.Params{ @@ -300,6 +409,7 @@ func (s *Service) restoreSweep(id RegistrationID) error { return retryablef("restore descriptor sweep input: %w", err) } s.launch(func() { s.consumeSweepResult(id, result) }) + return nil } @@ -330,7 +440,11 @@ func (s *Service) watchOutput(id RegistrationID) error { } event, err := s.cfg.Notifier.RegisterConfirmationsNtfn( - nil, pkScript, minConfs, heightHint, chainntnfs.WithIncludeBlock(), + nil, + pkScript, + minConfs, + heightHint, + chainntnfs.WithIncludeBlock(), ) if err != nil { s.mu.Unlock() @@ -351,6 +465,7 @@ func (s *Service) watchOutput(id RegistrationID) error { s.mu.Unlock() go s.consumeConfirmation(id, event) + return nil } @@ -365,6 +480,7 @@ func (s *Service) consumeConfirmation(id RegistrationID, s.handleRegistrationError(id, retryable(errors.New( "descriptor confirmation stream closed", ))) + return } if err := s.outputConfirmed(id, conf); err != nil { @@ -411,17 +527,22 @@ func (s *Service) outputConfirmed(id RegistrationID, )) } - // A script-only notifier may first report an output with a value - // chosen by an unrelated party. Advance past that block and persist - // the scan cursor; re-registering the same script can replay the + // A script-only notifier may first report an output with a + // value chosen by an unrelated party. Advance past that block + // and persist the scan cursor; re-registering the same script + // can replay the // notifier's cached match forever. bestHeight := s.bestHeight - _, err := s.updateRecordLocked(id, func(next *storedRecord) error { - next.WatchHeight = conf.BlockHeight + 1 - next.BlockScan = true - next.Status = StatusWatching - return nil - }) + _, err := s.updateRecordLocked( + id, + func(next *storedRecord) error { + next.WatchHeight = conf.BlockHeight + 1 + next.BlockScan = true + next.Status = StatusWatching + + return nil + }, + ) if err != nil { s.mu.Unlock() return err @@ -439,6 +560,7 @@ func (s *Service) outputConfirmed(id RegistrationID, next.ConfirmationHeight = conf.BlockHeight next.BlockScan = false next.Status = StatusFound + return nil }) if err != nil { @@ -469,7 +591,9 @@ func findExactOutput(block *wire.MsgBlock, pkScript []byte, expectedValue int64) (*exactOutputMatch, error) { if block == nil { - return nil, errors.New("descriptor confirmation did not include its block") + return nil, errors.New( + "descriptor confirmation did not include its block", + ) } var match *exactOutputMatch @@ -484,7 +608,10 @@ func findExactOutput(block *wire.MsgBlock, pkScript []byte, continue } if match != nil { - return nil, errors.New("confirmed block has multiple exact descriptor outputs") + return nil, errors.New( + "confirmed block has multiple exact " + + "descriptor outputs", + ) } match = &exactOutputMatch{ tx: tx, @@ -498,14 +625,16 @@ func findExactOutput(block *wire.MsgBlock, pkScript []byte, func (s *Service) scanMatureBlocks(id RegistrationID, bestHeight uint32) error { + select { case <-s.quit: return nil default: } - // Readiness restore and block epochs can overlap briefly. Serializing the - // range scan prevents duplicate block work and duplicate sweeper offers. + // Readiness restore and block epochs can overlap briefly. Serializing + // the range scan prevents duplicate block work and duplicate sweeper + // offers. s.scanMu.Lock() defer s.scanMu.Unlock() @@ -538,8 +667,11 @@ func (s *Service) scanMatureBlocks(id RegistrationID, for height := startHeight; height <= matureThrough; height++ { blockHash, err := s.cfg.BlockSource.GetBlockHash(int64(height)) if err != nil { - return retryablef("get descriptor scan block %d hash: %w", - height, err) + return retryablef( + "get descriptor scan block %d hash: %w", + height, + err, + ) } block, err := s.cfg.BlockSource.GetBlock(blockHash) if err != nil { @@ -582,19 +714,25 @@ func (s *Service) scanMatureBlocks(id RegistrationID, return err } s.mu.Unlock() + continue } txid := match.tx.TxHash() op := wire.OutPoint{Hash: txid, Index: match.outputIndex} - _, err = s.updateRecordLocked(id, func(next *storedRecord) error { - next.OutPoint = &op - next.Value = match.tx.TxOut[match.outputIndex].Value - next.ConfirmationHeight = height - next.BlockScan = false - next.Status = StatusFound - return nil - }) + _, err = s.updateRecordLocked( + id, + func(next *storedRecord) error { + next.OutPoint = &op + txOut := match.tx.TxOut[match.outputIndex] + next.Value = txOut.Value + next.ConfirmationHeight = height + next.BlockScan = false + next.Status = StatusFound + + return nil + }, + ) if err != nil { s.mu.Unlock() return err @@ -620,6 +758,7 @@ func (s *Service) consumeEpochs(event *chainntnfs.BlockEpochEvent) { return } event = replacement + continue } if epoch == nil || epoch.Height < 0 { @@ -630,7 +769,8 @@ func (s *Service) consumeEpochs(event *chainntnfs.BlockEpochEvent) { ids := make([]RegistrationID, 0, len(s.records)) scanIDs := make([]RegistrationID, 0, len(s.records)) for id, record := range s.records { - if record.BlockScan && record.Status != StatusFailed && + if record.BlockScan && + record.Status != StatusFailed && record.Status != StatusSwept { scanIDs = append(scanIDs, id) @@ -648,7 +788,6 @@ func (s *Service) consumeEpochs(event *chainntnfs.BlockEpochEvent) { if err := s.scanMatureBlocks( id, uint32(epoch.Height), ); err != nil { - s.handleRegistrationError(id, err) } } @@ -688,6 +827,7 @@ func (s *Service) reconnectEpochs() *chainntnfs.BlockEpochEvent { default: } } + return nil } if backoff < maximum { @@ -743,6 +883,7 @@ func (s *Service) trySweep(id RegistrationID) error { }, ) s.mu.Unlock() + return storeErr } @@ -758,47 +899,76 @@ func (s *Service) trySweep(id RegistrationID) error { *constraints.AbsoluteLocktime >= txscript.LockTimeThreshold { s.mu.Unlock() - return deterministic(errors.New("selected path has time-based CLTV")) + return deterministic( + errors.New("selected path has time-based CLTV"), + ) } if constraints.RelativeLocktime != nil && - *constraints.RelativeLocktime&wire.SequenceLockTimeIsSeconds != 0 { + *constraints.RelativeLocktime& + wire.SequenceLockTimeIsSeconds != 0 { s.mu.Unlock() - return deterministic(errors.New("selected path has time-based CSV")) + return deterministic( + errors.New("selected path has time-based CSV"), + ) } // Freeze all witness material and the exact branch before giving the // input to UtxoSweeper. The only mutable object after this point is the // durable lifecycle record, never the input implementation. - frozen := record.cloneForInput() bestHeight := s.bestHeight var deadline *int32 if record.DeadlineDelta > 0 { if bestHeight > math.MaxInt32-record.DeadlineDelta { s.mu.Unlock() return deterministic(errors.New( - "selected sweep deadline exceeds maximum block height", + "selected sweep deadline exceeds maximum " + + "block height", )) } value := int32(bestHeight + record.DeadlineDelta) deadline = &value } + var tapLeafHash *chainhash.Hash + var tapControlBlock []byte + if desc.DescType() == descriptors.DescTypeTr { + spendInfo, ok := plan.TaprootSpendInfo() + if !ok || spendInfo.Kind != descriptors.TaprootSpendScriptPath { + s.mu.Unlock() + return deterministic(errors.New( + "selected taproot plan is not a script path", + )) + } + leafHash := spendInfo.LeafHash + tapLeafHash = &leafHash + tapControlBlock = append([]byte(nil), spendInfo.ControlBlock...) + } + next, err := s.updateRecordLocked(id, func(next *storedRecord) error { next.PlanLocktime = cloneUint32(constraints.AbsoluteLocktime) next.PlanSequence = cloneUint32(constraints.RelativeLocktime) next.PlanDeadlineHeight = cloneInt32(deadline) + next.PlanTapLeafHash = cloneHash(tapLeafHash) + next.PlanTapControlBlock = append( + []byte(nil), tapControlBlock..., + ) next.Status = StatusSweeping + return nil }) if err != nil { s.mu.Unlock() return err } - frozen = next.cloneForInput() + frozen := next.cloneForInput() s.mu.Unlock() - inp, err := newDescriptorInput(desc, plan, frozen) + inp, err := newDescriptorInput(s.cfg.KeyRing, desc, plan, frozen) if err != nil { + if isRetryable(err) { + return err + } + return deterministic(err) } params := sweep.Params{ @@ -818,6 +988,7 @@ func (s *Service) trySweep(id RegistrationID) error { } s.launch(func() { s.consumeSweepResult(id, result) }) + return nil } @@ -831,15 +1002,19 @@ func (s *Service) consumeSweepResult(id RegistrationID, s.handleRegistrationError(id, retryable(errors.New( "descriptor sweep result stream closed", ))) + return } var persistErr error if sweepResult.Err != nil { - // A sweeper result can represent a transient publisher or - // backend failure. Re-offer the exact frozen branch unless the - // input was definitively spent by somebody else. + // A sweeper result can represent a transient publisher + // or backend failure. Re-offer the exact frozen branch + // unless somebody else definitively spent the input. if errors.Is(sweepResult.Err, sweep.ErrRemoteSpend) || - errors.Is(sweepResult.Err, sweep.ErrExclusiveGroupSpend) { + errors.Is( + sweepResult.Err, + sweep.ErrExclusiveGroupSpend, + ) { s.failDurably(id, sweepResult.Err) return @@ -849,22 +1024,30 @@ func (s *Service) consumeSweepResult(id RegistrationID, ) } else { persistSuccess := func() error { - return s.persistTransition(id, func(next *storedRecord) error { - next.Status = StatusSwept - next.Error = "" - if sweepResult.Tx != nil { - txid := sweepResult.Tx.TxHash() - next.SweepTxID = &txid - } - return nil - }) + return s.persistTransition( + id, func(next *storedRecord) error { + next.Status = StatusSwept + next.Error = "" + if sweepResult.Tx != nil { + tx := sweepResult.Tx + txid := tx.TxHash() + next.SweepTxID = &txid + } + + return nil + }, + ) } persistErr = persistSuccess() if persistErr != nil { + retry := retryKey{ + id: id, kind: "persist-success", + } s.scheduleRetry( - retryKey{id: id, kind: "persist-success"}, + retry, persistSuccess, nil, ) + return } } @@ -884,7 +1067,8 @@ func makeAssets(record *storedRecord, bestHeight uint32) descriptors.Assets { } // Candidate lock values expose every currently mature branch to PlanAt. - // The returned Plan.TxConstraints then freezes only the selected branch. + // The returned Plan.TxConstraints then freezes only the selected + // branch. var absolute, relative *uint32 if bestHeight > 0 { absolute = &bestHeight @@ -901,6 +1085,10 @@ func makeAssets(record *storedRecord, bestHeight uint32) descriptors.Assets { _, ok := availableKeys[key] return ok }, + LookupTapLeafScriptSig: func(key, _ string) (uint32, bool) { + _, ok := availableKeys[key] + return schnorr.SignatureSize, ok + }, LookupPreimage: func(hashFunc string, hash []byte) bool { _, ok := record.Preimages[preimageKey(hashFunc, hash)] return ok @@ -914,6 +1102,7 @@ func makeFrozenAssets(record *storedRecord) descriptors.Assets { assets := makeAssets(record, 0) assets.AbsoluteLocktime = cloneUint32(record.PlanLocktime) assets.RelativeLocktime = cloneUint32(record.PlanSequence) + return assets } @@ -921,9 +1110,38 @@ func sameOptionalUint32(a, b *uint32) bool { if a == nil || b == nil { return a == nil && b == nil } + return *a == *b } +func verifyFrozenTaprootPlan(desc *descriptors.Descriptor, + plan *descriptors.Plan, record *storedRecord) error { + + if desc.DescType() != descriptors.DescTypeTr { + return nil + } + if record.PlanTapLeafHash == nil || + len(record.PlanTapControlBlock) == 0 { + + return errors.New( + "frozen taproot plan has no selected leaf identity", + ) + } + + spendInfo, ok := plan.TaprootSpendInfo() + if !ok || spendInfo.Kind != descriptors.TaprootSpendScriptPath { + return errors.New("restored taproot plan is not a script path") + } + if *record.PlanTapLeafHash != spendInfo.LeafHash || !bytes.Equal( + record.PlanTapControlBlock, spendInfo.ControlBlock, + ) { + + return errors.New("restored taproot plan changed frozen leaf") + } + + return nil +} + func preimageKey(hashFunc string, hash []byte) string { return hashFunc + ":" + hex.EncodeToString(hash) } @@ -938,14 +1156,29 @@ func (r *storedRecord) cloneForInput() *storedRecord { result.PlanLocktime = cloneUint32(r.PlanLocktime) result.PlanSequence = cloneUint32(r.PlanSequence) result.PlanDeadlineHeight = cloneInt32(r.PlanDeadlineHeight) + result.PlanTapLeafHash = cloneHash(r.PlanTapLeafHash) + result.PlanTapControlBlock = append( + []byte(nil), r.PlanTapControlBlock..., + ) + return &result } +func cloneHash(value *chainhash.Hash) *chainhash.Hash { + if value == nil { + return nil + } + cloned := *value + + return &cloned +} + func cloneUint32(value *uint32) *uint32 { if value == nil { return nil } cloned := *value + return &cloned } @@ -954,6 +1187,7 @@ func cloneInt32(value *int32) *int32 { return nil } cloned := *value + return &cloned } @@ -968,27 +1202,59 @@ type descriptorInput struct { preimage fn.Option[lntypes.Preimage] } -func newDescriptorInput(desc *descriptors.Descriptor, plan *descriptors.Plan, +func newDescriptorInput(keyRing keychain.KeyRing, + desc *descriptors.Descriptor, plan *descriptors.Plan, record *storedRecord) (*descriptorInput, error) { if record.OutPoint == nil { return nil, errors.New("descriptor input has no outpoint") } - witnessType, err := newDescriptorWitnessType(desc, plan, record) + witnessType, err := newDescriptorWitnessType( + keyRing, + desc, + plan, + record, + ) if err != nil { return nil, err } + signMethod := input.WitnessV0SignMethod + hashType := txscript.SigHashAll + witnessScript := append([]byte(nil), record.WitnessScript...) + var controlBlock []byte + if desc.DescType() == descriptors.DescTypeTr { + spendInfo, ok := plan.TaprootSpendInfo() + if !ok { + return nil, errors.New( + "taproot descriptor plan has no spend metadata", + ) + } + if spendInfo.Kind != descriptors.TaprootSpendScriptPath { + return nil, errors.New( + "taproot key-path descriptor sweeps are not " + + "supported", + ) + } + signMethod = input.TaprootScriptSpendSignMethod + hashType = txscript.SigHashDefault + witnessScript = append([]byte(nil), spendInfo.LeafScript...) + controlBlock = append([]byte(nil), spendInfo.ControlBlock...) + } + return &descriptorInput{ op: *record.OutPoint, signDesc: input.SignDescriptor{ - WitnessScript: append([]byte(nil), record.WitnessScript...), + WitnessScript: witnessScript, Output: &wire.TxOut{ - Value: record.Value, - PkScript: append([]byte(nil), record.PkScript...), + Value: record.Value, + PkScript: append( + []byte(nil), + record.PkScript...), }, - HashType: txscript.SigHashAll, - SignMethod: input.WitnessV0SignMethod, + HashType: hashType, + SignMethod: signMethod, + ControlBlock: controlBlock, }, witnessType: witnessType, heightHint: record.ConfirmationHeight, @@ -1005,6 +1271,7 @@ func firstPreimage(preimages map[string][]byte) fn.Option[lntypes.Preimage] { copy(preimage[:], raw) return fn.Some(preimage) } + return fn.None[lntypes.Preimage]() } @@ -1012,6 +1279,7 @@ func valueOrZero(value *uint32) uint32 { if value == nil { return 0 } + return *value } @@ -1021,10 +1289,17 @@ func (i *descriptorInput) RequiredLockTime() (uint32, bool) { if i.locktime == nil { return 0, false } + return *i.locktime, true } -func (i *descriptorInput) WitnessType() input.WitnessType { return i.witnessType } -func (i *descriptorInput) SignDesc() *input.SignDescriptor { return &i.signDesc } + +func (i *descriptorInput) WitnessType() input.WitnessType { + return i.witnessType +} + +func (i *descriptorInput) SignDesc() *input.SignDescriptor { + return &i.signDesc +} func (i *descriptorInput) CraftInputScript(signer input.Signer, tx *wire.MsgTx, hashes *txscript.TxSigHashes, fetcher txscript.PrevOutputFetcher, index int) (*input.Script, error) { @@ -1032,6 +1307,7 @@ func (i *descriptorInput) CraftInputScript(signer input.Signer, i.signDesc.SigHashes = hashes i.signDesc.PrevOutputFetcher = fetcher i.signDesc.InputIndex = index + return i.witnessType.craft(signer, &i.signDesc, tx, index) } func (i *descriptorInput) BlocksToMaturity() uint32 { return i.sequence } @@ -1051,6 +1327,8 @@ type descriptorWitnessType struct { preimages map[string][]byte witnessScript []byte witnessSize lntypes.WeightUnit + taproot bool + leafHash string } // IsWitnessType reports whether a sweeper witness belongs to this service. @@ -1061,42 +1339,111 @@ func IsWitnessType(witness input.WitnessType) bool { return ok } -func newDescriptorWitnessType(desc *descriptors.Descriptor, - plan *descriptors.Plan, record *storedRecord) (*descriptorWitnessType, error) { +// IsTaprootWitnessType reports whether a sweeper witness is a descriptor +// Taproot script-path spend. +func IsTaprootWitnessType(witness input.WitnessType) bool { + descriptorWitness, ok := witness.(*descriptorWitnessType) + return ok && descriptorWitness.taproot +} - bindings := make(map[string]keychain.KeyDescriptor, len(record.KeyBindings)) +func newDescriptorWitnessType(keyRing keychain.KeyRing, + desc *descriptors.Descriptor, plan *descriptors.Plan, + record *storedRecord) (*descriptorWitnessType, error) { + + bindings := make( + map[string]keychain.KeyDescriptor, + len(record.KeyBindings), + ) for _, binding := range record.KeyBindings { - pubKeyBytes, err := descriptorPubKey(binding.DescriptorKey) - if err != nil { - return nil, err + if keyRing == nil { + return nil, errors.New( + "descriptor witness key ring is required", + ) } - pubKey, err := btcec.ParsePubKey(pubKeyBytes) + derived, err := keyRing.DeriveKey(binding.KeyLocator) if err != nil { - return nil, err + return nil, retryable(fmt.Errorf("derive key %q: %w", + binding.DescriptorKey, err)) + } + if derived.PubKey == nil { + return nil, fmt.Errorf( + "derived key %q has no public key", + binding.DescriptorKey, + ) + } + if !descriptorKeyMatches( + binding.DescriptorKey, derived.PubKey, + desc.DescType() == descriptors.DescTypeTr, + ) { + + return nil, fmt.Errorf( + "descriptor key %q no longer matches locator", + binding.DescriptorKey, + ) } bindings[binding.DescriptorKey] = keychain.KeyDescriptor{ KeyLocator: binding.KeyLocator, - PubKey: pubKey, + // Preserve the wallet's actual compressed point. An + // x-only descriptor key does not encode parity, while + // remote signer PSBT derivations require the exact + // locator-derived public key. + PubKey: derived.PubKey, } } - maxWeight, err := desc.MaxWeightToSatisfy() - if err != nil { - return nil, err + witnessScript := append([]byte(nil), record.WitnessScript...) + taproot := desc.DescType() == descriptors.DescTypeTr + var leafHash string + var witnessSize lntypes.WeightUnit + if taproot { + spendInfo, ok := plan.TaprootSpendInfo() + if !ok { + return nil, errors.New( + "taproot descriptor plan has no spend metadata", + ) + } + if spendInfo.Kind != descriptors.TaprootSpendScriptPath { + return nil, errors.New( + "taproot key-path descriptor sweeps are not " + + "supported", + ) + } + witnessScript = append([]byte(nil), spendInfo.LeafScript...) + leafHash = hex.EncodeToString(spendInfo.LeafHash[:]) + // Schnorr signatures have the fixed 64-byte size advertised to + // PlanAt, so the frozen plan's complete serialized witness is + // exact. + witnessSize = lntypes.WeightUnit(plan.WitnessSize()) + } else { + maxWeight, err := desc.MaxWeightToSatisfy() + if err != nil { + return nil, err + } + // MaxWeightToSatisfy is relative to an empty witness. lnd + // expects the complete serialized witness, including the + // element-count byte. + witnessSize = lntypes.WeightUnit(maxWeight + 1) } + return &descriptorWitnessType{ desc: desc, plan: plan, bindings: bindings, preimages: record.Preimages, - witnessScript: append([]byte(nil), record.WitnessScript...), - // MaxWeightToSatisfy is relative to an empty witness. lnd expects - // the complete serialized witness, including the element-count byte. - witnessSize: lntypes.WeightUnit(maxWeight + 1), + witnessScript: witnessScript, + witnessSize: witnessSize, + taproot: taproot, + leafHash: leafHash, }, nil } -func (w *descriptorWitnessType) String() string { return "descriptor-wsh" } +func (w *descriptorWitnessType) String() string { + if w.taproot { + return "descriptor-tr" + } + + return "descriptor-wsh" +} func (w *descriptorWitnessType) WitnessGenerator(signer input.Signer, desc *input.SignDescriptor) input.WitnessGenerator { @@ -1137,9 +1484,34 @@ func (w *descriptorWitnessType) craft(signer input.Signer, } serialized := signature.Serialize() serialized = append(serialized, byte(local.HashType)) + return serialized, true }, - LookupPreimage: func(hashFunc string, hash []byte) ([]byte, bool) { + LookupTapLeafScriptSig: func( + key, leafHash string, + ) ([]byte, bool) { + + if !w.taproot || leafHash != w.leafHash { + return nil, false + } + keyDesc, ok := w.bindings[key] + if !ok { + return nil, false + } + local := *signDesc + local.KeyDesc = keyDesc + local.InputIndex = index + signature, err := signer.SignOutputRaw(tx, &local) + if err != nil { + return nil, false + } + + return signature.Serialize(), true + }, + LookupPreimage: func( + hashFunc string, hash []byte, + ) ([]byte, bool) { + preimage, ok := w.preimages[preimageKey(hashFunc, hash)] return append([]byte(nil), preimage...), ok }, @@ -1150,7 +1522,12 @@ func (w *descriptorWitnessType) craft(signer input.Signer, return nil, err } witness := append(wire.TxWitness{}, result.Witness...) - witness = append(witness, append([]byte(nil), w.witnessScript...)) + if !w.taproot { + witness = append( + witness, + append([]byte(nil), w.witnessScript...), + ) + } return &input.Script{ Witness: witness, diff --git a/sweep/descriptorsweep/service_test.go b/sweep/descriptorsweep/service_test.go index f3752e72a7f..f6de86d38a5 100644 --- a/sweep/descriptorsweep/service_test.go +++ b/sweep/descriptorsweep/service_test.go @@ -2,8 +2,8 @@ package descriptorsweep import ( "bytes" - "context" "crypto/sha256" + "encoding/hex" "errors" "fmt" "sync" @@ -11,6 +11,7 @@ import ( "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" @@ -31,6 +32,33 @@ type testKeyRing struct { keys map[keychain.KeyLocator]*btcec.PublicKey } +type failOnceKeyRing struct { + mu sync.Mutex + delegate *testKeyRing + failures int +} + +func (k *failOnceKeyRing) DeriveNextKey(family keychain.KeyFamily) ( + keychain.KeyDescriptor, error) { + + return k.delegate.DeriveNextKey(family) +} + +func (k *failOnceKeyRing) DeriveKey(locator keychain.KeyLocator) ( + keychain.KeyDescriptor, error) { + + k.mu.Lock() + defer k.mu.Unlock() + if k.failures > 0 { + k.failures-- + return keychain.KeyDescriptor{}, errors.New( + "transient key ring failure", + ) + } + + return k.delegate.DeriveKey(locator) +} + func (k *testKeyRing) DeriveNextKey(keychain.KeyFamily) ( keychain.KeyDescriptor, error) { @@ -44,6 +72,7 @@ func (k *testKeyRing) DeriveKey(locator keychain.KeyLocator) ( if !ok { return keychain.KeyDescriptor{}, fmt.Errorf("key not found") } + return keychain.KeyDescriptor{KeyLocator: locator, PubKey: key}, nil } @@ -51,6 +80,19 @@ type testSweeper struct { inputs chan input.Input } +type captureSigner struct { + input.Signer + signDesc *input.SignDescriptor +} + +func (s *captureSigner) SignOutputRaw(tx *wire.MsgTx, + signDesc *input.SignDescriptor) (input.Signature, error) { + + cloned := *signDesc + s.signDesc = &cloned + return s.Signer.SignOutputRaw(tx, signDesc) +} + func (s *testSweeper) SweepInput(inp input.Input, _ sweep.Params) ( chan sweep.Result, error) { @@ -80,6 +122,7 @@ func (s *failOnceStore) put(record *storedRecord) error { s.failures-- return errors.New("transient store failure") } + return s.delegate.put(record) } @@ -130,6 +173,7 @@ func (s *testBlockSource) GetBlockHash(height int64) (*chainhash.Hash, error) { if !ok { return nil, fmt.Errorf("block %d not found", height) } + return &hash, nil } @@ -143,6 +187,7 @@ func (s *testBlockSource) GetBlock( if !ok { return nil, fmt.Errorf("block %v not found", hash) } + return block.Copy(), nil } @@ -179,6 +224,7 @@ func newReadyTestNotifier() *readyTestNotifier { blockEpochs: make(chan *chainntnfs.BlockEpoch, 1), } notifier.blockEpochs <- &chainntnfs.BlockEpoch{Height: 100} + return notifier } @@ -195,7 +241,9 @@ func (n *readyTestNotifier) RegisterConfirmationsNtfn(_ *chainhash.Hash, if n.confFailures > 0 { n.confFailures-- n.mu.Unlock() - return nil, errors.New("transient confirmation registration failure") + return nil, errors.New( + "transient confirmation registration failure", + ) } n.mu.Unlock() @@ -278,6 +326,7 @@ func testBackend(t *testing.T) kvdb.Backend { db, cleanup, err := kvdb.GetTestBackend(t.TempDir(), "descriptor.db") require.NoError(t, err) t.Cleanup(cleanup) + return db } @@ -335,13 +384,13 @@ func TestRegisterValidatesAndPersists(t *testing.T) { }) require.NoError(t, err) - record, err := service.Register(context.Background(), RegisterRequest{ + record, err := service.Register(t.Context(), RegisterRequest{ Descriptor: descriptor, KeyBindings: []KeyBinding{{ - DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[0]), KeyLocator: locA, }, { - DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[1]), KeyLocator: locB, }}, ExpectedValue: 50_000, @@ -367,7 +416,7 @@ func TestRegisterValidatesAndPersists(t *testing.T) { other, err := btcec.NewPrivateKey() require.NoError(t, err) bad := other.PubKey().SerializeCompressed() - _, err = service.Register(context.Background(), RegisterRequest{ + _, err = service.Register(t.Context(), RegisterRequest{ Descriptor: fmt.Sprintf("wsh(pk(%x))", bad), KeyBindings: []KeyBinding{{ DescriptorKey: fmt.Sprintf("%x", bad), KeyLocator: locA, @@ -379,6 +428,185 @@ func TestRegisterValidatesAndPersists(t *testing.T) { require.ErrorContains(t, err, "does not match locator") } +func TestRegisterTaprootWithUnboundInternalKey(t *testing.T) { + t.Parallel() + + internalKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + leafKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + internal := hex.EncodeToString( + schnorr.SerializePubKey(internalKey.PubKey()), + ) + leaf := hex.EncodeToString(schnorr.SerializePubKey(leafKey.PubKey())) + descriptor := fmt.Sprintf("tr(%s,pk(%s))", internal, leaf) + locator := keychain.KeyLocator{Family: 31, Index: 7} + service, err := New(Config{ + DB: testBackend(t), + Notifier: &chainntnfs.MockChainNotifier{}, + KeyRing: &testKeyRing{ + keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locator: leafKey.PubKey(), + }, + }, + Sweeper: &testSweeper{inputs: make(chan input.Input, 1)}, + BlockSource: newTestBlockSource(), + ChainParams: &chaincfg.RegressionNetParams, + Ready: make(chan struct{}), + }) + require.NoError(t, err) + _, err = service.Register(t.Context(), RegisterRequest{ + Descriptor: fmt.Sprintf("tr(%s)", internal), + ExpectedValue: 50_000, + HeightHint: 100, + Budget: 10_000, + }) + require.ErrorContains(t, err, "no path satisfiable") + + record, err := service.Register(t.Context(), RegisterRequest{ + Descriptor: descriptor, + KeyBindings: []KeyBinding{{ + DescriptorKey: leaf, + KeyLocator: locator, + }}, + ExpectedValue: 50_000, + HeightHint: 100, + Budget: 10_000, + }) + require.NoError(t, err) + require.True(t, txscript.IsPayToTaproot(record.PkScript)) + require.Empty(t, record.WitnessScript) + require.Len(t, record.KeyBindings, 1) + require.NotContains(t, record.KeyBindings, KeyBinding{ + DescriptorKey: internal, + }) + + const privateWIF = "L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2" + + "z8TrisoyY1" + + // Binding only the leaf must not let private or ranged material in an + // unbound internal-key position enter durable/listable state. + _, err = service.Register(t.Context(), RegisterRequest{ + Descriptor: fmt.Sprintf("tr(%s,pk(%s))", privateWIF, leaf), + KeyBindings: []KeyBinding{{ + DescriptorKey: leaf, + KeyLocator: locator, + }}, + ExpectedValue: 50_000, + HeightHint: 100, + Budget: 10_000, + }) + require.ErrorContains(t, err, "descriptor key") + + // Use a known-valid BIP32 xpub wildcard. It is valid descriptor syntax, + // but outside this service's raw, fixed-key contract. + const validXpub = "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1Lk" + + "BUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGR" + + "jaDMzQLcgJvLJuZZvRcEL" + _, err = service.Register(t.Context(), RegisterRequest{ + Descriptor: fmt.Sprintf("tr(%s/*,pk(%s))", validXpub, leaf), + KeyBindings: []KeyBinding{{ + DescriptorKey: leaf, + KeyLocator: locator, + }}, + ExpectedValue: 50_000, + HeightHint: 100, + Budget: 10_000, + }) + require.ErrorContains(t, err, "descriptor key") +} + +func TestTaprootCompressedKeyMatchesXOnlyLocator(t *testing.T) { + t.Parallel() + + internalKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + leafKey := oddYPrivateKey(t) + internal := hex.EncodeToString( + schnorr.SerializePubKey(internalKey.PubKey()), + ) + // Parsing the x-only point returns its even-Y lift. BIP386 converts + // compressed and x-only keys in tr() to this x-only identity, so the + // descriptor must still bind to the odd-Y locator key with the same x. + evenLift, err := schnorr.ParsePubKey( + schnorr.SerializePubKey(leafKey.PubKey()), + ) + require.NoError(t, err) + compressedLeaf := hex.EncodeToString(evenLift.SerializeCompressed()) + require.True(t, descriptorKeyMatches( + compressedLeaf, leafKey.PubKey(), true, + )) + require.False(t, descriptorKeyMatches( + compressedLeaf, leafKey.PubKey(), false, + )) + + locator := keychain.KeyLocator{Family: 34, Index: 10} + service, err := New(Config{ + DB: testBackend(t), + Notifier: &chainntnfs.MockChainNotifier{}, + KeyRing: &testKeyRing{ + keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locator: leafKey.PubKey(), + }, + }, + Sweeper: &testSweeper{inputs: make(chan input.Input, 1)}, + BlockSource: newTestBlockSource(), + ChainParams: &chaincfg.RegressionNetParams, + Ready: make(chan struct{}), + }) + require.NoError(t, err) + _, err = service.Register(t.Context(), RegisterRequest{ + Descriptor: fmt.Sprintf( + "tr(%s,pk(%s))", internal, compressedLeaf, + ), + KeyBindings: []KeyBinding{{ + DescriptorKey: compressedLeaf, + KeyLocator: locator, + }}, + ExpectedValue: 50_000, + HeightHint: 100, + Budget: 10_000, + }) + require.NoError(t, err) +} + +func TestRegisterRejectsUnsupportedPreimageOnlyPath(t *testing.T) { + t.Parallel() + + key, err := btcec.NewPrivateKey() + require.NoError(t, err) + keyString := hex.EncodeToString(key.PubKey().SerializeCompressed()) + locator := keychain.KeyLocator{Family: 35, Index: 11} + service, err := New(Config{ + DB: testBackend(t), + Notifier: &chainntnfs.MockChainNotifier{}, + KeyRing: &testKeyRing{ + keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locator: key.PubKey(), + }, + }, + Sweeper: &testSweeper{inputs: make(chan input.Input, 1)}, + BlockSource: newTestBlockSource(), + ChainParams: &chaincfg.RegressionNetParams, + Ready: make(chan struct{}), + }) + require.NoError(t, err) + digest := bytes.Repeat([]byte{1}, 20) + _, err = service.Register(t.Context(), RegisterRequest{ + Descriptor: fmt.Sprintf( + "wsh(and_v(v:pk(%s),hash160(%x)))", keyString, digest, + ), + KeyBindings: []KeyBinding{{ + DescriptorKey: keyString, + KeyLocator: locator, + }}, + ExpectedValue: 50_000, + HeightHint: 100, + Budget: 10_000, + }) + require.ErrorContains(t, err, "no path satisfiable") +} + func TestRegisterRejectsZeroBudget(t *testing.T) { t.Parallel() @@ -388,9 +616,12 @@ func TestRegisterRejectsZeroBudget(t *testing.T) { service, err := New(Config{ DB: testBackend(t), Notifier: &chainntnfs.MockChainNotifier{}, - KeyRing: &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ - locatorA: keys[0].PubKey(), locatorB: keys[1].PubKey(), - }}, + KeyRing: &testKeyRing{ + keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locatorA: keys[0].PubKey(), + locatorB: keys[1].PubKey(), + }, + }, Sweeper: &testSweeper{inputs: make(chan input.Input, 1)}, BlockSource: newTestBlockSource(), ChainParams: &chaincfg.RegressionNetParams, @@ -398,13 +629,13 @@ func TestRegisterRejectsZeroBudget(t *testing.T) { }) require.NoError(t, err) - _, err = service.Register(context.Background(), RegisterRequest{ + _, err = service.Register(t.Context(), RegisterRequest{ Descriptor: descriptor, KeyBindings: []KeyBinding{{ - DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[0]), KeyLocator: locatorA, }, { - DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[1]), KeyLocator: locatorB, }}, ExpectedValue: 50_000, @@ -426,22 +657,22 @@ func TestRegisterValueAndLabelValidation(t *testing.T) { req := base req.ExpectedValue = 0 - _, err := service.Register(context.Background(), req) + _, err := service.Register(t.Context(), req) require.ErrorContains(t, err, "expected output value must be positive") req = base req.ExpectedValue = btcutil.MaxSatoshi + 1 - _, err = service.Register(context.Background(), req) + _, err = service.Register(t.Context(), req) require.ErrorContains(t, err, "maximum money") req = base req.Budget = req.ExpectedValue + 1 - _, err = service.Register(context.Background(), req) + _, err = service.Register(t.Context(), req) require.ErrorContains(t, err, "budget must not exceed expected") req = base req.Label = string(bytes.Repeat([]byte{'a'}, 501)) - _, err = service.Register(context.Background(), req) + _, err = service.Register(t.Context(), req) require.ErrorContains(t, err, "label must not exceed 500 bytes") idA := registrationID("wsh(pk(02))", nil, 0, 10_000) @@ -454,7 +685,7 @@ func TestRegisterRejectsTooManyConfirmations(t *testing.T) { t.Parallel() service := &Service{} - _, err := service.Register(context.Background(), RegisterRequest{ + _, err := service.Register(t.Context(), RegisterRequest{ Descriptor: "wsh(pk(02))", ExpectedValue: 1, HeightHint: 1, @@ -475,9 +706,12 @@ func TestRegisterBeforeNotifierReadiness(t *testing.T) { service, err := New(Config{ DB: testBackend(t), Notifier: notifier, - KeyRing: &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ - locA: keys[0].PubKey(), locB: keys[1].PubKey(), - }}, + KeyRing: &testKeyRing{ + keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locA: keys[0].PubKey(), + locB: keys[1].PubKey(), + }, + }, Sweeper: &testSweeper{inputs: make(chan input.Input, 1)}, BlockSource: newTestBlockSource(), ChainParams: &chaincfg.RegressionNetParams, @@ -487,13 +721,13 @@ func TestRegisterBeforeNotifierReadiness(t *testing.T) { require.NoError(t, service.Start()) t.Cleanup(func() { require.NoError(t, service.Stop()) }) - record, err := service.Register(context.Background(), RegisterRequest{ + record, err := service.Register(t.Context(), RegisterRequest{ Descriptor: descriptor, KeyBindings: []KeyBinding{{ - DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[0]), KeyLocator: locA, }, { - DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[1]), KeyLocator: locB, }}, ExpectedValue: 50_000, @@ -540,9 +774,12 @@ func TestTransientWatchFailureRetries(t *testing.T) { service, err := New(Config{ DB: testBackend(t), Notifier: notifier, - KeyRing: &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ - locA: keys[0].PubKey(), locB: keys[1].PubKey(), - }}, + KeyRing: &testKeyRing{ + keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locA: keys[0].PubKey(), + locB: keys[1].PubKey(), + }, + }, Sweeper: &testSweeper{inputs: make(chan input.Input, 1)}, BlockSource: newTestBlockSource(), ChainParams: &chaincfg.RegressionNetParams, @@ -551,13 +788,13 @@ func TestTransientWatchFailureRetries(t *testing.T) { require.NoError(t, err) service.retryInitial = time.Millisecond service.retryMax = 5 * time.Millisecond - record, err := service.Register(context.Background(), RegisterRequest{ + record, err := service.Register(t.Context(), RegisterRequest{ Descriptor: descriptor, KeyBindings: []KeyBinding{{ - DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[0]), KeyLocator: locA, }, { - DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[1]), KeyLocator: locB, }}, ExpectedValue: 50_000, @@ -596,10 +833,14 @@ func TestReadinessDrainDoesNotDoubleWatch(t *testing.T) { service, err := New(Config{ DB: testBackend(t), Notifier: notifier, - KeyRing: &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ - locA1: keysA[0].PubKey(), locA2: keysA[1].PubKey(), - locB1: keysB[0].PubKey(), locB2: keysB[1].PubKey(), - }}, + KeyRing: &testKeyRing{ + keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locA1: keysA[0].PubKey(), + locA2: keysA[1].PubKey(), + locB1: keysB[0].PubKey(), + locB2: keysB[1].PubKey(), + }, + }, Sweeper: &testSweeper{inputs: make(chan input.Input, 1)}, BlockSource: newTestBlockSource(), ChainParams: &chaincfg.RegressionNetParams, @@ -611,14 +852,14 @@ func TestReadinessDrainDoesNotDoubleWatch(t *testing.T) { second keychain.KeyLocator) []KeyBinding { return []KeyBinding{{ - DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[0]), KeyLocator: first, }, { - DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[1]), KeyLocator: second, }} } - recordA, err := service.Register(context.Background(), RegisterRequest{ + recordA, err := service.Register(t.Context(), RegisterRequest{ Descriptor: descriptorA, KeyBindings: binding(keysA, locA1, locA2), ExpectedValue: 50_000, @@ -639,7 +880,7 @@ func TestReadinessDrainDoesNotDoubleWatch(t *testing.T) { registered := make(chan registerResult, 1) go func() { record, err := service.Register( - context.Background(), RegisterRequest{ + t.Context(), RegisterRequest{ Descriptor: descriptorB, KeyBindings: binding(keysB, locB1, locB2), ExpectedValue: 50_000, @@ -682,22 +923,25 @@ func TestAddPreimageWaitsForReadiness(t *testing.T) { service, err := New(Config{ DB: testBackend(t), Notifier: notifier, - KeyRing: &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ - locA: keys[0].PubKey(), locB: keys[1].PubKey(), - }}, + KeyRing: &testKeyRing{ + keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locA: keys[0].PubKey(), + locB: keys[1].PubKey(), + }, + }, Sweeper: sweeper, BlockSource: newTestBlockSource(), ChainParams: &chaincfg.RegressionNetParams, Ready: ready, }) require.NoError(t, err) - record, err := service.Register(context.Background(), RegisterRequest{ + record, err := service.Register(t.Context(), RegisterRequest{ Descriptor: descriptor, KeyBindings: []KeyBinding{{ - DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[0]), KeyLocator: locA, }, { - DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[1]), KeyLocator: locB, }}, ExpectedValue: 50_000, @@ -718,7 +962,7 @@ func TestAddPreimageWaitsForReadiness(t *testing.T) { require.NoError(t, service.Start()) t.Cleanup(func() { require.NoError(t, service.Stop()) }) updated, err := service.AddPreimage( - context.Background(), record.ID, preimage, + t.Context(), record.ID, preimage, ) require.NoError(t, err) require.Equal(t, StatusWaiting, updated.Status) @@ -760,7 +1004,7 @@ func TestAddPreimageRejectsFailedRegistration(t *testing.T) { quit: make(chan struct{}), } - _, err = service.AddPreimage(context.Background(), id, preimage) + _, err = service.AddPreimage(t.Context(), id, preimage) require.ErrorContains(t, err, "already frozen") require.Empty(t, record.Preimages) require.NoError(t, service.trySweep(id)) @@ -779,9 +1023,12 @@ func TestWrongValueRewatchesFromNextBlock(t *testing.T) { service, err := New(Config{ DB: testBackend(t), Notifier: notifier, - KeyRing: &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ - locA: keys[0].PubKey(), locB: keys[1].PubKey(), - }}, + KeyRing: &testKeyRing{ + keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locA: keys[0].PubKey(), + locB: keys[1].PubKey(), + }, + }, Sweeper: &testSweeper{inputs: make(chan input.Input, 1)}, BlockSource: blockSource, ChainParams: &chaincfg.RegressionNetParams, @@ -790,13 +1037,13 @@ func TestWrongValueRewatchesFromNextBlock(t *testing.T) { require.NoError(t, err) service.retryInitial = time.Millisecond service.retryMax = 5 * time.Millisecond - record, err := service.Register(context.Background(), RegisterRequest{ + record, err := service.Register(t.Context(), RegisterRequest{ Descriptor: descriptor, KeyBindings: []KeyBinding{{ - DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[0]), KeyLocator: locA, }, { - DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[1]), KeyLocator: locB, }}, ExpectedValue: 50_000, @@ -820,8 +1067,9 @@ func TestWrongValueRewatchesFromNextBlock(t *testing.T) { Value: int64(record.ExpectedValue), PkScript: record.PkScript, }) firstEvent.Confirmed <- &chainntnfs.TxConfirmation{ - // The notification points at the wrong-value transaction. Full-block - // scanning must still find the valid transaction in the same block. + // The notification points at the wrong-value transaction. + // Full-block scanning must still find the valid transaction in + // the same block. Tx: wrongValueTx, BlockHeight: 120, Block: &wire.MsgBlock{Transactions: []*wire.MsgTx{ @@ -842,17 +1090,28 @@ func TestWrongValueRewatchesFromNextBlock(t *testing.T) { service.mu.Lock() service.records[secondID] = &storedRecord{ Record: Record{ - ID: secondID, CanonicalDescriptor: record.CanonicalDescriptor, - KeyBindings: append([]KeyBinding(nil), record.KeyBindings...), - PkScript: append([]byte(nil), record.PkScript...), - WitnessScript: append([]byte(nil), record.WitnessScript...), - ExpectedValue: 25_000, HeightHint: 90, MinConfs: 1, - Budget: 1_000, Status: StatusWatching, + ID: secondID, + CanonicalDescriptor: record.CanonicalDescriptor, + KeyBindings: append( + []KeyBinding(nil), record.KeyBindings..., + ), + PkScript: append( + []byte(nil), record.PkScript..., + ), + WitnessScript: append( + []byte(nil), record.WitnessScript..., + ), + ExpectedValue: 25_000, + HeightHint: 90, + MinConfs: 1, + Budget: 1_000, + Status: StatusWatching, }, WatchHeight: 90, Preimages: make(map[string][]byte), } - require.NoError(t, newStore(service.cfg.DB).put(service.records[secondID])) + err = newStore(service.cfg.DB).put(service.records[secondID]) + require.NoError(t, err) service.bestHeight = 132 service.mu.Unlock() require.NoError(t, service.watchOutput(secondID)) @@ -869,7 +1128,9 @@ func TestWrongValueRewatchesFromNextBlock(t *testing.T) { scanTx.AddTxOut(&wire.TxOut{ Value: 25_000, PkScript: record.PkScript, }) - blockSource.add(132, &wire.MsgBlock{Transactions: []*wire.MsgTx{scanTx}}) + blockSource.add(132, &wire.MsgBlock{ + Transactions: []*wire.MsgTx{scanTx}, + }) wrongEvent.Confirmed <- &chainntnfs.TxConfirmation{ Tx: wrongOnlyTx, BlockHeight: 130, Block: &wire.MsgBlock{Transactions: []*wire.MsgTx{wrongOnlyTx}}, @@ -943,7 +1204,9 @@ func TestAddPreimageRejectsUnrelatedData(t *testing.T) { require.NoError(t, err) record := &storedRecord{ Record: Record{ - ID: registrationID(desc.String(), nil, 0, 50_000), + ID: registrationID( + desc.String(), nil, 0, 50_000, + ), CanonicalDescriptor: desc.String(), Status: StatusWaiting, }, @@ -956,7 +1219,7 @@ func TestAddPreimageRejectsUnrelatedData(t *testing.T) { } _, err = service.AddPreimage( - context.Background(), record.ID, bytes.Repeat([]byte{0x99}, 32), + t.Context(), record.ID, bytes.Repeat([]byte{0x99}, 32), ) require.ErrorContains(t, err, "does not match") require.Empty(t, record.Preimages) @@ -971,7 +1234,8 @@ func TestStoreRejectsUnknownVersion(t *testing.T) { id := RegistrationID{1} err := kvdb.Update(db, func(tx kvdb.RwTx) error { bucket := tx.ReadWriteBucket(descriptorSweepBucket) - return bucket.Put(id[:], []byte{descriptorSweepStoreVersion + 1}) + version := []byte{descriptorSweepStoreVersion + 1} + return bucket.Put(id[:], version) }, func() {}) require.NoError(t, err) @@ -1042,18 +1306,18 @@ func TestRestoreSweepKeepsFrozenBranch(t *testing.T) { ID: RegistrationID{3}, CanonicalDescriptor: desc.String(), KeyBindings: []KeyBinding{{ - DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[0]), KeyLocator: locA, }, { - DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[1]), KeyLocator: locB, }}, PkScript: pkScript, WitnessScript: witnessScript, OutPoint: &outpoint, Value: 50_000, Status: StatusSweeping, Budget: 10_000, }, - // A success preimage arriving in storage must not switch an already - // frozen timeout plan during restart. + // A success preimage arriving in storage must not switch an + // already frozen timeout plan during restart. Preimages: map[string][]byte{ preimageKey("sha256", hash[:]): preimage, }, @@ -1064,7 +1328,15 @@ func TestRestoreSweepKeepsFrozenBranch(t *testing.T) { record.DeadlineDelta = 99 sweeper := &restartSweeper{} service := &Service{ - cfg: Config{Sweeper: sweeper}, + cfg: Config{ + Sweeper: sweeper, + KeyRing: &testKeyRing{ + keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locA: keys[0].PubKey(), + locB: keys[1].PubKey(), + }, + }, + }, records: map[RegistrationID]*storedRecord{record.ID: record}, bestHeight: 800, quit: make(chan struct{}), @@ -1072,14 +1344,70 @@ func TestRestoreSweepKeepsFrozenBranch(t *testing.T) { require.NoError(t, service.restoreSweep(record.ID)) require.NotNil(t, sweeper.input) + descriptorInput, ok := sweeper.input.(*descriptorInput) + require.True(t, ok) require.Equal(t, locktime, - valueOrZero(sweeper.input.(*descriptorInput).locktime)) + valueOrZero(descriptorInput.locktime)) require.Equal(t, deadline, sweeper.params.DeadlineHeight.UnwrapOr(0)) - _, err = service.AddPreimage(context.Background(), record.ID, preimage) + _, err = service.AddPreimage(t.Context(), record.ID, preimage) require.ErrorContains(t, err, "already frozen") } +func TestRestoreSweepRetriesTransientKeyRingFailure(t *testing.T) { + t.Parallel() + + key, err := btcec.NewPrivateKey() + require.NoError(t, err) + keyString := hex.EncodeToString(key.PubKey().SerializeCompressed()) + desc, err := descriptors.NewDescriptor("wsh(pk(" + keyString + "))") + require.NoError(t, err) + _, pkScript, witnessScript, err := descriptorScripts( + desc, &chaincfg.RegressionNetParams, 0, + ) + require.NoError(t, err) + locator := keychain.KeyLocator{Family: 36, Index: 12} + outpoint := wire.OutPoint{Index: 3} + record := &storedRecord{ + Record: Record{ + ID: RegistrationID{36}, + CanonicalDescriptor: desc.String(), + KeyBindings: []KeyBinding{{ + DescriptorKey: keyString, + KeyLocator: locator, + }}, + PkScript: pkScript, + WitnessScript: witnessScript, + OutPoint: &outpoint, + Value: 50_000, + Budget: 10_000, + Status: StatusSweeping, + }, + Preimages: map[string][]byte{}, + } + keyRing := &failOnceKeyRing{ + delegate: &testKeyRing{ + keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locator: key.PubKey(), + }, + }, + failures: 1, + } + sweeper := &restartSweeper{} + service := &Service{ + cfg: Config{Sweeper: sweeper, KeyRing: keyRing}, + records: map[RegistrationID]*storedRecord{record.ID: record}, + quit: make(chan struct{}), + } + + err = service.restoreSweep(record.ID) + require.ErrorContains(t, err, "transient key ring failure") + require.True(t, isRetryable(err)) + require.Nil(t, sweeper.input) + require.NoError(t, service.restoreSweep(record.ID)) + require.NotNil(t, sweeper.input) +} + func TestFrozenPlanUsesOnlySelectedBranch(t *testing.T) { t.Parallel() @@ -1094,10 +1422,10 @@ func TestFrozenPlanUsesOnlySelectedBranch(t *testing.T) { Record: Record{ CanonicalDescriptor: desc.String(), KeyBindings: []KeyBinding{{ - DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[0]), KeyLocator: locA, }, { - DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[1]), KeyLocator: locB, }}, WitnessScript: witnessScript, @@ -1163,7 +1491,10 @@ func TestDescriptorInputUsesSelectedCSV(t *testing.T) { ConfirmationHeight: 100, }, PlanSequence: constraints.RelativeLocktime} - inp, err := newDescriptorInput(desc, plan, record) + keyRing := &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ + record.KeyBindings[0].KeyLocator: key.PubKey(), + }} + inp, err := newDescriptorInput(keyRing, desc, plan, record) require.NoError(t, err) require.Equal(t, sequence, inp.BlocksToMaturity()) require.Equal(t, uint32(100), inp.HeightHint()) @@ -1179,9 +1510,9 @@ func TestImmediatePlanWinsOverMatureTimeout(t *testing.T) { require.NoError(t, err) record := &storedRecord{ Record: Record{KeyBindings: []KeyBinding{{ - DescriptorKey: fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[0]), }, { - DescriptorKey: fmt.Sprintf("%x", keys[1].PubKey().SerializeCompressed()), + DescriptorKey: compressedKeyString(keys[1]), }}}, Preimages: make(map[string][]byte), } @@ -1209,7 +1540,7 @@ func TestDescriptorWitness(t *testing.T) { witnessScript, err := desc.ScriptCodeAt(0, 0) require.NoError(t, err) hash := sha256.Sum256(preimage) - keyA := fmt.Sprintf("%x", keys[0].PubKey().SerializeCompressed()) + keyA := compressedKeyString(keys[0]) assets := descriptors.Assets{ LookupEcdsaSig: func(key string) bool { return key == keyA }, LookupPreimage: func(string, []byte) bool { return true }, @@ -1219,15 +1550,25 @@ func TestDescriptorWitness(t *testing.T) { record := &storedRecord{ Record: Record{ - KeyBindings: []KeyBinding{{DescriptorKey: keyA, - KeyLocator: keychain.KeyLocator{Family: 3, Index: 4}}}, + KeyBindings: []KeyBinding{{ + DescriptorKey: keyA, + KeyLocator: keychain.KeyLocator{ + Family: 3, + Index: 4, + }, + }}, WitnessScript: witnessScript, }, Preimages: map[string][]byte{ preimageKey("sha256", hash[:]): preimage, }, } - witnessType, err := newDescriptorWitnessType(desc, plan, record) + keyRing := &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ + record.KeyBindings[0].KeyLocator: keys[0].PubKey(), + }} + witnessType, err := newDescriptorWitnessType( + keyRing, desc, plan, record, + ) require.NoError(t, err) tx := wire.NewMsgTx(2) @@ -1259,7 +1600,353 @@ func TestDescriptorWitness(t *testing.T) { var actual int actual += wire.VarIntSerializeSize(uint64(len(script.Witness))) for _, element := range script.Witness { - actual += wire.VarIntSerializeSize(uint64(len(element))) + len(element) + actual += wire.VarIntSerializeSize( + uint64(len(element)), + ) + len(element) } require.LessOrEqual(t, actual, int(bound)) } + +func TestTaprootDescriptorWitnessPaths(t *testing.T) { + t.Parallel() + + internalKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + successKey := evenYPrivateKey(t) + timeoutKey := evenYPrivateKey(t) + preimage := bytes.Repeat([]byte{0x51}, 32) + hash := sha256.Sum256(preimage) + const locktime = uint32(500) + + internal := hex.EncodeToString( + schnorr.SerializePubKey(internalKey.PubKey()), + ) + success := hex.EncodeToString( + schnorr.SerializePubKey(successKey.PubKey()), + ) + timeout := hex.EncodeToString( + schnorr.SerializePubKey(timeoutKey.PubKey()), + ) + desc, err := descriptors.NewDescriptor(fmt.Sprintf( + "tr(%s,{and_v(v:pk(%s),sha256(%x)),"+ + "and_v(v:pk(%s),after(%d))})", + internal, success, hash, timeout, locktime, + )) + require.NoError(t, err) + addressString, pkScript, witnessScript, err := descriptorScripts( + desc, &chaincfg.RegressionNetParams, 0, + ) + require.NoError(t, err) + require.NotEmpty(t, addressString) + require.True(t, txscript.IsPayToTaproot(pkScript)) + require.Empty(t, witnessScript) + + tests := []struct { + name string + key *btcec.PrivateKey + keyString string + preimages map[string][]byte + assetsHeight uint32 + locktime uint32 + }{ + { + name: "preimage", + key: successKey, + keyString: success, + preimages: map[string][]byte{ + preimageKey("sha256", hash[:]): preimage, + }, + }, + { + name: "cltv", + key: timeoutKey, + keyString: timeout, + preimages: map[string][]byte{}, + assetsHeight: locktime, + locktime: locktime, + }, + } + + for testIndex, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + outpoint := wire.OutPoint{Index: uint32(testIndex)} + record := &storedRecord{ + Record: Record{ + CanonicalDescriptor: desc.String(), + KeyBindings: []KeyBinding{{ + DescriptorKey: test.keyString, + KeyLocator: keychain.KeyLocator{ + Family: 21, + Index: uint32( + testIndex + 1, + ), + }, + }}, + PkScript: pkScript, + OutPoint: &outpoint, + Value: 50_000, + ConfirmationHeight: 100, + }, + Preimages: test.preimages, + } + assets := makeAssets(record, test.assetsHeight) + plan, err := desc.PlanAt(0, 0, assets) + require.NoError(t, err) + constraints := plan.TxConstraints() + record.PlanLocktime = cloneUint32( + constraints.AbsoluteLocktime, + ) + record.PlanSequence = cloneUint32( + constraints.RelativeLocktime, + ) + + locator := record.KeyBindings[0].KeyLocator + keyRing := &testKeyRing{ + keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locator: test.key.PubKey(), + }, + } + inp, err := newDescriptorInput( + keyRing, desc, plan, record, + ) + require.NoError(t, err) + require.Equal( + t, input.TaprootScriptSpendSignMethod, + inp.SignDesc().SignMethod, + ) + require.Equal(t, txscript.SigHashDefault, + inp.SignDesc().HashType) + require.True(t, IsTaprootWitnessType(inp.WitnessType())) + + tx := wire.NewMsgTx(2) + tx.LockTime = test.locktime + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: outpoint, + Sequence: valueOrZero( + record.PlanSequence, + ), + }) + tx.AddTxOut(&wire.TxOut{ + Value: 40_000, + PkScript: []byte{txscript.OP_TRUE}, + }) + fetcher := txscript.NewCannedPrevOutputFetcher( + pkScript, record.Value, + ) + hashes := txscript.NewTxSigHashes(tx, fetcher) + script, err := inp.CraftInputScript( + newTestSigner( + []*btcec.PrivateKey{test.key}, + ), + tx, + hashes, fetcher, 0, + ) + require.NoError(t, err) + require.Empty(t, script.SigScript) + require.GreaterOrEqual(t, len(script.Witness), 3) + + spendInfo, ok := plan.TaprootSpendInfo() + require.True(t, ok) + require.Equal(t, descriptors.TaprootSpendScriptPath, + spendInfo.Kind) + require.Equal(t, spendInfo.LeafScript, + script.Witness[len(script.Witness)-2]) + require.Equal(t, spendInfo.ControlBlock, + script.Witness[len(script.Witness)-1]) + + tx.TxIn[0].Witness = script.Witness + engine, err := txscript.NewEngine( + pkScript, tx, 0, txscript.StandardVerifyFlags, + nil, hashes, record.Value, fetcher, + ) + require.NoError(t, err) + require.NoError(t, engine.Execute()) + + bound, nested, err := inp.WitnessType().SizeUpperBound() + require.NoError(t, err) + require.False(t, nested) + var actual int + actual += wire.VarIntSerializeSize( + uint64(len(script.Witness)), + ) + for _, element := range script.Witness { + actual += wire.VarIntSerializeSize( + uint64(len(element)), + ) + len(element) + } + require.LessOrEqual(t, actual, int(bound)) + }) + } +} + +func TestTaprootDescriptorPreservesOddYBinding(t *testing.T) { + t.Parallel() + + internalKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + leafKey := oddYPrivateKey(t) + internal := hex.EncodeToString( + schnorr.SerializePubKey(internalKey.PubKey()), + ) + leaf := hex.EncodeToString(schnorr.SerializePubKey(leafKey.PubKey())) + desc, err := descriptors.NewDescriptor(fmt.Sprintf( + "tr(%s,pk(%s))", internal, leaf, + )) + require.NoError(t, err) + _, pkScript, _, err := descriptorScripts( + desc, &chaincfg.RegressionNetParams, 0, + ) + require.NoError(t, err) + + locator := keychain.KeyLocator{Family: 32, Index: 8} + keyRing := &testKeyRing{keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locator: leafKey.PubKey(), + }} + outpoint := wire.OutPoint{Index: 1} + record := &storedRecord{ + Record: Record{ + KeyBindings: []KeyBinding{{ + DescriptorKey: leaf, + KeyLocator: locator, + }}, + PkScript: pkScript, + OutPoint: &outpoint, + Value: 50_000, + }, + Preimages: map[string][]byte{}, + } + plan, err := desc.PlanAt(0, 0, makeAssets(record, 0)) + require.NoError(t, err) + inp, err := newDescriptorInput(keyRing, desc, plan, record) + require.NoError(t, err) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: outpoint}) + tx.AddTxOut(&wire.TxOut{ + Value: 40_000, PkScript: []byte{txscript.OP_TRUE}, + }) + fetcher := txscript.NewCannedPrevOutputFetcher(pkScript, record.Value) + hashes := txscript.NewTxSigHashes(tx, fetcher) + signer := &captureSigner{Signer: newTestSigner( + []*btcec.PrivateKey{leafKey}, + )} + _, err = inp.CraftInputScript(signer, tx, hashes, fetcher, 0) + require.NoError(t, err) + require.NotNil(t, signer.signDesc) + require.Equal(t, input.PubKeyFormatCompressedOdd, + signer.signDesc.KeyDesc.PubKey.SerializeCompressed()[0]) + require.Equal(t, leafKey.PubKey().SerializeCompressed(), + signer.signDesc.KeyDesc.PubKey.SerializeCompressed()) +} + +func TestRestoreTaprootSweepVerifiesFrozenLeaf(t *testing.T) { + t.Parallel() + + internalKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + leafA := evenYPrivateKey(t) + leafB := evenYPrivateKey(t) + internal := hex.EncodeToString( + schnorr.SerializePubKey(internalKey.PubKey()), + ) + keyA := hex.EncodeToString(schnorr.SerializePubKey(leafA.PubKey())) + keyB := hex.EncodeToString(schnorr.SerializePubKey(leafB.PubKey())) + desc, err := descriptors.NewDescriptor(fmt.Sprintf( + "tr(%s,{pk(%s),pk(%s)})", internal, keyA, keyB, + )) + require.NoError(t, err) + _, pkScript, _, err := descriptorScripts( + desc, &chaincfg.RegressionNetParams, 0, + ) + require.NoError(t, err) + locator := keychain.KeyLocator{Family: 33, Index: 9} + outpoint := wire.OutPoint{Index: 2} + record := &storedRecord{ + Record: Record{ + ID: RegistrationID{33}, + CanonicalDescriptor: desc.String(), + KeyBindings: []KeyBinding{{ + DescriptorKey: keyA, + KeyLocator: locator, + }}, + PkScript: pkScript, + OutPoint: &outpoint, + Value: 50_000, + Budget: 10_000, + Status: StatusSweeping, + }, + Preimages: map[string][]byte{}, + } + plan, err := desc.PlanAt(0, 0, makeFrozenAssets(record)) + require.NoError(t, err) + spendInfo, ok := plan.TaprootSpendInfo() + require.True(t, ok) + record.PlanTapLeafHash = cloneHash(&spendInfo.LeafHash) + record.PlanTapControlBlock = append( + []byte(nil), spendInfo.ControlBlock..., + ) + + storage := newStore(testBackend(t)) + require.NoError(t, storage.init()) + require.NoError(t, storage.put(record)) + loaded, err := storage.list() + require.NoError(t, err) + require.Len(t, loaded, 1) + require.Equal(t, spendInfo.LeafHash, *loaded[0].PlanTapLeafHash) + require.Equal(t, spendInfo.ControlBlock, + loaded[0].PlanTapControlBlock) + + sweeper := &restartSweeper{} + service := &Service{ + cfg: Config{ + Sweeper: sweeper, + KeyRing: &testKeyRing{ + keys: map[keychain.KeyLocator]*btcec.PublicKey{ + locator: leafA.PubKey(), + }, + }, + }, + records: map[RegistrationID]*storedRecord{record.ID: record}, + quit: make(chan struct{}), + } + require.NoError(t, service.restoreSweep(record.ID)) + require.NotNil(t, sweeper.input) + + record.PlanTapControlBlock[0] ^= 1 + err = service.restoreSweep(record.ID) + require.ErrorContains(t, err, "changed frozen leaf") +} + +func compressedKeyString(key *btcec.PrivateKey) string { + return hex.EncodeToString(key.PubKey().SerializeCompressed()) +} + +func evenYPrivateKey(t *testing.T) *btcec.PrivateKey { + t.Helper() + + for { + key, err := btcec.NewPrivateKey() + require.NoError(t, err) + if key.PubKey().SerializeCompressed()[0] != + input.PubKeyFormatCompressedOdd { + + return key + } + } +} + +func oddYPrivateKey(t *testing.T) *btcec.PrivateKey { + t.Helper() + + for { + key, err := btcec.NewPrivateKey() + require.NoError(t, err) + if key.PubKey().SerializeCompressed()[0] == + input.PubKeyFormatCompressedOdd { + + return key + } + } +} diff --git a/sweep/descriptorsweep/store.go b/sweep/descriptorsweep/store.go index 5031c8732c9..a6b1019e07d 100644 --- a/sweep/descriptorsweep/store.go +++ b/sweep/descriptorsweep/store.go @@ -3,6 +3,7 @@ package descriptorsweep import ( "bytes" "encoding/gob" + "errors" "fmt" "github.com/lightningnetwork/lnd/kvdb" @@ -29,9 +30,10 @@ func newStore(db kvdb.Backend) *store { func (s *store) init() error { return kvdb.Update(s.db, func(tx kvdb.RwTx) error { _, err := tx.CreateTopLevelBucket(descriptorSweepBucket) - if err == kvdb.ErrBucketExists { + if errors.Is(err, kvdb.ErrBucketExists) { return nil } + return err }, func() {}) } @@ -48,6 +50,7 @@ func (s *store) put(record *storedRecord) error { if bucket == nil { return kvdb.ErrBucketNotFound } + return bucket.Put(record.ID[:], encoded) }, func() {}) } @@ -61,19 +64,27 @@ func (s *store) list() ([]*storedRecord, error) { } return bucket.ForEach(func(_, value []byte) error { - if len(value) == 0 || value[0] != descriptorSweepStoreVersion { - return fmt.Errorf("unknown descriptor sweep store version") + if len(value) == 0 || + value[0] != descriptorSweepStoreVersion { + + return errors.New( + "unknown descriptor sweep " + + "store version", + ) } + var record storedRecord - if err := gob.NewDecoder(bytes.NewReader(value[1:])).Decode( - &record, - ); err != nil { - return fmt.Errorf("decode descriptor sweep: %w", err) + decoder := gob.NewDecoder(bytes.NewReader(value[1:])) + if err := decoder.Decode(&record); err != nil { + return fmt.Errorf( + "decode descriptor sweep: %w", err, + ) } if record.Preimages == nil { record.Preimages = make(map[string][]byte) } records = append(records, &record) + return nil }) }, func() { diff --git a/sweep/descriptorsweep/types.go b/sweep/descriptorsweep/types.go index 9e9c1960a4a..9b97958b7d8 100644 --- a/sweep/descriptorsweep/types.go +++ b/sweep/descriptorsweep/types.go @@ -55,7 +55,10 @@ func ParseRegistrationID(id string) (RegistrationID, error) { return result, err } if len(raw) != len(result) { - return result, fmt.Errorf("registration id must be %d bytes", len(result)) + return result, fmt.Errorf( + "registration id must be %d bytes", + len(result), + ) } copy(result[:], raw) @@ -66,9 +69,13 @@ func ParseRegistrationID(id string) (RegistrationID, error) { func RegistrationIDFromBytes(id []byte) (RegistrationID, error) { var result RegistrationID if len(id) != len(result) { - return result, fmt.Errorf("registration id must be %d bytes", len(result)) + return result, fmt.Errorf( + "registration id must be %d bytes", + len(result), + ) } copy(result[:], id) + return result, nil } @@ -121,9 +128,10 @@ type KeyBinding struct { KeyLocator keychain.KeyLocator } -// RegisterRequest describes one fixed-index native P2WSH output to watch and -// sweep. Ranged and multipath descriptors are deliberately excluded from the -// first version of the service. +// RegisterRequest describes one fixed-index native P2WSH or P2TR output to +// watch and sweep. P2TR registrations use script paths only. Ranged and +// multipath descriptors are deliberately excluded from the first version of +// the service. type RegisterRequest struct { Descriptor string DerivationIndex uint32 @@ -165,14 +173,16 @@ type Record struct { type storedRecord struct { Record - WatchHeight uint32 - BlockScan bool - Preimages map[string][]byte - PlanLocktime *uint32 - PlanSequence *uint32 - PlanDeadlineHeight *int32 - HasStartingFeeRate bool - StartingFeeRate chainfee.SatPerKWeight + WatchHeight uint32 + BlockScan bool + Preimages map[string][]byte + PlanLocktime *uint32 + PlanSequence *uint32 + PlanDeadlineHeight *int32 + PlanTapLeafHash *chainhash.Hash + PlanTapControlBlock []byte + HasStartingFeeRate bool + StartingFeeRate chainfee.SatPerKWeight } func (r *storedRecord) snapshot() *Record { @@ -188,6 +198,7 @@ func (r *storedRecord) snapshot() *Record { txid := *r.SweepTxID result.SweepTxID = &txid } + return &result } @@ -215,7 +226,7 @@ type Config struct { ChainParams *chaincfg.Params // Ready is closed after both the chain notifier and UTXO sweeper have - // started. WalletKit itself starts before either dependency, so notifier + // started. WalletKit itself starts before either dependency. Notifier // registrations must be deferred until this explicit lifecycle signal. Ready <-chan struct{} } @@ -266,11 +277,17 @@ func New(cfg Config) (*Service, error) { case cfg.Sweeper == nil: return nil, errors.New("descriptor sweep sweeper is required") case cfg.BlockSource == nil: - return nil, errors.New("descriptor sweep block source is required") + return nil, errors.New( + "descriptor sweep block source is required", + ) case cfg.ChainParams == nil: - return nil, errors.New("descriptor sweep chain params are required") + return nil, errors.New( + "descriptor sweep chain params are required", + ) case cfg.Ready == nil: - return nil, errors.New("descriptor sweep ready signal is required") + return nil, errors.New( + "descriptor sweep ready signal is required", + ) } storage := newStore(cfg.DB) @@ -295,6 +312,7 @@ func (s *Service) storage() recordStore { if s.store != nil { return s.store } + return newStore(s.cfg.DB) } @@ -313,9 +331,12 @@ func (s *Service) updateRecordLocked(id RegistrationID, return nil, err } if err := s.storage().put(next); err != nil { - return nil, retryable(fmt.Errorf("persist descriptor sweep: %w", err)) + return nil, retryable( + fmt.Errorf("persist descriptor sweep: %w", err), + ) } s.records[id] = next + return next, nil } @@ -324,7 +345,8 @@ func registrationID(descriptor string, bindings []KeyBinding, copyBindings := append([]KeyBinding(nil), bindings...) sort.Slice(copyBindings, func(i, j int) bool { - return copyBindings[i].DescriptorKey < copyBindings[j].DescriptorKey + return copyBindings[i].DescriptorKey < + copyBindings[j].DescriptorKey }) h := sha256.New() @@ -339,6 +361,7 @@ func registrationID(descriptor string, bindings []KeyBinding, var id RegistrationID copy(id[:], h.Sum(nil)) + return id } @@ -357,9 +380,12 @@ func descriptorScripts(desc *descriptors.Descriptor, params *chaincfg.Params, if err != nil { return "", nil, nil, err } - witnessScript, err := desc.ScriptCodeAt(0, index) - if err != nil { - return "", nil, nil, err + var witnessScript []byte + if desc.DescType() == descriptors.DescTypeWsh { + witnessScript, err = desc.ScriptCodeAt(0, index) + if err != nil { + return "", nil, nil, err + } } return addressString, pkScript, witnessScript, nil @@ -414,6 +440,7 @@ func (s *Service) Stop() error { s.mu.Unlock() s.wg.Wait() + return nil } @@ -428,13 +455,17 @@ func (s *Service) Register(_ context.Context, return nil, errors.New("expected output value must be positive") } if req.ExpectedValue > btcutil.MaxSatoshi { - return nil, errors.New("expected output value exceeds maximum money") + return nil, errors.New( + "expected output value exceeds maximum money", + ) } if req.Budget <= 0 { return nil, errors.New("sweep budget must be positive") } if req.Budget > req.ExpectedValue { - return nil, errors.New("sweep budget must not exceed expected output value") + return nil, errors.New( + "sweep budget must not exceed expected output value", + ) } if req.MinConfs == 0 { req.MinConfs = 1 @@ -444,7 +475,9 @@ func (s *Service) Register(_ context.Context, chainntnfs.MaxNumConfs) } if req.DeadlineDelta > uint32(math.MaxInt32) { - return nil, errors.New("deadline delta exceeds maximum block height") + return nil, errors.New( + "deadline delta exceeds maximum block height", + ) } if len(req.Label) > 500 { return nil, errors.New("label must not exceed 500 bytes") @@ -454,11 +487,17 @@ func (s *Service) Register(_ context.Context, if err != nil { return nil, fmt.Errorf("parse descriptor: %w", err) } - if desc.DescType() != descriptors.DescTypeWsh { - return nil, fmt.Errorf("only native wsh descriptors are supported") + switch desc.DescType() { + case descriptors.DescTypeWsh, descriptors.DescTypeTr: + default: + return nil, fmt.Errorf( + "only native wsh and tr descriptors are supported", + ) } if desc.MultipathLen() != 1 { - return nil, errors.New("multipath descriptors are not supported") + return nil, errors.New( + "multipath descriptors are not supported", + ) } if req.DerivationIndex != 0 { return nil, errors.New("ranged descriptors are not supported") @@ -470,6 +509,9 @@ func (s *Service) Register(_ context.Context, if err := s.verifyBindings(desc, req.KeyBindings); err != nil { return nil, err } + if err := validateSupportedPaths(desc, req.KeyBindings); err != nil { + return nil, err + } canonical := desc.String() addr, pkScript, witnessScript, err := descriptorScripts( @@ -489,18 +531,20 @@ func (s *Service) Register(_ context.Context, Descriptor: req.Descriptor, CanonicalDescriptor: canonical, DerivationIndex: req.DerivationIndex, - KeyBindings: append([]KeyBinding(nil), req.KeyBindings...), - Address: addr, - PkScript: pkScript, - WitnessScript: witnessScript, - ExpectedValue: req.ExpectedValue, - HeightHint: req.HeightHint, - MinConfs: req.MinConfs, - Budget: req.Budget, - DeadlineDelta: req.DeadlineDelta, - Immediate: req.Immediate, - Label: req.Label, - Status: StatusRegistered, + KeyBindings: append( + []KeyBinding(nil), + req.KeyBindings...), + Address: addr, + PkScript: pkScript, + WitnessScript: witnessScript, + ExpectedValue: req.ExpectedValue, + HeightHint: req.HeightHint, + MinConfs: req.MinConfs, + Budget: req.Budget, + DeadlineDelta: req.DeadlineDelta, + Immediate: req.Immediate, + Label: req.Label, + Status: StatusRegistered, }, WatchHeight: req.HeightHint, Preimages: make(map[string][]byte), @@ -554,8 +598,11 @@ func (s *Service) AddPreimage(_ context.Context, id RegistrationID, } if record.Status == StatusSweeping || record.Status == StatusSwept || record.Status == StatusFailed { + s.mu.Unlock() - return nil, errors.New("descriptor sweep branch is already frozen") + return nil, errors.New( + "descriptor sweep branch is already frozen", + ) } hash := sha256.Sum256(preimage) desc, err := descriptors.NewDescriptor(record.CanonicalDescriptor) @@ -570,14 +617,18 @@ func (s *Service) AddPreimage(_ context.Context, id RegistrationID, } if !policyCommitsSHA256(policy, hash[:]) { s.mu.Unlock() - return nil, errors.New("preimage does not match a descriptor sha256 commitment") + return nil, errors.New( + "preimage does not match a descriptor sha256 " + + "commitment", + ) } - record, err = s.updateRecordLocked(id, func(next *storedRecord) error { + _, err = s.updateRecordLocked(id, func(next *storedRecord) error { if next.Preimages == nil { next.Preimages = make(map[string][]byte) } next.Preimages[preimageKey("sha256", hash[:])] = append([]byte(nil), preimage...) + return nil }) if err != nil { @@ -602,6 +653,7 @@ func (s *Service) AddPreimage(_ context.Context, id RegistrationID, return nil, err } } + return s.Get(id) } @@ -624,6 +676,7 @@ func policyCommitsSHA256(policy *descriptors.SemanticPolicy, return true } } + return false } @@ -636,6 +689,7 @@ func (s *Service) Get(id RegistrationID) (*Record, error) { if !ok { return nil, ErrNotFound } + return record.snapshot(), nil } @@ -651,5 +705,6 @@ func (s *Service) List() []*Record { sort.Slice(result, func(i, j int) bool { return result[i].ID.String() < result[j].ID.String() }) + return result }