From b877a8d2331387d6729cdfcded67cacd12d776f8 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Thu, 20 Aug 2026 17:55:27 +0000 Subject: [PATCH 1/2] loopd: support stateless HTLC recovery Allow sweephtlc to reconstruct protocol-11 Loop Out HTLCs when the local swap record is unavailable but the public swap data and preimage can be recovered. Prefer signing by public key and use a bounded family-99 key scan only when direct signing fails or produces an invalid witness. --- cmd/loop/sweephtlc.go | 140 +++- cmd/loop/sweephtlc_test.go | 134 ++++ docs/loop.md | 25 +- docs/release-notes/release-notes-next.md | 4 + loopd/sweep_htlc.go | 459 +++++++++++-- loopd/sweep_htlc_test.go | 804 ++++++++++++++++++++++- looprpc/client.pb.go | 358 ++++++---- looprpc/client.proto | 30 +- looprpc/client.swagger.json | 37 +- 9 files changed, 1772 insertions(+), 219 deletions(-) create mode 100644 cmd/loop/sweephtlc_test.go diff --git a/cmd/loop/sweephtlc.go b/cmd/loop/sweephtlc.go index 6f496bd1f..d2298e123 100644 --- a/cmd/loop/sweephtlc.go +++ b/cmd/loop/sweephtlc.go @@ -15,6 +15,17 @@ import ( var sweepHtlcCommand = &cli.Command{ Name: "sweephtlc", Usage: "sweep an HTLC output using the preimage success path", + Description: "Supplying any stateless-recovery flag selects " + + "stateless recovery mode. Both public keys, the preimage, " + + "CLTV expiry, and swap initiation height must then be " + + "supplied. In this mode, Loop reconstructs a protocol-11 " + + "Loop Out HTLC without querying its swap database. It " + + "verifies the reconstructed address against the requested " + + "address and the actual on-chain output, then asks lnd to " + + "sign using the client public key. If signing fails or the " + + "signature does not verify, Loop scans the configured " + + "number of keys in key family 99 and retries with the " + + "recovered key locator.", Flags: []cli.Flag{ &cli.StringFlag{ Name: "outpoint", @@ -41,6 +52,31 @@ var sweepHtlcCommand = &cli.Command{ Usage: "optional preimage hex to override stored " + "swap preimage", }, + &cli.StringFlag{ + Name: "serverpubkey", + Usage: "compressed server HTLC public key; enables " + + "stateless recovery mode", + }, + &cli.StringFlag{ + Name: "clientpubkey", + Usage: "compressed client HTLC public key; enables " + + "stateless recovery mode", + }, + &cli.IntFlag{ + Name: "cltvexpiry", + Usage: "absolute HTLC CLTV expiry for " + + "stateless recovery", + }, + &cli.IntFlag{ + Name: "initiationheight", + Usage: "block height at which the swap was " + + "initiated; required for stateless recovery", + }, + &cli.UintFlag{ + Name: "keyscanlimit", + Usage: "maximum family-99 keys to scan; zero uses " + + "loopd's default", + }, &cli.BoolFlag{ Name: "publish", Usage: "publish the sweep transaction immediately", @@ -69,14 +105,77 @@ func sweepHtlc(ctx context.Context, cmd *cli.Command) error { } } + decodePubKey := func(flag string) ([]byte, error) { + if !cmd.IsSet(flag) { + return nil, nil + } + if cmd.String(flag) == "" { + return nil, fmt.Errorf("%s cannot be empty", flag) + } + + pubKey, err := hex.DecodeString(cmd.String(flag)) + if err != nil { + return nil, fmt.Errorf("invalid %s: %w", flag, err) + } + + return pubKey, nil + } + + stateless := cmd.IsSet("serverpubkey") || + cmd.IsSet("clientpubkey") || cmd.IsSet("cltvexpiry") || + cmd.IsSet("initiationheight") || cmd.IsSet("keyscanlimit") + + var recovery *looprpc.StatelessRecovery + if stateless { + serverPubKey, keyErr := decodePubKey("serverpubkey") + if keyErr != nil { + return keyErr + } + + clientPubKey, keyErr := decodePubKey("clientpubkey") + if keyErr != nil { + return keyErr + } + + cltvExpiry, flagErr := sweepHtlcInt32Flag( + cmd, "cltvexpiry", + ) + if flagErr != nil { + return flagErr + } + + initiationHeight, flagErr := sweepHtlcInt32Flag( + cmd, "initiationheight", + ) + if flagErr != nil { + return flagErr + } + + keyScanLimit, flagErr := sweepHtlcUint32Flag( + cmd, "keyscanlimit", + ) + if flagErr != nil { + return flagErr + } + + recovery = &looprpc.StatelessRecovery{ + ServerPubkey: serverPubKey, + ClientPubkey: clientPubKey, + CltvExpiry: cltvExpiry, + SwapInitiationHeight: initiationHeight, + KeyScanLimit: keyScanLimit, + } + } + // Call SweepHtlc on loopd trying to sweep the HTLC. resp, err := client.SweepHtlc(ctx, &looprpc.SweepHtlcRequest{ - Outpoint: cmd.String("outpoint"), - DestAddress: cmd.String("destaddr"), - HtlcAddress: cmd.String("htlcaddr"), - SatPerVbyte: uint32(cmd.Uint("feerate")), - Preimage: preimage, - Publish: cmd.Bool("publish"), + Outpoint: cmd.String("outpoint"), + DestAddress: cmd.String("destaddr"), + HtlcAddress: cmd.String("htlcaddr"), + SatPerVbyte: uint32(cmd.Uint("feerate")), + Preimage: preimage, + Publish: cmd.Bool("publish"), + StatelessRecovery: recovery, }) if err != nil { return err @@ -117,3 +216,32 @@ func sweepHtlc(ctx context.Context, cmd *cli.Command) error { return nil } + +// sweepHtlcUint32Flag returns an unsigned integer flag after checking that +// protobuf encoding cannot truncate it. +func sweepHtlcUint32Flag(cmd *cli.Command, name string) (uint32, error) { + const maxUint32 = 1<<32 - 1 + + value := cmd.Uint(name) + if uint64(value) > maxUint32 { + return 0, fmt.Errorf("--%s is outside the uint32 range", name) + } + + return uint32(value), nil +} + +// sweepHtlcInt32Flag returns an integer flag after checking that protobuf +// encoding cannot truncate it. +func sweepHtlcInt32Flag(cmd *cli.Command, name string) (int32, error) { + const ( + minInt32 = -1 << 31 + maxInt32 = 1<<31 - 1 + ) + + value := cmd.Int(name) + if int64(value) < minInt32 || int64(value) > maxInt32 { + return 0, fmt.Errorf("--%s is outside the int32 range", name) + } + + return int32(value), nil +} diff --git a/cmd/loop/sweephtlc_test.go b/cmd/loop/sweephtlc_test.go new file mode 100644 index 000000000..cbf621b22 --- /dev/null +++ b/cmd/loop/sweephtlc_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +// TestSweepHtlcInt32Flag verifies that CLI values cannot be truncated when +// they are encoded in the RPC request. +func TestSweepHtlcInt32Flag(t *testing.T) { + testCases := []struct { + name string + value string + expected int32 + expectErr bool + }{ + { + name: "maximum", + value: "2147483647", + expected: 2147483647, + }, + { + name: "positive overflow", + value: "2147483648", + expectErr: true, + }, + { + name: "negative overflow", + value: "-2147483649", + expectErr: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + var actual int32 + command := &cli.Command{ + Flags: []cli.Flag{ + &cli.IntFlag{Name: "height"}, + }, + Action: func(_ context.Context, + cmd *cli.Command) error { + + var err error + actual, err = sweepHtlcInt32Flag( + cmd, "height", + ) + + return err + }, + } + + args := []string{ + "test", "--height", testCase.value, + } + err := command.Run( + t.Context(), args, + ) + if testCase.expectErr { + require.ErrorContains( + t, err, "outside the int32 range", + ) + + return + } + + require.NoError(t, err) + require.Equal(t, testCase.expected, actual) + }) + } +} + +// TestSweepHtlcUint32Flag verifies that scan limits cannot be truncated in +// the RPC request. +func TestSweepHtlcUint32Flag(t *testing.T) { + testCases := []struct { + name string + value string + expected uint32 + expectErr bool + }{ + { + name: "maximum", + value: "4294967295", + expected: 4294967295, + }, + { + name: "overflow", + value: "4294967296", + expectErr: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + var actual uint32 + command := &cli.Command{ + Flags: []cli.Flag{ + &cli.UintFlag{Name: "limit"}, + }, + Action: func(_ context.Context, + cmd *cli.Command) error { + + var err error + actual, err = sweepHtlcUint32Flag( + cmd, "limit", + ) + + return err + }, + } + + args := []string{ + "test", "--limit", testCase.value, + } + err := command.Run( + t.Context(), args, + ) + if testCase.expectErr { + require.ErrorContains( + t, err, "outside the uint32 range", + ) + + return + } + + require.NoError(t, err) + require.Equal(t, testCase.expected, actual) + }) + } +} diff --git a/docs/loop.md b/docs/loop.md index 316cebdce..0897ebe7e 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -57,6 +57,8 @@ The following flags are supported: sweep an HTLC output using the preimage success path. +Supplying any stateless-recovery flag selects stateless recovery mode. Both public keys, the preimage, CLTV expiry, and swap initiation height must then be supplied. In this mode, Loop reconstructs a protocol-11 Loop Out HTLC without querying its swap database. It verifies the reconstructed address against the requested address and the actual on-chain output, then asks lnd to sign using the client public key. If signing fails or the signature does not verify, Loop scans the configured number of keys in key family 99 and retries with the recovered key locator. + Usage: ```bash @@ -65,15 +67,20 @@ $ loop [GLOBAL FLAGS] out sweephtlc [COMMAND FLAGS] [ARGUMENTS...] The following flags are supported: -| Name | Description | Type | Default value | -|------------------|----------------------------------------------------------------|--------|:-------------:| -| `--outpoint="…"` | htlc outpoint to sweep (format: txid:vout) | string | -| `--htlcaddr="…"` | htlc address corresponding to the outpoint | string | -| `--feerate="…"` | fee rate to use in sat/vbyte | uint | `0` | -| `--destaddr="…"` | optional destination address; defaults to a new wallet address | string | -| `--preimage="…"` | optional preimage hex to override stored swap preimage | string | -| `--publish` | publish the sweep transaction immediately | bool | `false` | -| `--help` (`-h`) | show help | bool | `false` | +| Name | Description | Type | Default value | +|--------------------------|-------------------------------------------------------------------------------|--------|:-------------:| +| `--outpoint="…"` | htlc outpoint to sweep (format: txid:vout) | string | +| `--htlcaddr="…"` | htlc address corresponding to the outpoint | string | +| `--feerate="…"` | fee rate to use in sat/vbyte | uint | `0` | +| `--destaddr="…"` | optional destination address; defaults to a new wallet address | string | +| `--preimage="…"` | optional preimage hex to override stored swap preimage | string | +| `--serverpubkey="…"` | compressed server HTLC public key; enables stateless recovery mode | string | +| `--clientpubkey="…"` | compressed client HTLC public key; enables stateless recovery mode | string | +| `--cltvexpiry="…"` | absolute HTLC CLTV expiry for stateless recovery | int | `0` | +| `--initiationheight="…"` | block height at which the swap was initiated; required for stateless recovery | int | `0` | +| `--keyscanlimit="…"` | maximum family-99 keys to scan; zero uses loopd's default | uint | `0` | +| `--publish` | publish the sweep transaction immediately | bool | `false` | +| `--help` (`-h`) | show help | bool | `false` | ### `in` command diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 5aa8195f7..8e85c9d51 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -2,6 +2,10 @@ #### New Features +* The `loop out sweephtlc` recovery command can reconstruct and sweep a + protocol-11 Loop Out HTLC from public swap data when the local swap database + record is unavailable. + #### Breaking Changes * Instant Out and reservation RPCs now require the `loop:out` permission. diff --git a/loopd/sweep_htlc.go b/loopd/sweep_htlc.go index a95727f3b..b88c7aa8e 100644 --- a/loopd/sweep_htlc.go +++ b/loopd/sweep_htlc.go @@ -3,7 +3,9 @@ package loopd import ( "bytes" "context" + "fmt" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -27,6 +29,14 @@ import ( "google.golang.org/grpc/status" ) +// defaultStatelessRecoveryKeyScanLimit is the maximum number of client keys +// scanned during stateless recovery. +const defaultStatelessRecoveryKeyScanLimit = 20_000 + +// statelessRecoveryKeyScanLogInterval controls how often a key scan reports +// progress. +const statelessRecoveryKeyScanLogInterval = 2_000 + // loopOutStore abstracts the minimal store API needed to look up loop-out // swaps. type loopOutStore interface { @@ -45,6 +55,10 @@ type htlcChainNotifier interface { // htlcWallet abstracts the wallet calls used for sweeping. type htlcWallet interface { + // DeriveKey derives the key identified by the given locator. + DeriveKey(ctx context.Context, locator *keychain.KeyLocator) ( + *keychain.KeyDescriptor, error) + // NextAddr derives the next address from the given account and type. NextAddr(ctx context.Context, account string, addrType walletrpc.AddressType, @@ -63,6 +77,10 @@ type htlcSigner interface { SignOutputRaw(ctx context.Context, tx *wire.MsgTx, signDescriptors []*lndclient.SignDescriptor, prevOutputs []*wire.TxOut) ([][]byte, error) + + SignOutputRawKeyLocator(ctx context.Context, tx *wire.MsgTx, + signDescriptors []*lndclient.SignDescriptor, + prevOutputs []*wire.TxOut) ([][]byte, error) } // sweepHtlc spends a Loop HTLC output using the success path and a known @@ -86,6 +104,31 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, "sat_per_vbyte required") } + recovery := req.StatelessRecovery + stateless := recovery != nil + if stateless { + if len(recovery.ServerPubkey) == 0 || + len(recovery.ClientPubkey) == 0 { + + return nil, status.Error(codes.InvalidArgument, + "both server_pubkey and client_pubkey "+ + "are required") + } + if recovery.CltvExpiry <= 0 { + return nil, status.Error(codes.InvalidArgument, + "cltv_expiry required in stateless mode") + } + if recovery.SwapInitiationHeight <= 0 { + return nil, status.Error(codes.InvalidArgument, + "swap_initiation_height required in "+ + "stateless mode") + } + if len(req.Preimage) == 0 { + return nil, status.Error(codes.InvalidArgument, + "preimage required in stateless mode") + } + } + // Parse the inputs. htlcAddr, err := btcutil.DecodeAddress( req.HtlcAddress, chainParams, @@ -119,44 +162,130 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, } } - // Locate the loop-out swap whose HTLC script matches the outpoint so - // we can obtain keys, the stored preimage, and the default destination. - swaps, err := store.FetchLoopOutSwaps(ctx) - if err != nil { - return nil, err - } - var ( - targetSwap *loopdb.LoopOut - targetHtlc *swap.Htlc + targetSwap *loopdb.LoopOut + targetHtlc *swap.Htlc + targetHash lntypes.Hash + preimage lntypes.Preimage + heightHint int32 + storedDest btcutil.Address + keyDesc keychain.KeyDescriptor + clientKey *btcec.PublicKey + keyScanLimit uint32 ) - for _, swp := range swaps { - htlc, htlcErr := utils.GetHtlc( - swp.Hash, &swp.Contract.SwapContract, - chainParams, + if stateless { + preimage, err = lntypes.MakePreimage(req.Preimage) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, + "invalid preimage: %v", err) + } + + serverKey, _, keyErr := parseSweepPubKey( + "server_pubkey", recovery.ServerPubkey, ) - if htlcErr != nil { - return nil, htlcErr + if keyErr != nil { + return nil, keyErr } - if bytes.Equal(htlc.PkScript, htlcPkScript) { - targetSwap = swp - targetHtlc = htlc - break + clientKeyBytes, clientPubKey, keyErr := parseSweepPubKey( + "client_pubkey", recovery.ClientPubkey, + ) + if keyErr != nil { + return nil, keyErr + } + clientKey = clientPubKey + keyDesc.PubKey = clientPubKey + keyScanLimit = recovery.KeyScanLimit + if keyScanLimit == 0 { + keyScanLimit = defaultStatelessRecoveryKeyScanLimit } - } - if targetSwap == nil || targetHtlc == nil { - return nil, status.Error(codes.NotFound, - "no matching swap HTLC found") + targetHash = preimage.Hash() + contract := loopdb.SwapContract{ + Preimage: preimage, + CltvExpiry: recovery.CltvExpiry, + InitiationHeight: recovery.SwapInitiationHeight, + ProtocolVersion: loopdb.ProtocolVersionMuSig2, + HtlcKeys: loopdb.HtlcKeys{ + SenderScriptKey: serverKey, + SenderInternalPubKey: serverKey, + ReceiverScriptKey: clientKeyBytes, + ReceiverInternalPubKey: clientKeyBytes, + }, + } + + targetHtlc, err = utils.GetHtlc( + targetHash, &contract, chainParams, + ) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, + "construct stateless HTLC: %v", err) + } + + if !bytes.Equal(targetHtlc.PkScript, htlcPkScript) { + return nil, status.Errorf( + codes.InvalidArgument, + "provided HTLC address %s does not match "+ + "generated HTLC address %s", + htlcAddr.EncodeAddress(), + targetHtlc.Address.EncodeAddress(), + ) + } + + heightHint = recovery.SwapInitiationHeight + } else { + // Locate the loop-out swap whose HTLC script matches the + // requested HTLC address. This supplies all fields needed by + // the legacy database-backed mode. + swaps, storeErr := store.FetchLoopOutSwaps(ctx) + if storeErr != nil { + return nil, storeErr + } + + for _, swp := range swaps { + htlc, htlcErr := utils.GetHtlc( + swp.Hash, &swp.Contract.SwapContract, + chainParams, + ) + if htlcErr != nil { + return nil, htlcErr + } + + if bytes.Equal(htlc.PkScript, htlcPkScript) { + targetSwap = swp + targetHtlc = htlc + break + } + } + + if targetSwap == nil || targetHtlc == nil { + return nil, status.Error(codes.NotFound, + "no matching swap HTLC found") + } + + targetHash = targetSwap.Hash + heightHint = targetSwap.Contract.InitiationHeight + storedDest = targetSwap.Contract.DestAddr + keyDesc.KeyLocator = targetSwap.Contract.HtlcKeys. + ClientScriptKeyLocator + + if len(req.Preimage) > 0 { + preimage, err = lntypes.MakePreimage(req.Preimage) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, + "invalid preimage: %v", err) + } + } else { + preimage = targetSwap.Contract.Preimage + } } // Prefer the stored swap destination for recovery sweeps and only // derive a fresh wallet address when neither the request nor DB // specifies one. if sweepAddr == nil { - sweepAddr = targetSwap.Contract.DestAddr + sweepAddr = storedDest } if sweepAddr == nil { sweepAddr, err = wallet.NextAddr( @@ -180,22 +309,21 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, infof("sweephtlc: start sweep for %v -> %v", req.Outpoint, sweepAddr.EncodeAddress()) - infof("sweephtlc: matched swap %v at height hint %v", - targetSwap.Hash, targetSwap.Contract.InitiationHeight) + infof("sweephtlc: using swap hash %v at height hint %v", + targetHash, heightHint) - if targetSwap.Contract.InitiationHeight <= 0 { + if heightHint <= 0 { return nil, status.Errorf(codes.InvalidArgument, - "invalid initiation height %d", - targetSwap.Contract.InitiationHeight) + "invalid initiation height %d", heightHint) } // Wait for a confirmation so we can read the full transaction even if // it's not in our wallet. infof("sweephtlc: registering conf ntfn for %v hint=%v", - req.Outpoint, targetSwap.Contract.InitiationHeight) + req.Outpoint, heightHint) confChan, errChan, err := notifier.RegisterConfirmationsNtfn( ctx, &htlcOutpoint.Hash, htlcPkScript, 1, - targetSwap.Contract.InitiationHeight, + heightHint, ) if err != nil { return nil, status.Errorf(codes.Internal, @@ -210,6 +338,11 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, infof("sweephtlc: waiting for confirmation of %v", req.Outpoint) select { case conf := <-confChan: + if conf == nil { + return nil, status.Error(codes.Internal, + "confirmation notification was empty") + } + fundingTx = conf.Tx infof("sweephtlc: funding confirmed at height %v", conf.BlockHeight) @@ -229,6 +362,19 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, "waiting for transaction details") } + if fundingTx == nil { + return nil, status.Error(codes.Internal, + "confirmation did not include the funding transaction") + } + if fundingTx.TxHash() != htlcOutpoint.Hash { + return nil, status.Errorf( + codes.InvalidArgument, + "confirmed transaction %s does not match outpoint "+ + "transaction %s", + fundingTx.TxHash(), htlcOutpoint.Hash, + ) + } + if int(htlcOutpoint.Index) >= len(fundingTx.TxOut) { return nil, status.Errorf(codes.InvalidArgument, "vout %d out of range", htlcOutpoint.Index) @@ -237,25 +383,25 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, htlcTxOut = fundingTx.TxOut[htlcOutpoint.Index] if !bytes.Equal(htlcTxOut.PkScript, htlcPkScript) { + if stateless { + observed := sweepOutputAddress( + htlcTxOut.PkScript, chainParams, + ) + + return nil, status.Errorf( + codes.InvalidArgument, + "on-chain HTLC address %s does not match "+ + "generated HTLC address %s", + observed, targetHtlc.Address.EncodeAddress(), + ) + } + return nil, status.Error(codes.InvalidArgument, "outpoint script does not match HTLC address") } infof("sweephtlc: swap hash validated for %v", req.Outpoint) - // Pick a preimage: prefer the caller-provided override, otherwise use - // the swap's stored preimage. - var preimage lntypes.Preimage - if len(req.Preimage) > 0 { - preimage, err = lntypes.MakePreimage(req.Preimage) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, - "invalid preimage: %v", err) - } - } else { - preimage = targetSwap.Contract.Preimage - } - if preimage.Hash() != targetHtlc.Hash { return nil, status.Error(codes.InvalidArgument, "preimage does not match HTLC hash") @@ -331,34 +477,118 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, Output: prevOut, HashType: targetHtlc.SigHash(), InputIndex: 0, - KeyDesc: keychain.KeyDescriptor{ - KeyLocator: targetSwap.Contract.HtlcKeys. - ClientScriptKeyLocator, - }, + KeyDesc: keyDesc, } if targetHtlc.Version == swap.HtlcV3 { signDesc.SignMethod = input.TaprootScriptSpendSignMethod } - // Sign the HTLC spend. + // Sign the HTLC spend. Stateless recovery first asks lnd to resolve + // the supplied public key from its wallet. A signing error or invalid + // signature triggers the bounded key-family scan. + usedKeyScan := false + scanAndSign := func() ([][]byte, error) { + recoveredKey, scanErr := findStatelessSweepKey( + ctx, wallet, clientKey, keyScanLimit, + ) + if scanErr != nil { + return nil, scanErr + } + + signDesc.KeyDesc = recoveredKey + usedKeyScan = true + + return signer.SignOutputRawKeyLocator( + ctx, sweepTx, + []*lndclient.SignDescriptor{&signDesc}, + []*wire.TxOut{prevOut}, + ) + } + rawSigs, err := signer.SignOutputRaw( ctx, sweepTx, []*lndclient.SignDescriptor{&signDesc}, []*wire.TxOut{prevOut}, ) if err != nil { - return nil, err + if !stateless { + return nil, err + } + + infof("sweephtlc: public-key signing failed: %v; "+ + "scanning up to %d family-%d keys", err, + keyScanLimit, swap.KeyFamily, + ) + rawSigs, err = scanAndSign() + if err != nil { + return nil, err + } } - sig := rawSigs[0] - infof("sweephtlc: witness assembled, tx size=%d vbytes", - sweepTx.SerializeSize()) + applySignature := func(signatures [][]byte) error { + if len(signatures) != 1 || len(signatures[0]) == 0 { + return status.Error(codes.Internal, + "signer returned an invalid signature count") + } - // Assemble the success witness using the signature and preimage. - witness, err := targetHtlc.GenSuccessWitness(sig, preimage) - if err != nil { + witness, witnessErr := targetHtlc.GenSuccessWitness( + signatures[0], preimage, + ) + if witnessErr != nil { + return witnessErr + } + + sweepTx.TxIn[0].Witness = witness + + return nil + } + if err = applySignature(rawSigs); err != nil { return nil, err } - sweepTx.TxIn[0].Witness = witness + + if stateless { + if usedKeyScan { + infof("sweephtlc: verifying stateless sweep witness " + + "after key recovery") + } else { + infof("sweephtlc: verifying stateless sweep witness") + } + + verifyErr := verifySweepHtlcWitness(sweepTx, prevOut) + if verifyErr != nil && !usedKeyScan { + infof("sweephtlc: public-key signature did not match "+ + "client key; scanning up to %d family-%d keys", + keyScanLimit, swap.KeyFamily) + + rawSigs, err = scanAndSign() + if err != nil { + return nil, err + } + if err = applySignature(rawSigs); err != nil { + return nil, err + } + + infof("sweephtlc: verifying stateless sweep witness " + + "after key recovery") + verifyErr = verifySweepHtlcWitness(sweepTx, prevOut) + } + if verifyErr != nil { + return nil, status.Errorf(codes.Internal, + "lnd produced an invalid signature for "+ + "client_pubkey: %v", verifyErr) + } + infof("sweephtlc: stateless sweep witness verified") + + if usedKeyScan { + infof("sweephtlc: signed with recovered client " + + "key locator") + } else { + infof("sweephtlc: signed directly with client " + + "public key") + } + } + + infof("sweephtlc: witness assembled, tx size=%d vbytes", + sweepTx.SerializeSize()) var rawBuf bytes.Buffer err = sweepTx.Serialize(&rawBuf) @@ -372,7 +602,7 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, if req.Publish { err = wallet.PublishTransaction( ctx, sweepTx, - labels.LoopOutSweepSuccess(targetSwap.Hash.String()), + labels.LoopOutSweepSuccess(targetHash.String()), ) if err != nil { errorf("sweephtlc: publish failed for %v: %v", @@ -408,3 +638,116 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, return resp, nil } + +// findStatelessSweepKey locates the client key in the Loop key family so lnd +// receives the actual key locator when signing. +func findStatelessSweepKey(ctx context.Context, wallet htlcWallet, + targetKey *btcec.PublicKey, + keyScanLimit uint32) (keychain.KeyDescriptor, error) { + + for index := range keyScanLimit { + locator := keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.KeyFamily), + Index: index, + } + keyDesc, err := wallet.DeriveKey(ctx, &locator) + if err != nil { + return keychain.KeyDescriptor{}, status.Errorf( + codes.Internal, + "derive client key at family %d index %d: %v", + swap.KeyFamily, index, err, + ) + } + if keyDesc == nil || keyDesc.PubKey == nil { + return keychain.KeyDescriptor{}, status.Errorf( + codes.Internal, + "lnd returned an empty key at family %d "+ + "index %d", + swap.KeyFamily, index, + ) + } + + keysScanned := index + 1 + if keysScanned%statelessRecoveryKeyScanLogInterval == 0 { + infof("sweephtlc: scanned %d of %d family-%d keys", + keysScanned, keyScanLimit, swap.KeyFamily, + ) + } + + if keyDesc.PubKey.IsEqual(targetKey) { + infof("sweephtlc: found client key at family %d "+ + "index %d", swap.KeyFamily, index, + ) + + return *keyDesc, nil + } + } + + return keychain.KeyDescriptor{}, status.Errorf( + codes.FailedPrecondition, + "client_pubkey does not belong to the connected lnd wallet; "+ + "searched key family %d indices 0-%d", + swap.KeyFamily, keyScanLimit-1, + ) +} + +// parseSweepPubKey parses a canonical compressed public key for stateless +// recovery. +func parseSweepPubKey(name string, serialized []byte) ([33]byte, + *btcec.PublicKey, error) { + + var keyBytes [33]byte + if len(serialized) != len(keyBytes) { + return keyBytes, nil, status.Errorf( + codes.InvalidArgument, "%s must be 33 bytes", name, + ) + } + + pubKey, err := btcec.ParsePubKey(serialized) + if err != nil { + return keyBytes, nil, status.Errorf( + codes.InvalidArgument, "invalid %s: %v", name, err, + ) + } + if !bytes.Equal(serialized, pubKey.SerializeCompressed()) { + return keyBytes, nil, status.Errorf( + codes.InvalidArgument, + "%s must use canonical compressed encoding", name, + ) + } + + copy(keyBytes[:], serialized) + + return keyBytes, pubKey, nil +} + +// sweepOutputAddress returns a printable address for an observed output. A +// script is returned when the output isn't a standard single-address script. +func sweepOutputAddress(pkScript []byte, chainParams *chaincfg.Params) string { + _, addresses, _, err := txscript.ExtractPkScriptAddrs( + pkScript, chainParams, + ) + if err != nil || len(addresses) != 1 { + return fmt.Sprintf("script:%x", pkScript) + } + + return addresses[0].EncodeAddress() +} + +// verifySweepHtlcWitness executes the stateless recovery witness against the +// exact on-chain output before the transaction can be returned or published. +func verifySweepHtlcWitness(sweepTx *wire.MsgTx, prevOut *wire.TxOut) error { + prevOutFetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(sweepTx, prevOutFetcher) + engine, err := txscript.NewEngine( + prevOut.PkScript, sweepTx, 0, txscript.StandardVerifyFlags, + nil, sigHashes, prevOut.Value, prevOutFetcher, + ) + if err != nil { + return err + } + + return engine.Execute() +} diff --git a/loopd/sweep_htlc_test.go b/loopd/sweep_htlc_test.go index 7be675cc6..9257dfd89 100644 --- a/loopd/sweep_htlc_test.go +++ b/loopd/sweep_htlc_test.go @@ -4,19 +4,23 @@ import ( "bytes" "context" "errors" + "fmt" "testing" "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/looprpc" "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/test" "github.com/lightninglabs/loop/utils" "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lntypes" @@ -37,6 +41,7 @@ var sweepHtlcTests = []struct { noSwap bool publish bool publishErr bool + signer htlcSigner modifyReq func(*looprpc.SweepHtlcRequest) mutateSwap func(*loopdb.LoopOutContract) mutateTxOut func(*wire.TxOut) @@ -49,7 +54,7 @@ var sweepHtlcTests = []struct { expectRegister: true, expectLogs: []string{ "sweephtlc: start sweep for %v -> %v", - "sweephtlc: matched swap %v at height hint %v", + "sweephtlc: using swap hash %v at height hint %v", "sweephtlc: registering conf ntfn for %v hint=%v", "sweephtlc: waiting for confirmation of %v", "sweephtlc: funding confirmed at height %v", @@ -66,7 +71,7 @@ var sweepHtlcTests = []struct { expectRegister: true, expectLogs: []string{ "sweephtlc: start sweep for %v -> %v", - "sweephtlc: matched swap %v at height hint %v", + "sweephtlc: using swap hash %v at height hint %v", "sweephtlc: registering conf ntfn for %v hint=%v", "sweephtlc: waiting for confirmation of %v", "sweephtlc: funding confirmed at height %v", @@ -85,7 +90,7 @@ var sweepHtlcTests = []struct { expectRegister: true, expectLogs: []string{ "sweephtlc: start sweep for %v -> %v", - "sweephtlc: matched swap %v at height hint %v", + "sweephtlc: using swap hash %v at height hint %v", "sweephtlc: registering conf ntfn for %v hint=%v", "sweephtlc: waiting for confirmation of %v", "sweephtlc: funding confirmed at height %v", @@ -106,7 +111,7 @@ var sweepHtlcTests = []struct { expectRegister: true, expectLogs: []string{ "sweephtlc: start sweep for %v -> %v", - "sweephtlc: matched swap %v at height hint %v", + "sweephtlc: using swap hash %v at height hint %v", "sweephtlc: registering conf ntfn for %v hint=%v", "sweephtlc: waiting for confirmation of %v", "sweephtlc: funding confirmed at height %v", @@ -124,7 +129,7 @@ var sweepHtlcTests = []struct { expectRegister: true, expectLogs: []string{ "sweephtlc: start sweep for %v -> %v", - "sweephtlc: matched swap %v at height hint %v", + "sweephtlc: using swap hash %v at height hint %v", "sweephtlc: registering conf ntfn for %v hint=%v", "sweephtlc: waiting for confirmation of %v", "sweephtlc: funding confirmed at height %v", @@ -193,7 +198,7 @@ var sweepHtlcTests = []struct { }, expectLogs: []string{ "sweephtlc: start sweep for %v -> %v", - "sweephtlc: matched swap %v at height hint %v", + "sweephtlc: using swap hash %v at height hint %v", }, }, { @@ -207,12 +212,47 @@ var sweepHtlcTests = []struct { }, expectLogs: []string{ "sweephtlc: start sweep for %v -> %v", - "sweephtlc: matched swap %v at height hint %v", + "sweephtlc: using swap hash %v at height hint %v", "sweephtlc: registering conf ntfn for %v hint=%v", "sweephtlc: waiting for confirmation of %v", "sweephtlc: conf ntfn error for %v: %v", }, }, + { + name: "empty confirmation", + amount: 100_000, + satPerVByte: 10, + expectErrMsg: "confirmation notification was empty", + expectRegister: true, + sendConf: func(reg *test.ConfRegistration) { + close(reg.ConfChan) + }, + expectLogs: []string{ + "sweephtlc: start sweep for %v -> %v", + "sweephtlc: using swap hash %v at height hint %v", + "sweephtlc: registering conf ntfn for %v hint=%v", + "sweephtlc: waiting for confirmation of %v", + }, + }, + { + name: "confirmed transaction mismatch", + amount: 100_000, + satPerVByte: 10, + expectErrMsg: "confirmed transaction", + expectRegister: true, + sendConf: func(reg *test.ConfRegistration) { + reg.ConfChan <- &chainntnfs.TxConfirmation{ + Tx: wire.NewMsgTx(2), + } + }, + expectLogs: []string{ + "sweephtlc: start sweep for %v -> %v", + "sweephtlc: using swap hash %v at height hint %v", + "sweephtlc: registering conf ntfn for %v hint=%v", + "sweephtlc: waiting for confirmation of %v", + "sweephtlc: funding confirmed at height %v", + }, + }, { name: "outpoint script mismatch", amount: 100_000, @@ -224,7 +264,7 @@ var sweepHtlcTests = []struct { }, expectLogs: []string{ "sweephtlc: start sweep for %v -> %v", - "sweephtlc: matched swap %v at height hint %v", + "sweephtlc: using swap hash %v at height hint %v", "sweephtlc: registering conf ntfn for %v hint=%v", "sweephtlc: waiting for confirmation of %v", "sweephtlc: funding confirmed at height %v", @@ -238,7 +278,7 @@ var sweepHtlcTests = []struct { expectRegister: true, expectLogs: []string{ "sweephtlc: start sweep for %v -> %v", - "sweephtlc: matched swap %v at height hint %v", + "sweephtlc: using swap hash %v at height hint %v", "sweephtlc: registering conf ntfn for %v hint=%v", "sweephtlc: waiting for confirmation of %v", "sweephtlc: funding confirmed at height %v", @@ -257,7 +297,7 @@ var sweepHtlcTests = []struct { }, expectLogs: []string{ "sweephtlc: start sweep for %v -> %v", - "sweephtlc: matched swap %v at height hint %v", + "sweephtlc: using swap hash %v at height hint %v", "sweephtlc: registering conf ntfn for %v hint=%v", "sweephtlc: waiting for confirmation of %v", "sweephtlc: funding confirmed at height %v", @@ -275,7 +315,7 @@ var sweepHtlcTests = []struct { expectLogs: []string{ "sweephtlc: generated new destination address: %v", "sweephtlc: start sweep for %v -> %v", - "sweephtlc: matched swap %v at height hint %v", + "sweephtlc: using swap hash %v at height hint %v", "sweephtlc: registering conf ntfn for %v hint=%v", "sweephtlc: waiting for confirmation of %v", "sweephtlc: funding confirmed at height %v", @@ -285,6 +325,727 @@ var sweepHtlcTests = []struct { "sweephtlc: witness assembled, tx size=%d vbytes", }, }, + { + name: "invalid signer response", + amount: 100_000, + satPerVByte: 10, + expectErrMsg: "signer returned an invalid signature count", + expectRegister: true, + signer: &emptySweepSigner{}, + expectLogs: []string{ + "sweephtlc: start sweep for %v -> %v", + "sweephtlc: using swap hash %v at height hint %v", + "sweephtlc: registering conf ntfn for %v hint=%v", + "sweephtlc: waiting for confirmation of %v", + "sweephtlc: funding confirmed at height %v", + "sweephtlc: swap hash validated for %v", + "sweephtlc: sweeping to %v with feerate %v sat/vbyte", + "sweephtlc: signing sweep spending %v", + }, + }, +} + +// TestSweepHtlcStatelessValidation covers the stateless request fields before +// any swap database or chain access is attempted. +func TestSweepHtlcStatelessValidation(t *testing.T) { + defer test.Guard(t)() + setLogger(btclog.Disabled) + + lnd := test.NewMockLnd() + serverPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + _, clientKey := test.CreateKey(3) + serverPubKey := serverPrivKey.PubKey().SerializeCompressed() + clientPubKey := clientKey.SerializeCompressed() + preimage := lntypes.Preimage{1, 2, 3, 4} + htlc := statelessTestHtlc( + t, lnd, preimage, 500, serverPubKey, clientPubKey, + ) + + newRequest := func() *looprpc.SweepHtlcRequest { + return &looprpc.SweepHtlcRequest{ + Outpoint: wire.OutPoint{}.String(), + HtlcAddress: htlc.Address.EncodeAddress(), + SatPerVbyte: 10, + Preimage: preimage[:], + StatelessRecovery: &looprpc.StatelessRecovery{ + ServerPubkey: serverPubKey, + ClientPubkey: clientPubKey, + CltvExpiry: 500, + SwapInitiationHeight: 123, + }, + } + } + + testCases := []struct { + name string + modify func(*looprpc.SweepHtlcRequest) + expected string + }{ + { + name: "missing server key", + modify: func(req *looprpc.SweepHtlcRequest) { + req.StatelessRecovery.ServerPubkey = nil + }, + expected: "both server_pubkey and client_pubkey " + + "are required", + }, + { + name: "missing client key", + modify: func(req *looprpc.SweepHtlcRequest) { + req.StatelessRecovery.ClientPubkey = nil + }, + expected: "both server_pubkey and client_pubkey " + + "are required", + }, + { + name: "missing cltv expiry", + modify: func(req *looprpc.SweepHtlcRequest) { + req.StatelessRecovery.CltvExpiry = 0 + }, + expected: "cltv_expiry required in stateless mode", + }, + { + name: "missing initiation height", + modify: func(req *looprpc.SweepHtlcRequest) { + req.StatelessRecovery.SwapInitiationHeight = 0 + }, + expected: "swap_initiation_height required in " + + "stateless mode", + }, + { + name: "missing preimage", + modify: func(req *looprpc.SweepHtlcRequest) { + req.Preimage = nil + }, + expected: "preimage required in stateless mode", + }, + { + name: "server key length", + modify: func(req *looprpc.SweepHtlcRequest) { + req.StatelessRecovery.ServerPubkey = []byte{2} + }, + expected: "server_pubkey must be 33 bytes", + }, + { + name: "server key encoding", + modify: func(req *looprpc.SweepHtlcRequest) { + recovery := req.StatelessRecovery + recovery.ServerPubkey = bytes.Repeat( + []byte{0xff}, 33, + ) + }, + expected: "invalid server_pubkey", + }, + { + name: "client key length", + modify: func(req *looprpc.SweepHtlcRequest) { + req.StatelessRecovery.ClientPubkey = []byte{2} + }, + expected: "client_pubkey must be 33 bytes", + }, + { + name: "client key encoding", + modify: func(req *looprpc.SweepHtlcRequest) { + recovery := req.StatelessRecovery + recovery.ClientPubkey = bytes.Repeat( + []byte{0xff}, 33, + ) + }, + expected: "invalid client_pubkey", + }, + } + + store := &rejectingLoopOutStore{} + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + req := newRequest() + testCase.modify(req) + + _, err := sweepHtlc( + t.Context(), req, lnd.ChainParams, store, + lnd.ChainNotifier, lnd.WalletKit, + &realSweepSigner{}, + ) + require.ErrorContains(t, err, testCase.expected) + }) + } + + require.Zero(t, store.calls) + require.NoError(t, lnd.IsDone()) +} + +// TestSweepHtlcStateless exercises public-key signing and the bounded key scan +// fallback with real Schnorr signatures and script execution. +func TestSweepHtlcStateless(t *testing.T) { + defer test.Guard(t)() + setLogger(btclog.Disabled) + + const clientKeyIndex = int32(7) + + testCases := []struct { + name string + directMode string + walletErr error + keyScanLimit uint32 + expectErr string + expectKeyScans int + expectLocatorSig int + }{ + { + name: "lnd knows public key", + keyScanLimit: 1, + }, + { + name: "signer error scan succeeds", + directMode: "signer error", + keyScanLimit: uint32(clientKeyIndex + 1), + expectKeyScans: int(clientKeyIndex + 1), + expectLocatorSig: 1, + }, + { + name: "zero scan limit uses default", + directMode: "signer error", + expectKeyScans: int(clientKeyIndex + 1), + expectLocatorSig: 1, + }, + { + name: "legacy wrong key scan succeeds", + directMode: "wrong signature", + keyScanLimit: uint32(clientKeyIndex + 1), + expectKeyScans: int(clientKeyIndex + 1), + expectLocatorSig: 1, + }, + { + name: "key outside scan range", + directMode: "signer error", + keyScanLimit: uint32(clientKeyIndex), + expectErr: "searched key family 99 indices 0-6", + expectKeyScans: int(clientKeyIndex), + }, + { + name: "signer error reports scan failure", + directMode: "signer error", + walletErr: errors.New("wallet locked"), + keyScanLimit: uint32(clientKeyIndex + 1), + expectErr: "derive client key at family 99 index 0: " + + "wallet locked", + expectKeyScans: 1, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + logger := newFormatLogger() + setLogger(logger) + + lnd := test.NewMockLnd() + store := &rejectingLoopOutStore{} + wallet := &countingSweepWallet{ + htlcWallet: lnd.WalletKit, + deriveErr: testCase.walletErr, + } + + serverPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + clientPrivKey, clientKey := test.CreateKey( + clientKeyIndex, + ) + wrongPrivKey, _ := test.CreateKey(100) + serverPubKey := serverPrivKey.PubKey(). + SerializeCompressed() + clientPubKey := clientKey.SerializeCompressed() + preimage := lntypes.Preimage{1, 2, 3, 4} + + htlc := statelessTestHtlc( + t, lnd, preimage, 500, serverPubKey, + clientPubKey, + ) + fundingTx := wire.NewMsgTx(2) + fundingTx.AddTxOut(&wire.TxOut{ + Value: 100_000, + PkScript: htlc.PkScript, + }) + outpoint := wire.OutPoint{Hash: fundingTx.TxHash()} + + destAddr, err := btcutil.NewAddressWitnessPubKeyHash( + bytes.Repeat([]byte{2}, 20), lnd.ChainParams, + ) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout( + t.Context(), 5*time.Second, + ) + defer cancel() + go sendSweepConfirmation(ctx, lnd, fundingTx) + + signer := &realSweepSigner{ + privateKey: clientPrivKey, + locatorPrivateKey: clientPrivKey, + expectedPubKey: clientPubKey, + expectedKeyLocator: &keychain.KeyLocator{ + Family: keychain.KeyFamily( + swap.KeyFamily, + ), + Index: uint32(clientKeyIndex), + }, + } + switch testCase.directMode { + case "signer error": + signer.directErr = errors.New("signer failed") + + case "wrong signature": + signer.privateKey = wrongPrivKey + } + + recovery := &looprpc.StatelessRecovery{ + ServerPubkey: serverPubKey, + ClientPubkey: clientPubKey, + CltvExpiry: 500, + SwapInitiationHeight: 123, + KeyScanLimit: testCase.keyScanLimit, + } + request := &looprpc.SweepHtlcRequest{ + Outpoint: outpoint.String(), + HtlcAddress: htlc.Address.EncodeAddress(), + DestAddress: destAddr.EncodeAddress(), + SatPerVbyte: 10, + Preimage: preimage[:], + StatelessRecovery: recovery, + } + resp, err := sweepHtlc( + ctx, request, lnd.ChainParams, store, + lnd.ChainNotifier, + wallet, signer, + ) + if testCase.expectErr != "" { + require.ErrorContains( + t, err, testCase.expectErr, + ) + require.Nil(t, resp) + } else { + require.NoError(t, err) + expectedVerifyLog := "sweephtlc: verifying " + + "stateless sweep witness" + if testCase.expectLocatorSig > 0 { + expectedVerifyLog += " after " + + "key recovery" + } + require.Contains( + t, logger.formats, expectedVerifyLog, + ) + verifiedLog := "sweephtlc: stateless sweep " + + "witness verified" + require.Contains(t, logger.formats, verifiedLog) + + var sweepTx wire.MsgTx + require.NoError(t, sweepTx.Deserialize( + bytes.NewReader(resp.SweepTx), + )) + require.NoError(t, verifySweepHtlcWitness( + &sweepTx, fundingTx.TxOut[0], + )) + } + + require.Zero(t, store.calls) + require.Equal(t, 1, signer.directCalls) + require.Equal( + t, testCase.expectLocatorSig, + signer.keyLocatorCalls, + ) + require.Equal( + t, testCase.expectKeyScans, wallet.deriveCalls, + ) + require.NoError(t, lnd.IsDone()) + }) + } +} + +// TestFindStatelessSweepKeyProgress verifies that a long scan reports +// progress before exhausting its search range. +func TestFindStatelessSweepKeyProgress(t *testing.T) { + defer test.Guard(t)() + + logger := newFormatLogger() + setLogger(logger) + lnd := test.NewMockLnd() + targetKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + keyScanLimit := uint32(statelessRecoveryKeyScanLogInterval) + + _, err = findStatelessSweepKey( + t.Context(), lnd.WalletKit, targetKey.PubKey(), keyScanLimit, + ) + require.ErrorContains(t, err, "searched key family 99 indices 0-1999") + require.Equal(t, []string{ + "sweephtlc: scanned %d of %d family-%d keys", + }, logger.formats) + require.NoError(t, lnd.IsDone()) +} + +// TestSweepHtlcStatelessRejectsInvalidSignature ensures a signer cannot return +// a signature for a different key and still produce a recovery transaction. +func TestSweepHtlcStatelessRejectsInvalidSignature(t *testing.T) { + defer test.Guard(t)() + setLogger(btclog.Disabled) + + lnd := test.NewMockLnd() + serverPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + _, clientKey := test.CreateKey(8) + wrongPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + serverPubKey := serverPrivKey.PubKey().SerializeCompressed() + clientPubKey := clientKey.SerializeCompressed() + preimage := lntypes.Preimage{5, 6, 7, 8} + htlc := statelessTestHtlc( + t, lnd, preimage, 500, serverPubKey, clientPubKey, + ) + + fundingTx := wire.NewMsgTx(2) + fundingTx.AddTxOut(&wire.TxOut{ + Value: 100_000, + PkScript: htlc.PkScript, + }) + outpoint := wire.OutPoint{Hash: fundingTx.TxHash()} + + destAddr, err := btcutil.NewAddressWitnessPubKeyHash( + bytes.Repeat([]byte{3}, 20), lnd.ChainParams, + ) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + go sendSweepConfirmation(ctx, lnd, fundingTx) + + _, err = sweepHtlc( + ctx, &looprpc.SweepHtlcRequest{ + Outpoint: outpoint.String(), + HtlcAddress: htlc.Address.EncodeAddress(), + DestAddress: destAddr.EncodeAddress(), + SatPerVbyte: 10, + Preimage: preimage[:], + StatelessRecovery: &looprpc.StatelessRecovery{ + ServerPubkey: serverPubKey, + ClientPubkey: clientPubKey, + CltvExpiry: 500, + SwapInitiationHeight: 123, + }, + }, lnd.ChainParams, &rejectingLoopOutStore{}, + lnd.ChainNotifier, lnd.WalletKit, &realSweepSigner{ + privateKey: wrongPrivKey, + expectedPubKey: clientPubKey, + }, + ) + require.ErrorContains(t, err, "invalid signature for client_pubkey") + require.NoError(t, lnd.IsDone()) +} + +// TestSweepHtlcStatelessAddressMismatch verifies that key reconstruction is +// checked before any database or chain access and reports both addresses. +func TestSweepHtlcStatelessAddressMismatch(t *testing.T) { + defer test.Guard(t)() + setLogger(btclog.Disabled) + + lnd := test.NewMockLnd() + serverPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + clientPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + otherServerPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + serverPubKey := serverPrivKey.PubKey().SerializeCompressed() + clientPubKey := clientPrivKey.PubKey().SerializeCompressed() + otherServerPubKey := otherServerPrivKey.PubKey().SerializeCompressed() + preimage := lntypes.Preimage{9, 10, 11, 12} + + providedHtlc := statelessTestHtlc( + t, lnd, preimage, 500, serverPubKey, clientPubKey, + ) + generatedHtlc := statelessTestHtlc( + t, lnd, preimage, 500, otherServerPubKey, clientPubKey, + ) + store := &rejectingLoopOutStore{} + + _, err = sweepHtlc( + t.Context(), &looprpc.SweepHtlcRequest{ + Outpoint: wire.OutPoint{}.String(), + HtlcAddress: providedHtlc.Address.EncodeAddress(), + SatPerVbyte: 10, + Preimage: preimage[:], + StatelessRecovery: &looprpc.StatelessRecovery{ + ServerPubkey: otherServerPubKey, + ClientPubkey: clientPubKey, + CltvExpiry: 500, + SwapInitiationHeight: 123, + }, + }, lnd.ChainParams, store, lnd.ChainNotifier, + lnd.WalletKit, &realSweepSigner{}, + ) + require.ErrorContains( + t, err, providedHtlc.Address.EncodeAddress(), + ) + require.ErrorContains( + t, err, generatedHtlc.Address.EncodeAddress(), + ) + require.Zero(t, store.calls) + require.NoError(t, lnd.IsDone()) +} + +// TestSweepHtlcStatelessOnChainAddressMismatch verifies that the actual +// outpoint script is compared with the reconstructed HTLC and that both +// addresses are included in the error. +func TestSweepHtlcStatelessOnChainAddressMismatch(t *testing.T) { + defer test.Guard(t)() + setLogger(btclog.Disabled) + + lnd := test.NewMockLnd() + serverPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + clientPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + serverPubKey := serverPrivKey.PubKey().SerializeCompressed() + clientPubKey := clientPrivKey.PubKey().SerializeCompressed() + preimage := lntypes.Preimage{13, 14, 15, 16} + htlc := statelessTestHtlc( + t, lnd, preimage, 500, serverPubKey, clientPubKey, + ) + observedAddr, err := btcutil.NewAddressWitnessPubKeyHash( + bytes.Repeat([]byte{4}, 20), lnd.ChainParams, + ) + require.NoError(t, err) + observedScript, err := txscript.PayToAddrScript(observedAddr) + require.NoError(t, err) + + fundingTx := wire.NewMsgTx(2) + fundingTx.AddTxOut(&wire.TxOut{ + Value: 100_000, + PkScript: observedScript, + }) + outpoint := wire.OutPoint{Hash: fundingTx.TxHash()} + store := &rejectingLoopOutStore{} + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + go sendSweepConfirmation(ctx, lnd, fundingTx) + + _, err = sweepHtlc( + ctx, &looprpc.SweepHtlcRequest{ + Outpoint: outpoint.String(), + HtlcAddress: htlc.Address.EncodeAddress(), + SatPerVbyte: 10, + Preimage: preimage[:], + StatelessRecovery: &looprpc.StatelessRecovery{ + ServerPubkey: serverPubKey, + ClientPubkey: clientPubKey, + CltvExpiry: 500, + SwapInitiationHeight: 123, + }, + }, lnd.ChainParams, store, lnd.ChainNotifier, + lnd.WalletKit, &realSweepSigner{}, + ) + require.ErrorContains(t, err, observedAddr.EncodeAddress()) + require.ErrorContains(t, err, htlc.Address.EncodeAddress()) + require.Zero(t, store.calls) + require.NoError(t, lnd.IsDone()) +} + +// statelessTestHtlc constructs the latest Loop Out HTLC from the public data +// accepted by stateless sweep mode. +func statelessTestHtlc(t *testing.T, lnd *test.LndMockServices, + preimage lntypes.Preimage, cltvExpiry int32, serverPubKey, + clientPubKey []byte) *swap.Htlc { + + var serverKey, clientKey [33]byte + copy(serverKey[:], serverPubKey) + copy(clientKey[:], clientPubKey) + + htlc, err := utils.GetHtlc( + preimage.Hash(), &loopdb.SwapContract{ + CltvExpiry: cltvExpiry, + ProtocolVersion: loopdb.ProtocolVersionMuSig2, + HtlcKeys: loopdb.HtlcKeys{ + SenderScriptKey: serverKey, + SenderInternalPubKey: serverKey, + ReceiverScriptKey: clientKey, + ReceiverInternalPubKey: clientKey, + }, + }, lnd.ChainParams, + ) + require.NoError(t, err) + + return htlc +} + +// sendSweepConfirmation responds to a confirmation registration with the +// supplied funding transaction. +func sendSweepConfirmation(ctx context.Context, lnd *test.LndMockServices, + fundingTx *wire.MsgTx) { + + select { + case registration := <-lnd.RegisterConfChannel: + registration.ConfChan <- &chainntnfs.TxConfirmation{ + Tx: fundingTx, + } + + case <-ctx.Done(): + } +} + +// rejectingLoopOutStore proves that stateless recovery does not query the +// Loop database. +type rejectingLoopOutStore struct { + calls int +} + +// countingSweepWallet records bounded key scan derivations. +type countingSweepWallet struct { + htlcWallet + + deriveCalls int + deriveErr error +} + +// DeriveKey records and forwards a key derivation. +func (w *countingSweepWallet) DeriveKey(ctx context.Context, + locator *keychain.KeyLocator) (*keychain.KeyDescriptor, error) { + + w.deriveCalls++ + if w.deriveErr != nil { + return nil, w.deriveErr + } + + return w.htlcWallet.DeriveKey(ctx, locator) +} + +// FetchLoopOutSwaps always fails if the stateless path accesses the store. +func (s *rejectingLoopOutStore) FetchLoopOutSwaps(context.Context) ( + []*loopdb.LoopOut, error) { + + s.calls++ + + return nil, errors.New("unexpected swap database access") +} + +// realSweepSigner produces real Schnorr signatures for the Taproot script +// spend used by stateless recovery. +type realSweepSigner struct { + privateKey *btcec.PrivateKey + locatorPrivateKey *btcec.PrivateKey + directErr error + expectedPubKey []byte + expectedKeyLocator *keychain.KeyLocator + directCalls int + keyLocatorCalls int +} + +// emptySweepSigner returns an invalid empty signature response. +type emptySweepSigner struct{} + +// SignOutputRaw returns no signatures. +func (*emptySweepSigner) SignOutputRaw(context.Context, *wire.MsgTx, + []*lndclient.SignDescriptor, []*wire.TxOut) ([][]byte, error) { + + return nil, nil +} + +// SignOutputRawKeyLocator returns no signatures. +func (*emptySweepSigner) SignOutputRawKeyLocator(context.Context, *wire.MsgTx, + []*lndclient.SignDescriptor, []*wire.TxOut) ([][]byte, error) { + + return nil, nil +} + +// SignOutputRaw signs each descriptor with the configured private key. +func (s *realSweepSigner) SignOutputRaw(_ context.Context, tx *wire.MsgTx, + signDescriptors []*lndclient.SignDescriptor, + prevOutputs []*wire.TxOut) ([][]byte, error) { + + s.directCalls++ + if s.directErr != nil { + return nil, s.directErr + } + + return s.sign(tx, signDescriptors, prevOutputs, s.privateKey, false) +} + +// SignOutputRawKeyLocator signs using the recovered key locator. +func (s *realSweepSigner) SignOutputRawKeyLocator(_ context.Context, + tx *wire.MsgTx, signDescriptors []*lndclient.SignDescriptor, + prevOutputs []*wire.TxOut) ([][]byte, error) { + + s.keyLocatorCalls++ + + privateKey := s.locatorPrivateKey + if privateKey == nil { + privateKey = s.privateKey + } + + return s.sign(tx, signDescriptors, prevOutputs, privateKey, true) +} + +// sign produces real Schnorr signatures with the selected private key. +func (s *realSweepSigner) sign(tx *wire.MsgTx, + signDescriptors []*lndclient.SignDescriptor, + prevOutputs []*wire.TxOut, privateKey *btcec.PrivateKey, + requireLocator bool) ([][]byte, error) { + + if privateKey == nil { + return nil, errors.New("private key unavailable") + } + if len(prevOutputs) != len(tx.TxIn) { + return nil, errors.New("previous output count mismatch") + } + + prevOutFetcher := txscript.NewMultiPrevOutFetcher(nil) + for i, txIn := range tx.TxIn { + prevOutFetcher.AddPrevOut( + txIn.PreviousOutPoint, prevOutputs[i], + ) + } + sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher) + + signatures := make([][]byte, len(signDescriptors)) + for i, signDesc := range signDescriptors { + if signDesc.SignMethod != input.TaprootScriptSpendSignMethod { + return nil, fmt.Errorf("unexpected sign method: %v", + signDesc.SignMethod) + } + if signDesc.KeyDesc.PubKey == nil { + return nil, errors.New("signing public key missing") + } + serializedKey := signDesc.KeyDesc.PubKey.SerializeCompressed() + if !bytes.Equal(serializedKey, s.expectedPubKey) { + return nil, errors.New("unexpected signing public key") + } + if requireLocator && s.expectedKeyLocator != nil && + signDesc.KeyDesc.KeyLocator != *s.expectedKeyLocator { + + return nil, fmt.Errorf( + "unexpected signing key locator: %v", + signDesc.KeyDesc.KeyLocator, + ) + } + + tapLeaf := txscript.NewBaseTapLeaf( + signDesc.WitnessScript, + ) + signature, err := txscript.RawTxInTapscriptSignature( + tx, sigHashes, signDesc.InputIndex, + signDesc.Output.Value, signDesc.Output.PkScript, + tapLeaf, signDesc.HashType, privateKey, + ) + if err != nil { + return nil, err + } + + signatures[i] = signature + } + + return signatures, nil } // TestSweepHtlc runs a table of happy-path and fee-related rejection cases for @@ -395,14 +1156,19 @@ func TestSweepHtlc(t *testing.T) { ) defer cancel() - // Drain signer requests to avoid blocking. - go func() { - select { - case <-lnd.SignOutputRawChannel: + signer := tc.signer + if signer == nil { + signer = lnd.Signer - case <-ctx.Done(): - } - }() + // Drain signer requests to avoid blocking. + go func() { + select { + case <-lnd.SignOutputRawChannel: + + case <-ctx.Done(): + } + }() + } pubChan := make(chan *wire.MsgTx, 1) @@ -464,7 +1230,7 @@ func TestSweepHtlc(t *testing.T) { resp, err := sweepHtlc( ctx, req, lnd.ChainParams, store, lnd.ChainNotifier, lnd.WalletKit, - lnd.Signer, + signer, ) // Handle confirmation registration caused by the call diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index 04b35f1e5..b016ab3b8 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -1922,15 +1922,19 @@ type SweepHtlcRequest struct { SatPerVbyte uint32 `protobuf:"varint,2,opt,name=sat_per_vbyte,json=satPerVbyte,proto3" json:"sat_per_vbyte,omitempty"` // HTLC outpoint to sweep, formatted as "txid:vout". Outpoint string `protobuf:"bytes,3,opt,name=outpoint,proto3" json:"outpoint,omitempty"` - // Optional override for the stored swap preimage. + // Optional override for the stored swap preimage. This is required when + // stateless_recovery is set. Preimage []byte `protobuf:"bytes,4,opt,name=preimage,proto3" json:"preimage,omitempty"` // If true, publish the sweep transaction immediately. Publish bool `protobuf:"varint,5,opt,name=publish,proto3" json:"publish,omitempty"` // The HTLC address whose output is being swept; used to derive the // expected pkScript. - HtlcAddress string `protobuf:"bytes,6,opt,name=htlc_address,json=htlcAddress,proto3" json:"htlc_address,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + HtlcAddress string `protobuf:"bytes,6,opt,name=htlc_address,json=htlcAddress,proto3" json:"htlc_address,omitempty"` + // If set, reconstruct the latest Loop Out HTLC without querying the swap + // database. + StatelessRecovery *StatelessRecovery `protobuf:"bytes,7,opt,name=stateless_recovery,json=statelessRecovery,proto3" json:"stateless_recovery,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SweepHtlcRequest) Reset() { @@ -2005,6 +2009,13 @@ func (x *SweepHtlcRequest) GetHtlcAddress() string { return "" } +func (x *SweepHtlcRequest) GetStatelessRecovery() *StatelessRecovery { + if x != nil { + return x.StatelessRecovery + } + return nil +} + // SweepHtlcResponse returns the broadcast sweep transaction. type SweepHtlcResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -6716,6 +6727,93 @@ func (x *AssetLoopOutInfo) GetAssetCostOffchain() uint64 { return 0 } +// StatelessRecovery contains the public swap data needed to reconstruct the +// latest Loop Out HTLC without its database record. +type StatelessRecovery struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The server's compressed HTLC public key. + ServerPubkey []byte `protobuf:"bytes,1,opt,name=server_pubkey,json=serverPubkey,proto3" json:"server_pubkey,omitempty"` + // The client's compressed HTLC public key. The connected lnd wallet must + // be able to derive the corresponding private key. + ClientPubkey []byte `protobuf:"bytes,2,opt,name=client_pubkey,json=clientPubkey,proto3" json:"client_pubkey,omitempty"` + // The absolute CLTV expiry committed to by the Loop Out HTLC. + CltvExpiry int32 `protobuf:"varint,3,opt,name=cltv_expiry,json=cltvExpiry,proto3" json:"cltv_expiry,omitempty"` + // The block height at which the swap was initiated. This is used as the + // earliest possible confirmation height when locating the HTLC funding + // transaction. + SwapInitiationHeight int32 `protobuf:"varint,4,opt,name=swap_initiation_height,json=swapInitiationHeight,proto3" json:"swap_initiation_height,omitempty"` + // The maximum number of client keys to scan in Loop's lnd key family if + // signing by public key fails. If zero, loopd uses its default limit. + KeyScanLimit uint32 `protobuf:"varint,5,opt,name=key_scan_limit,json=keyScanLimit,proto3" json:"key_scan_limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatelessRecovery) Reset() { + *x = StatelessRecovery{} + mi := &file_client_proto_msgTypes[79] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatelessRecovery) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatelessRecovery) ProtoMessage() {} + +func (x *StatelessRecovery) ProtoReflect() protoreflect.Message { + mi := &file_client_proto_msgTypes[79] + 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 StatelessRecovery.ProtoReflect.Descriptor instead. +func (*StatelessRecovery) Descriptor() ([]byte, []int) { + return file_client_proto_rawDescGZIP(), []int{79} +} + +func (x *StatelessRecovery) GetServerPubkey() []byte { + if x != nil { + return x.ServerPubkey + } + return nil +} + +func (x *StatelessRecovery) GetClientPubkey() []byte { + if x != nil { + return x.ClientPubkey + } + return nil +} + +func (x *StatelessRecovery) GetCltvExpiry() int32 { + if x != nil { + return x.CltvExpiry + } + return 0 +} + +func (x *StatelessRecovery) GetSwapInitiationHeight() int32 { + if x != nil { + return x.SwapInitiationHeight + } + return 0 +} + +func (x *StatelessRecovery) GetKeyScanLimit() uint32 { + if x != nil { + return x.KeyScanLimit + } + return 0 +} + var File_client_proto protoreflect.FileDescriptor const file_client_proto_rawDesc = "" + @@ -6816,14 +6914,15 @@ const file_client_proto_rawDesc = "" + "\aLOOP_IN\x10\x02\"f\n" + "\x11ListSwapsResponse\x12)\n" + "\x05swaps\x18\x01 \x03(\v2\x13.looprpc.SwapStatusR\x05swaps\x12&\n" + - "\x0fnext_start_time\x18\x02 \x01(\x03R\rnextStartTime\"\xce\x01\n" + + "\x0fnext_start_time\x18\x02 \x01(\x03R\rnextStartTime\"\x99\x02\n" + "\x10SweepHtlcRequest\x12!\n" + "\fdest_address\x18\x01 \x01(\tR\vdestAddress\x12\"\n" + "\rsat_per_vbyte\x18\x02 \x01(\rR\vsatPerVbyte\x12\x1a\n" + "\boutpoint\x18\x03 \x01(\tR\boutpoint\x12\x1a\n" + "\bpreimage\x18\x04 \x01(\fR\bpreimage\x12\x18\n" + "\apublish\x18\x05 \x01(\bR\apublish\x12!\n" + - "\fhtlc_address\x18\x06 \x01(\tR\vhtlcAddress\"\x86\x02\n" + + "\fhtlc_address\x18\x06 \x01(\tR\vhtlcAddress\x12I\n" + + "\x12stateless_recovery\x18\a \x01(\v2\x1a.looprpc.StatelessRecoveryR\x11statelessRecovery\"\x86\x02\n" + "\x11SweepHtlcResponse\x12\x19\n" + "\bsweep_tx\x18\x01 \x01(\fR\asweepTx\x12\x19\n" + "\bfee_sats\x18\x02 \x01(\x04R\afeeSats\x12C\n" + @@ -7158,7 +7257,14 @@ const file_client_proto_rawDesc = "" + "\basset_id\x18\x01 \x01(\tR\aassetId\x12\x1d\n" + "\n" + "asset_name\x18\x02 \x01(\tR\tassetName\x12.\n" + - "\x13asset_cost_offchain\x18\x03 \x01(\x04R\x11assetCostOffchain*;\n" + + "\x13asset_cost_offchain\x18\x03 \x01(\x04R\x11assetCostOffchain\"\xda\x01\n" + + "\x11StatelessRecovery\x12#\n" + + "\rserver_pubkey\x18\x01 \x01(\fR\fserverPubkey\x12#\n" + + "\rclient_pubkey\x18\x02 \x01(\fR\fclientPubkey\x12\x1f\n" + + "\vcltv_expiry\x18\x03 \x01(\x05R\n" + + "cltvExpiry\x124\n" + + "\x16swap_initiation_height\x18\x04 \x01(\x05R\x14swapInitiationHeight\x12$\n" + + "\x0ekey_scan_limit\x18\x05 \x01(\rR\fkeyScanLimit*;\n" + "\vAddressType\x12\x18\n" + "\x14ADDRESS_TYPE_UNKNOWN\x10\x00\x12\x12\n" + "\x0eTAPROOT_PUBKEY\x10\x01*9\n" + @@ -7291,7 +7397,7 @@ func file_client_proto_rawDescGZIP() []byte { } var file_client_proto_enumTypes = make([]protoimpl.EnumInfo, 10) -var file_client_proto_msgTypes = make([]protoimpl.MessageInfo, 80) +var file_client_proto_msgTypes = make([]protoimpl.MessageInfo, 81) var file_client_proto_goTypes = []any{ (AddressType)(0), // 0: looprpc.AddressType (SwapType)(0), // 1: looprpc.SwapType @@ -7382,17 +7488,18 @@ var file_client_proto_goTypes = []any{ (*AssetRfqInfo)(nil), // 86: looprpc.AssetRfqInfo (*FixedPoint)(nil), // 87: looprpc.FixedPoint (*AssetLoopOutInfo)(nil), // 88: looprpc.AssetLoopOutInfo - nil, // 89: looprpc.LiquidityParameters.EasyAssetParamsEntry - (*lnrpc.OpenChannelRequest)(nil), // 90: lnrpc.OpenChannelRequest - (*swapserverrpc.RouteHint)(nil), // 91: looprpc.RouteHint - (*lnrpc.OutPoint)(nil), // 92: lnrpc.OutPoint + (*StatelessRecovery)(nil), // 89: looprpc.StatelessRecovery + nil, // 90: looprpc.LiquidityParameters.EasyAssetParamsEntry + (*lnrpc.OpenChannelRequest)(nil), // 91: lnrpc.OpenChannelRequest + (*swapserverrpc.RouteHint)(nil), // 92: looprpc.RouteHint + (*lnrpc.OutPoint)(nil), // 93: lnrpc.OutPoint } var file_client_proto_depIdxs = []int32{ - 90, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest + 91, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest 0, // 1: looprpc.LoopOutRequest.account_addr_type:type_name -> looprpc.AddressType 85, // 2: looprpc.LoopOutRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest 86, // 3: looprpc.LoopOutRequest.asset_rfq_info:type_name -> looprpc.AssetRfqInfo - 91, // 4: looprpc.LoopInRequest.route_hints:type_name -> looprpc.RouteHint + 92, // 4: looprpc.LoopInRequest.route_hints:type_name -> looprpc.RouteHint 1, // 5: looprpc.SwapStatus.type:type_name -> looprpc.SwapType 2, // 6: looprpc.SwapStatus.state:type_name -> looprpc.SwapState 8, // 7: looprpc.SwapStatus.static_loop_in_state:type_name -> looprpc.StaticAddressLoopInSwapState @@ -7401,116 +7508,117 @@ var file_client_proto_depIdxs = []int32{ 20, // 10: looprpc.ListSwapsRequest.list_swap_filter:type_name -> looprpc.ListSwapsFilter 9, // 11: looprpc.ListSwapsFilter.swap_type:type_name -> looprpc.ListSwapsFilter.SwapTypeFilter 18, // 12: looprpc.ListSwapsResponse.swaps:type_name -> looprpc.SwapStatus - 24, // 13: looprpc.SweepHtlcResponse.not_requested:type_name -> looprpc.PublishNotRequested - 25, // 14: looprpc.SweepHtlcResponse.published:type_name -> looprpc.PublishSucceeded - 26, // 15: looprpc.SweepHtlcResponse.failed:type_name -> looprpc.PublishFailed - 91, // 16: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint - 85, // 17: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest - 86, // 18: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo - 91, // 19: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint - 40, // 20: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token - 41, // 21: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats - 41, // 22: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats - 47, // 23: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule - 0, // 24: looprpc.LiquidityParameters.account_addr_type:type_name -> looprpc.AddressType - 89, // 25: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry - 4, // 26: looprpc.LiquidityParameters.loop_in_source:type_name -> looprpc.LoopInSource - 1, // 27: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType - 5, // 28: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType - 45, // 29: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters - 6, // 30: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason - 14, // 31: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest - 15, // 32: looprpc.SuggestSwapsResponse.loop_in:type_name -> looprpc.LoopInRequest - 83, // 33: looprpc.SuggestSwapsResponse.static_loop_in:type_name -> looprpc.StaticAddressLoopInRequest - 51, // 34: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified - 57, // 35: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation - 64, // 36: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut - 69, // 37: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo - 92, // 38: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint - 7, // 39: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState - 80, // 40: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit - 81, // 41: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal - 82, // 42: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap - 7, // 43: looprpc.Deposit.state:type_name -> looprpc.DepositState - 80, // 44: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit - 8, // 45: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState - 80, // 46: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit - 91, // 47: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint - 80, // 48: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit - 87, // 49: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint - 87, // 50: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint - 46, // 51: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams - 14, // 52: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest - 15, // 53: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest - 17, // 54: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest - 19, // 55: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest - 22, // 56: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest - 27, // 57: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest - 53, // 58: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest - 28, // 59: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest - 31, // 60: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest - 28, // 61: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest - 31, // 62: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest - 34, // 63: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest - 36, // 64: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest - 36, // 65: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest - 38, // 66: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest - 42, // 67: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest - 12, // 68: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest - 44, // 69: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest - 48, // 70: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest - 50, // 71: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest - 55, // 72: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest - 58, // 73: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest - 60, // 74: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest - 62, // 75: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest - 65, // 76: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest - 67, // 77: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest - 70, // 78: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest - 72, // 79: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest - 74, // 80: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest - 76, // 81: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest - 78, // 82: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest - 83, // 83: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest - 10, // 84: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest - 16, // 85: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse - 16, // 86: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse - 18, // 87: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus - 21, // 88: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse - 23, // 89: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse - 18, // 90: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus - 54, // 91: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse - 30, // 92: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse - 33, // 93: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse - 29, // 94: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse - 32, // 95: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse - 35, // 96: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse - 37, // 97: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse - 37, // 98: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse - 39, // 99: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse - 43, // 100: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse - 13, // 101: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse - 45, // 102: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters - 49, // 103: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse - 52, // 104: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse - 56, // 105: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse - 59, // 106: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse - 61, // 107: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse - 63, // 108: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse - 66, // 109: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse - 68, // 110: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse - 71, // 111: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse - 73, // 112: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse - 75, // 113: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse - 77, // 114: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse - 79, // 115: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse - 84, // 116: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse - 11, // 117: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse - 85, // [85:118] is the sub-list for method output_type - 52, // [52:85] is the sub-list for method input_type - 52, // [52:52] is the sub-list for extension type_name - 52, // [52:52] is the sub-list for extension extendee - 0, // [0:52] is the sub-list for field type_name + 89, // 13: looprpc.SweepHtlcRequest.stateless_recovery:type_name -> looprpc.StatelessRecovery + 24, // 14: looprpc.SweepHtlcResponse.not_requested:type_name -> looprpc.PublishNotRequested + 25, // 15: looprpc.SweepHtlcResponse.published:type_name -> looprpc.PublishSucceeded + 26, // 16: looprpc.SweepHtlcResponse.failed:type_name -> looprpc.PublishFailed + 92, // 17: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint + 85, // 18: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest + 86, // 19: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo + 92, // 20: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint + 40, // 21: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token + 41, // 22: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats + 41, // 23: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats + 47, // 24: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule + 0, // 25: looprpc.LiquidityParameters.account_addr_type:type_name -> looprpc.AddressType + 90, // 26: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry + 4, // 27: looprpc.LiquidityParameters.loop_in_source:type_name -> looprpc.LoopInSource + 1, // 28: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType + 5, // 29: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType + 45, // 30: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters + 6, // 31: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason + 14, // 32: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest + 15, // 33: looprpc.SuggestSwapsResponse.loop_in:type_name -> looprpc.LoopInRequest + 83, // 34: looprpc.SuggestSwapsResponse.static_loop_in:type_name -> looprpc.StaticAddressLoopInRequest + 51, // 35: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified + 57, // 36: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation + 64, // 37: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut + 69, // 38: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo + 93, // 39: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint + 7, // 40: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState + 80, // 41: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit + 81, // 42: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal + 82, // 43: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap + 7, // 44: looprpc.Deposit.state:type_name -> looprpc.DepositState + 80, // 45: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit + 8, // 46: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState + 80, // 47: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit + 92, // 48: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint + 80, // 49: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit + 87, // 50: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint + 87, // 51: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint + 46, // 52: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams + 14, // 53: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest + 15, // 54: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest + 17, // 55: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest + 19, // 56: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest + 22, // 57: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest + 27, // 58: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest + 53, // 59: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest + 28, // 60: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest + 31, // 61: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest + 28, // 62: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest + 31, // 63: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest + 34, // 64: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest + 36, // 65: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest + 36, // 66: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest + 38, // 67: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest + 42, // 68: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest + 12, // 69: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest + 44, // 70: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest + 48, // 71: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest + 50, // 72: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest + 55, // 73: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest + 58, // 74: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest + 60, // 75: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest + 62, // 76: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest + 65, // 77: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest + 67, // 78: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest + 70, // 79: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest + 72, // 80: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest + 74, // 81: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest + 76, // 82: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest + 78, // 83: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest + 83, // 84: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest + 10, // 85: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest + 16, // 86: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse + 16, // 87: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse + 18, // 88: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus + 21, // 89: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse + 23, // 90: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse + 18, // 91: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus + 54, // 92: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse + 30, // 93: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse + 33, // 94: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse + 29, // 95: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse + 32, // 96: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse + 35, // 97: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse + 37, // 98: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse + 37, // 99: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse + 39, // 100: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse + 43, // 101: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse + 13, // 102: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse + 45, // 103: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters + 49, // 104: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse + 52, // 105: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse + 56, // 106: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse + 59, // 107: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse + 61, // 108: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse + 63, // 109: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse + 66, // 110: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse + 68, // 111: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse + 71, // 112: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse + 73, // 113: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse + 75, // 114: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse + 77, // 115: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse + 79, // 116: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse + 84, // 117: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse + 11, // 118: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse + 86, // [86:119] is the sub-list for method output_type + 53, // [53:86] is the sub-list for method input_type + 53, // [53:53] is the sub-list for extension type_name + 53, // [53:53] is the sub-list for extension extendee + 0, // [0:53] is the sub-list for field type_name } func init() { file_client_proto_init() } @@ -7535,7 +7643,7 @@ func file_client_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_client_proto_rawDesc), len(file_client_proto_rawDesc)), NumEnums: 10, - NumMessages: 80, + NumMessages: 81, NumExtensions: 0, NumServices: 1, }, diff --git a/looprpc/client.proto b/looprpc/client.proto index f2a592f13..346d1460a 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -789,7 +789,8 @@ message SweepHtlcRequest { // HTLC outpoint to sweep, formatted as "txid:vout". string outpoint = 3; - // Optional override for the stored swap preimage. + // Optional override for the stored swap preimage. This is required when + // stateless_recovery is set. bytes preimage = 4; // If true, publish the sweep transaction immediately. @@ -798,6 +799,10 @@ message SweepHtlcRequest { // The HTLC address whose output is being swept; used to derive the // expected pkScript. string htlc_address = 6; + + // If set, reconstruct the latest Loop Out HTLC without querying the swap + // database. + StatelessRecovery stateless_recovery = 7; } // SweepHtlcResponse returns the broadcast sweep transaction. @@ -2514,3 +2519,26 @@ message AssetLoopOutInfo { */ uint64 asset_cost_offchain = 3; } + +// StatelessRecovery contains the public swap data needed to reconstruct the +// latest Loop Out HTLC without its database record. +message StatelessRecovery { + // The server's compressed HTLC public key. + bytes server_pubkey = 1; + + // The client's compressed HTLC public key. The connected lnd wallet must + // be able to derive the corresponding private key. + bytes client_pubkey = 2; + + // The absolute CLTV expiry committed to by the Loop Out HTLC. + int32 cltv_expiry = 3; + + // The block height at which the swap was initiated. This is used as the + // earliest possible confirmation height when locating the HTLC funding + // transaction. + int32 swap_initiation_height = 4; + + // The maximum number of client keys to scan in Loop's lnd key family if + // signing by public key fails. If zero, loopd uses its default limit. + uint32 key_scan_limit = 5; +} diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index 57d6dfd63..f82f4c7c2 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -2646,6 +2646,37 @@ "looprpcSetLiquidityParamsResponse": { "type": "object" }, + "looprpcStatelessRecovery": { + "type": "object", + "properties": { + "server_pubkey": { + "type": "string", + "format": "byte", + "description": "The server's compressed HTLC public key." + }, + "client_pubkey": { + "type": "string", + "format": "byte", + "description": "The client's compressed HTLC public key. The connected lnd wallet must\nbe able to derive the corresponding private key." + }, + "cltv_expiry": { + "type": "integer", + "format": "int32", + "description": "The absolute CLTV expiry committed to by the Loop Out HTLC." + }, + "swap_initiation_height": { + "type": "integer", + "format": "int32", + "description": "The block height at which the swap was initiated. This is used as the\nearliest possible confirmation height when locating the HTLC funding\ntransaction." + }, + "key_scan_limit": { + "type": "integer", + "format": "int64", + "description": "The maximum number of client keys to scan in Loop's lnd key family if\nsigning by public key fails. If zero, loopd uses its default limit." + } + }, + "description": "StatelessRecovery contains the public swap data needed to reconstruct the\nlatest Loop Out HTLC without its database record." + }, "looprpcStaticAddressLoopInRequest": { "type": "object", "properties": { @@ -3165,7 +3196,7 @@ "preimage": { "type": "string", "format": "byte", - "description": "Optional override for the stored swap preimage." + "description": "Optional override for the stored swap preimage. This is required when\nstateless_recovery is set." }, "publish": { "type": "boolean", @@ -3174,6 +3205,10 @@ "htlc_address": { "type": "string", "description": "The HTLC address whose output is being swept; used to derive the\nexpected pkScript." + }, + "stateless_recovery": { + "$ref": "#/definitions/looprpcStatelessRecovery", + "description": "If set, reconstruct the latest Loop Out HTLC without querying the swap\ndatabase." } }, "description": "SweepHtlcRequest instructs loopd to sweep a swap HTLC via its success path." From 44b49b3b67ec4d1b9c823a0fff6561d0ca2bca26 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 21 Aug 2026 18:20:54 +0000 Subject: [PATCH 2/2] loopd: add cooperative sweephtlc recovery Allow sweephtlc to request the existing server MuSig2 signing flow so an original Taproot Loop Out HTLC can be recovered without revealing its preimage. Stateful recovery obtains ownership proof from the stored invoice, while stateless recovery accepts it with the public swap data. Keep publishing opt-in and verify the combined signature and completed witness before returning the transaction. --- client.go | 13 + cmd/loop/sweephtlc.go | 52 ++- docs/loop.md | 6 +- docs/release-notes/release-notes-next.md | 3 +- loopd/htlc_confirmed_recovery.go | 1 + loopd/swapclient_server.go | 1 + loopd/sweep_htlc.go | 472 +++++++++++++++++----- loopd/sweep_htlc_test.go | 484 ++++++++++++++++++++++- looprpc/client.pb.go | 313 +++++++++------ looprpc/client.proto | 20 +- looprpc/client.swagger.json | 23 +- looprpc/client_grpc.pb.go | 8 +- 12 files changed, 1138 insertions(+), 258 deletions(-) diff --git a/client.go b/client.go index f947cd08e..62179dc2c 100644 --- a/client.go +++ b/client.go @@ -112,6 +112,19 @@ type Client struct { wg sync.WaitGroup } +// MuSig2SignSweep asks the swap server to cooperatively sign a Loop Out HTLC +// sweep transaction. +func (c *Client) MuSig2SignSweep(ctx context.Context, + protocolVersion loopdb.ProtocolVersion, swapHash lntypes.Hash, + paymentAddr [32]byte, nonce, sweepTxPsbt []byte) ([]byte, []byte, + error) { + + return c.Server.MuSig2SignSweep( + ctx, protocolVersion, swapHash, paymentAddr, nonce, + sweepTxPsbt, + ) +} + // ClientConfig is the exported configuration structure that is required to // instantiate the loop client. type ClientConfig struct { diff --git a/cmd/loop/sweephtlc.go b/cmd/loop/sweephtlc.go index d2298e123..5104094c2 100644 --- a/cmd/loop/sweephtlc.go +++ b/cmd/loop/sweephtlc.go @@ -11,10 +11,10 @@ import ( "github.com/urfave/cli/v3" ) -// sweepHtlcCommand exposes HTLC success-path sweeping over loop CLI. +// sweepHtlcCommand exposes HTLC sweeping over loop CLI. var sweepHtlcCommand = &cli.Command{ Name: "sweephtlc", - Usage: "sweep an HTLC output using the preimage success path", + Usage: "sweep an HTLC output using a preimage or cooperation", Description: "Supplying any stateless-recovery flag selects " + "stateless recovery mode. Both public keys, the preimage, " + "CLTV expiry, and swap initiation height must then be " + @@ -25,7 +25,10 @@ var sweepHtlcCommand = &cli.Command{ "sign using the client public key. If signing fails or the " + "signature does not verify, Loop scans the configured " + "number of keys in key family 99 and retries with the " + - "recovered key locator.", + "recovered key locator. The cooperative option instead " + + "requests a MuSig2 server signature and spends via the " + + "Taproot key path. Stateless cooperative recovery also " + + "requires the swap invoice payment address.", Flags: []cli.Flag{ &cli.StringFlag{ Name: "outpoint", @@ -77,6 +80,15 @@ var sweepHtlcCommand = &cli.Command{ Usage: "maximum family-99 keys to scan; zero uses " + "loopd's default", }, + &cli.BoolFlag{ + Name: "cooperative", + Usage: "request a cooperative MuSig2 key-path sweep", + }, + &cli.StringFlag{ + Name: "paymentaddr", + Usage: "swap invoice payment address; required for " + + "stateless cooperative recovery", + }, &cli.BoolFlag{ Name: "publish", Usage: "publish the sweep transaction immediately", @@ -167,6 +179,39 @@ func sweepHtlc(ctx context.Context, cmd *cli.Command) error { } } + cooperative := cmd.Bool("cooperative") + if cmd.IsSet("paymentaddr") && !cooperative { + return fmt.Errorf("--paymentaddr requires --cooperative") + } + + var cooperativeSweep *looprpc.CooperativeSweep + if cooperative { + var paymentAddr []byte + if cmd.IsSet("paymentaddr") { + paymentAddr, err = hex.DecodeString(cmd.String("paymentaddr")) + if err != nil { + return fmt.Errorf("invalid paymentaddr: %w", err) + } + if len(paymentAddr) != 32 { + return fmt.Errorf("paymentaddr must be 32 bytes") + } + } + + switch { + case stateless && len(paymentAddr) == 0: + return fmt.Errorf("--paymentaddr is required for stateless " + + "cooperative recovery") + + case !stateless && len(paymentAddr) != 0: + return fmt.Errorf("--paymentaddr is only used for " + + "stateless recovery") + } + + cooperativeSweep = &looprpc.CooperativeSweep{ + PaymentAddress: paymentAddr, + } + } + // Call SweepHtlc on loopd trying to sweep the HTLC. resp, err := client.SweepHtlc(ctx, &looprpc.SweepHtlcRequest{ Outpoint: cmd.String("outpoint"), @@ -176,6 +221,7 @@ func sweepHtlc(ctx context.Context, cmd *cli.Command) error { Preimage: preimage, Publish: cmd.Bool("publish"), StatelessRecovery: recovery, + Cooperative: cooperativeSweep, }) if err != nil { return err diff --git a/docs/loop.md b/docs/loop.md index 0897ebe7e..fc075b093 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -55,9 +55,9 @@ The following flags are supported: ### `out sweephtlc` subcommand -sweep an HTLC output using the preimage success path. +sweep an HTLC output using a preimage or cooperation. -Supplying any stateless-recovery flag selects stateless recovery mode. Both public keys, the preimage, CLTV expiry, and swap initiation height must then be supplied. In this mode, Loop reconstructs a protocol-11 Loop Out HTLC without querying its swap database. It verifies the reconstructed address against the requested address and the actual on-chain output, then asks lnd to sign using the client public key. If signing fails or the signature does not verify, Loop scans the configured number of keys in key family 99 and retries with the recovered key locator. +Supplying any stateless-recovery flag selects stateless recovery mode. Both public keys, the preimage, CLTV expiry, and swap initiation height must then be supplied. In this mode, Loop reconstructs a protocol-11 Loop Out HTLC without querying its swap database. It verifies the reconstructed address against the requested address and the actual on-chain output, then asks lnd to sign using the client public key. If signing fails or the signature does not verify, Loop scans the configured number of keys in key family 99 and retries with the recovered key locator. The cooperative option instead requests a MuSig2 server signature and spends via the Taproot key path. Stateless cooperative recovery also requires the swap invoice payment address. Usage: @@ -79,6 +79,8 @@ The following flags are supported: | `--cltvexpiry="…"` | absolute HTLC CLTV expiry for stateless recovery | int | `0` | | `--initiationheight="…"` | block height at which the swap was initiated; required for stateless recovery | int | `0` | | `--keyscanlimit="…"` | maximum family-99 keys to scan; zero uses loopd's default | uint | `0` | +| `--cooperative` | request a cooperative MuSig2 key-path sweep | bool | `false` | +| `--paymentaddr="…"` | swap invoice payment address; required for stateless cooperative recovery | string | | `--publish` | publish the sweep transaction immediately | bool | `false` | | `--help` (`-h`) | show help | bool | `false` | diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 8e85c9d51..ed1f73352 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -4,7 +4,8 @@ * The `loop out sweephtlc` recovery command can reconstruct and sweep a protocol-11 Loop Out HTLC from public swap data when the local swap database - record is unavailable. + record is unavailable. It can also request the server's cooperative MuSig2 + signature to spend an original Taproot HTLC through its key path. #### Breaking Changes diff --git a/loopd/htlc_confirmed_recovery.go b/loopd/htlc_confirmed_recovery.go index 03246bb1e..27000f56c 100644 --- a/loopd/htlc_confirmed_recovery.go +++ b/loopd/htlc_confirmed_recovery.go @@ -88,6 +88,7 @@ func (m *htlcConfirmedRecoveryManager) handleNotification(ctx context.Context, SatPerVbyte: ntfn.SatPerVbyte, Publish: true, }, m.chainParams, m.swapStore, m.notifier, m.wallet, m.signer, + nil, nil, ) if err != nil { debugf("Unable to recover HTLC outpoint %s: %v", outpoint, err) diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 497fe3a8e..72c3d7dd7 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1563,6 +1563,7 @@ func (s *swapClientServer) SweepHtlc(ctx context.Context, return sweepHtlc( ctx, req, s.lnd.ChainParams, s.impl.Store, s.lnd.ChainNotifier, s.lnd.WalletKit, s.lnd.Signer, + s.lnd.Signer, s.impl.MuSig2SignSweep, ) } diff --git a/loopd/sweep_htlc.go b/loopd/sweep_htlc.go index b88c7aa8e..f916a3353 100644 --- a/loopd/sweep_htlc.go +++ b/loopd/sweep_htlc.go @@ -6,6 +6,8 @@ import ( "fmt" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -83,12 +85,41 @@ type htlcSigner interface { prevOutputs []*wire.TxOut) ([][]byte, error) } -// sweepHtlc spends a Loop HTLC output using the success path and a known -// preimage. +// htlcMuSig2Signer contains the lnd signer operations needed for a +// cooperative HTLC sweep. +type htlcMuSig2Signer interface { + MuSig2CreateSession(ctx context.Context, version input.MuSig2Version, + signerLoc *keychain.KeyLocator, signers [][]byte, + opts ...lndclient.MuSig2SessionOpts) ( + *input.MuSig2SessionInfo, error) + + MuSig2RegisterNonces(ctx context.Context, sessionID [32]byte, + nonces [][musig2.PubNonceSize]byte) (bool, error) + + MuSig2Sign(ctx context.Context, sessionID [32]byte, + message [32]byte, cleanup bool) ([]byte, error) + + MuSig2CombineSig(ctx context.Context, sessionID [32]byte, + otherPartialSigs [][]byte) (bool, []byte, error) + + MuSig2Cleanup(ctx context.Context, sessionID [32]byte) error +} + +// htlcCooperativeSigner asks the swap server for its nonce and MuSig2 partial +// signature. +type htlcCooperativeSigner func(ctx context.Context, + protocolVersion loopdb.ProtocolVersion, swapHash lntypes.Hash, + paymentAddr [32]byte, nonce, sweepTxPsbt []byte) ([]byte, []byte, + error) + +// sweepHtlc spends a Loop HTLC output using the preimage success path or the +// cooperative key path. func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, chainParams *chaincfg.Params, store loopOutStore, notifier htlcChainNotifier, wallet htlcWallet, - signer htlcSigner) (*looprpc.SweepHtlcResponse, error) { + signer htlcSigner, muSig2Signer htlcMuSig2Signer, + cooperativeSigner htlcCooperativeSigner) ( + *looprpc.SweepHtlcResponse, error) { // Make sure that the request has all required inputs. if req.Outpoint == "" { @@ -106,6 +137,7 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, recovery := req.StatelessRecovery stateless := recovery != nil + cooperative := req.Cooperative != nil if stateless { if len(recovery.ServerPubkey) == 0 || len(recovery.ClientPubkey) == 0 { @@ -127,6 +159,21 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, return nil, status.Error(codes.InvalidArgument, "preimage required in stateless mode") } + if cooperative && len(req.Cooperative.PaymentAddress) != 32 { + return nil, status.Error(codes.InvalidArgument, + "32-byte payment_address required for stateless "+ + "cooperative recovery") + } + } + if !stateless && cooperative && + len(req.Cooperative.PaymentAddress) != 0 { + + return nil, status.Error(codes.InvalidArgument, + "payment_address is only used in stateless mode") + } + if cooperative && (muSig2Signer == nil || cooperativeSigner == nil) { + return nil, status.Error(codes.FailedPrecondition, + "cooperative signing is unavailable") } // Parse the inputs. @@ -172,6 +219,9 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, keyDesc keychain.KeyDescriptor clientKey *btcec.PublicKey keyScanLimit uint32 + htlcKeys loopdb.HtlcKeys + protocol loopdb.ProtocolVersion + paymentAddr [32]byte ) if stateless { @@ -214,6 +264,11 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, ReceiverInternalPubKey: clientKeyBytes, }, } + htlcKeys = contract.HtlcKeys + protocol = contract.ProtocolVersion + if cooperative { + copy(paymentAddr[:], req.Cooperative.PaymentAddress) + } targetHtlc, err = utils.GetHtlc( targetHash, &contract, chainParams, @@ -267,9 +322,25 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, targetHash = targetSwap.Hash heightHint = targetSwap.Contract.InitiationHeight storedDest = targetSwap.Contract.DestAddr + htlcKeys = targetSwap.Contract.HtlcKeys + protocol = targetSwap.Contract.ProtocolVersion keyDesc.KeyLocator = targetSwap.Contract.HtlcKeys. ClientScriptKeyLocator + if cooperative { + storedPaymentAddr, paymentErr := + utils.ObtainSwapPaymentAddr( + targetSwap.Contract.SwapInvoice, + chainParams, + ) + if paymentErr != nil { + return nil, status.Errorf(codes.Internal, + "obtain stored swap payment address: %v", + paymentErr) + } + paymentAddr = *storedPaymentAddr + } + if len(req.Preimage) > 0 { preimage, err = lntypes.MakePreimage(req.Preimage) if err != nil { @@ -331,8 +402,9 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, } var ( - htlcTxOut *wire.TxOut - fundingTx *wire.MsgTx + htlcTxOut *wire.TxOut + fundingTx *wire.MsgTx + fundingHeight uint32 ) infof("sweephtlc: waiting for confirmation of %v", req.Outpoint) @@ -344,6 +416,7 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, } fundingTx = conf.Tx + fundingHeight = conf.BlockHeight infof("sweephtlc: funding confirmed at height %v", conf.BlockHeight) @@ -402,7 +475,12 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, infof("sweephtlc: swap hash validated for %v", req.Outpoint) - if preimage.Hash() != targetHtlc.Hash { + if cooperative && targetHtlc.Version != swap.HtlcV3 { + return nil, status.Error(codes.InvalidArgument, + "cooperative sweeping requires a Taproot HTLC") + } + + if !cooperative && preimage.Hash() != targetHtlc.Hash { return nil, status.Error(codes.InvalidArgument, "preimage does not match HTLC hash") } @@ -410,12 +488,16 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, infof("sweephtlc: sweeping to %v with feerate %v sat/vbyte", sweepAddr.EncodeAddress(), req.SatPerVbyte) - // Estimate fee for the success-path spend weight. + // Estimate the fee for the selected spend path. var estimator input.TxWeightEstimator - err = targetHtlc.AddSuccessToEstimator(&estimator) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, - "failed to estimate tx input weight: %v", err) + if cooperative { + estimator.AddTaprootKeySpendInput(txscript.SigHashDefault) + } else { + err = targetHtlc.AddSuccessToEstimator(&estimator) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, + "failed to estimate tx input weight: %v", err) + } } err = sweep.AddOutputEstimate(&estimator, sweepAddr) if err != nil { @@ -455,135 +537,194 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, ) } - // Build the sweep transaction spending the HTLC via the success path. - sweepTx := wire.NewMsgTx(2) - sweepTx.AddTxIn(&wire.TxIn{ - PreviousOutPoint: *htlcOutpoint, - Sequence: targetHtlc.SuccessSequence(), - }) - sweepTx.AddTxOut(&wire.TxOut{ - PkScript: sweepPkScript, - Value: int64(htlcValue - fee), - }) - - infof("sweephtlc: signing sweep spending %v", req.Outpoint) - prevOut := &wire.TxOut{ Value: int64(htlcValue), PkScript: targetHtlc.PkScript, } - signDesc := lndclient.SignDescriptor{ - WitnessScript: targetHtlc.SuccessScript(), - Output: prevOut, - HashType: targetHtlc.SigHash(), - InputIndex: 0, - KeyDesc: keyDesc, - } - if targetHtlc.Version == swap.HtlcV3 { - signDesc.SignMethod = input.TaprootScriptSpendSignMethod - } - // Sign the HTLC spend. Stateless recovery first asks lnd to resolve - // the supplied public key from its wallet. A signing error or invalid - // signature triggers the bounded key-family scan. - usedKeyScan := false - scanAndSign := func() ([][]byte, error) { - recoveredKey, scanErr := findStatelessSweepKey( - ctx, wallet, clientKey, keyScanLimit, - ) - if scanErr != nil { - return nil, scanErr + var sweepTx *wire.MsgTx + if cooperative { + if stateless { + recoveredKey, scanErr := findStatelessSweepKey( + ctx, wallet, clientKey, keyScanLimit, + ) + if scanErr != nil { + return nil, scanErr + } + keyDesc = recoveredKey + infof("sweephtlc: using recovered client key locator for " + + "cooperative signing") } - signDesc.KeyDesc = recoveredKey - usedKeyScan = true - - return signer.SignOutputRawKeyLocator( - ctx, sweepTx, - []*lndclient.SignDescriptor{&signDesc}, - []*wire.TxOut{prevOut}, - ) - } + infof("sweephtlc: signing cooperative sweep spending %v", + req.Outpoint) - rawSigs, err := signer.SignOutputRaw( - ctx, sweepTx, []*lndclient.SignDescriptor{&signDesc}, - []*wire.TxOut{prevOut}, - ) - if err != nil { - if !stateless { - return nil, err + var lockTime uint32 + if fundingHeight > 0 { + lockTime = fundingHeight } - infof("sweephtlc: public-key signing failed: %v; "+ - "scanning up to %d family-%d keys", err, - keyScanLimit, swap.KeyFamily, + cooperativeSweeper := &sweep.Sweeper{} + var ( + psbtBytes []byte + sigHash []byte ) - rawSigs, err = scanAndSign() + sweepTx, psbtBytes, sigHash, err = cooperativeSweeper. + CreateUnsignedTaprootKeySpendSweepTx( + ctx, lockTime, targetHtlc, *htlcOutpoint, + htlcValue, fee, sweepAddr, + ) if err != nil { - return nil, err - } - } - - applySignature := func(signatures [][]byte) error { - if len(signatures) != 1 || len(signatures[0]) == 0 { - return status.Error(codes.Internal, - "signer returned an invalid signature count") + return nil, status.Errorf(codes.Internal, + "construct cooperative sweep: %v", err) } - witness, witnessErr := targetHtlc.GenSuccessWitness( - signatures[0], preimage, + finalSig, signErr := signCooperativeSweep( + ctx, muSig2Signer, cooperativeSigner, protocol, + targetHash, paymentAddr, htlcKeys, + keyDesc.KeyLocator, targetHtlc, sigHash, psbtBytes, ) - if witnessErr != nil { - return witnessErr + if signErr != nil { + return nil, signErr } + sweepTx.TxIn[0].Witness = wire.TxWitness{finalSig} - sweepTx.TxIn[0].Witness = witness + if err := verifySweepHtlcWitness(sweepTx, prevOut); err != nil { + return nil, status.Errorf(codes.Internal, + "verify cooperative sweep witness: %v", err) + } + infof("sweephtlc: cooperative sweep witness verified") + } else { + // Build the transaction spending the HTLC via its success path. + sweepTx = wire.NewMsgTx(2) + sweepTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: *htlcOutpoint, + Sequence: targetHtlc.SuccessSequence(), + }) + sweepTx.AddTxOut(&wire.TxOut{ + PkScript: sweepPkScript, + Value: int64(htlcValue - fee), + }) + + infof("sweephtlc: signing sweep spending %v", req.Outpoint) + + signDesc := lndclient.SignDescriptor{ + WitnessScript: targetHtlc.SuccessScript(), + Output: prevOut, + HashType: targetHtlc.SigHash(), + InputIndex: 0, + KeyDesc: keyDesc, + } + if targetHtlc.Version == swap.HtlcV3 { + signDesc.SignMethod = input.TaprootScriptSpendSignMethod + } + + // Sign the HTLC spend. Stateless recovery first asks lnd to + // resolve the supplied public key from its wallet. A signing + // error or invalid signature triggers the key-family scan. + usedKeyScan := false + scanAndSign := func() ([][]byte, error) { + recoveredKey, scanErr := findStatelessSweepKey( + ctx, wallet, clientKey, keyScanLimit, + ) + if scanErr != nil { + return nil, scanErr + } - return nil - } - if err = applySignature(rawSigs); err != nil { - return nil, err - } + signDesc.KeyDesc = recoveredKey + usedKeyScan = true - if stateless { - if usedKeyScan { - infof("sweephtlc: verifying stateless sweep witness " + - "after key recovery") - } else { - infof("sweephtlc: verifying stateless sweep witness") + return signer.SignOutputRawKeyLocator( + ctx, sweepTx, + []*lndclient.SignDescriptor{&signDesc}, + []*wire.TxOut{prevOut}, + ) } - verifyErr := verifySweepHtlcWitness(sweepTx, prevOut) - if verifyErr != nil && !usedKeyScan { - infof("sweephtlc: public-key signature did not match "+ - "client key; scanning up to %d family-%d keys", - keyScanLimit, swap.KeyFamily) + rawSigs, err := signer.SignOutputRaw( + ctx, sweepTx, []*lndclient.SignDescriptor{&signDesc}, + []*wire.TxOut{prevOut}, + ) + if err != nil { + if !stateless { + return nil, err + } + infof("sweephtlc: public-key signing failed: %v; "+ + "scanning up to %d family-%d keys", err, + keyScanLimit, swap.KeyFamily, + ) rawSigs, err = scanAndSign() if err != nil { return nil, err } - if err = applySignature(rawSigs); err != nil { - return nil, err + } + + applySignature := func(signatures [][]byte) error { + if len(signatures) != 1 || len(signatures[0]) == 0 { + return status.Error(codes.Internal, + "signer returned an invalid signature count") + } + + witness, witnessErr := targetHtlc.GenSuccessWitness( + signatures[0], preimage, + ) + if witnessErr != nil { + return witnessErr } - infof("sweephtlc: verifying stateless sweep witness " + - "after key recovery") - verifyErr = verifySweepHtlcWitness(sweepTx, prevOut) + sweepTx.TxIn[0].Witness = witness + + return nil } - if verifyErr != nil { - return nil, status.Errorf(codes.Internal, - "lnd produced an invalid signature for "+ - "client_pubkey: %v", verifyErr) + if err = applySignature(rawSigs); err != nil { + return nil, err } - infof("sweephtlc: stateless sweep witness verified") - if usedKeyScan { - infof("sweephtlc: signed with recovered client " + - "key locator") - } else { - infof("sweephtlc: signed directly with client " + - "public key") + if stateless { + if usedKeyScan { + infof("sweephtlc: verifying stateless sweep " + + "witness after key recovery") + } else { + infof("sweephtlc: verifying stateless sweep " + + "witness") + } + + verifyErr := verifySweepHtlcWitness(sweepTx, prevOut) + if verifyErr != nil && !usedKeyScan { + infof("sweephtlc: public-key signature did not "+ + "match client key; scanning up to %d "+ + "family-%d keys", keyScanLimit, + swap.KeyFamily) + + rawSigs, err = scanAndSign() + if err != nil { + return nil, err + } + if err = applySignature(rawSigs); err != nil { + return nil, err + } + + infof("sweephtlc: verifying stateless sweep " + + "witness after key recovery") + verifyErr = verifySweepHtlcWitness( + sweepTx, prevOut, + ) + } + if verifyErr != nil { + return nil, status.Errorf(codes.Internal, + "lnd produced an invalid signature for "+ + "client_pubkey: %v", verifyErr) + } + infof("sweephtlc: stateless sweep witness verified") + + if usedKeyScan { + infof("sweephtlc: signed with recovered client " + + "key locator") + } else { + infof("sweephtlc: signed directly with client " + + "public key") + } } } @@ -639,6 +780,119 @@ func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, return resp, nil } +// signCooperativeSweep creates a client MuSig2 session, obtains the server's +// partial signature and returns the verified combined Schnorr signature. +func signCooperativeSweep(ctx context.Context, signer htlcMuSig2Signer, + serverSigner htlcCooperativeSigner, + protocolVersion loopdb.ProtocolVersion, swapHash lntypes.Hash, + paymentAddr [32]byte, htlcKeys loopdb.HtlcKeys, + clientKeyLocator keychain.KeyLocator, htlc *swap.Htlc, + sigHash, sweepTxPsbt []byte) ([]byte, error) { + + htlcV3, ok := htlc.HtlcScript.(*swap.HtlcScriptV3) + if !ok { + return nil, fmt.Errorf("cooperative sweep requires HTLC v3") + } + if len(sigHash) != chainhash.HashSize { + return nil, fmt.Errorf("invalid cooperative sighash length: %d", + len(sigHash)) + } + + var ( + muSig2Version input.MuSig2Version + signers [][]byte + ) + if protocolVersion >= loopdb.ProtocolVersionMuSig2 { + muSig2Version = input.MuSig2Version100RC2 + signers = [][]byte{ + htlcKeys.SenderInternalPubKey[:], + htlcKeys.ReceiverInternalPubKey[:], + } + } else { + muSig2Version = input.MuSig2Version040 + signers = [][]byte{ + htlcKeys.SenderInternalPubKey[1:], + htlcKeys.ReceiverInternalPubKey[1:], + } + } + + session, err := signer.MuSig2CreateSession( + ctx, muSig2Version, &clientKeyLocator, signers, + lndclient.MuSig2TaprootTweakOpt( + htlcV3.RootHash[:], false, + ), + ) + if err != nil { + return nil, fmt.Errorf("create client MuSig2 session: %w", err) + } + + cleanupSession := true + defer func() { + if cleanupSession { + _ = signer.MuSig2Cleanup(ctx, session.SessionID) + } + }() + + serverNonce, serverSig, err := serverSigner( + ctx, protocolVersion, swapHash, paymentAddr, + session.PublicNonce[:], sweepTxPsbt, + ) + if err != nil { + return nil, fmt.Errorf("server cooperative signing: %w", err) + } + if len(serverNonce) != musig2.PubNonceSize { + return nil, fmt.Errorf("invalid server nonce length: %d", + len(serverNonce)) + } + if len(serverSig) != input.MuSig2PartialSigSize { + return nil, fmt.Errorf("invalid server partial signature length: %d", + len(serverSig)) + } + + var otherNonce [musig2.PubNonceSize]byte + copy(otherNonce[:], serverNonce) + haveAllNonces, err := signer.MuSig2RegisterNonces( + ctx, session.SessionID, + [][musig2.PubNonceSize]byte{otherNonce}, + ) + if err != nil { + return nil, fmt.Errorf("register server MuSig2 nonce: %w", err) + } + if !haveAllNonces { + return nil, fmt.Errorf("MuSig2 session is missing nonces") + } + + var digest [chainhash.HashSize]byte + copy(digest[:], sigHash) + _, err = signer.MuSig2Sign( + ctx, session.SessionID, digest, false, + ) + if err != nil { + return nil, fmt.Errorf("create client MuSig2 signature: %w", err) + } + + haveAllSigs, finalSig, err := signer.MuSig2CombineSig( + ctx, session.SessionID, [][]byte{serverSig}, + ) + if err != nil { + return nil, fmt.Errorf("combine MuSig2 signatures: %w", err) + } + if !haveAllSigs { + return nil, fmt.Errorf("MuSig2 session is missing signatures") + } + cleanupSession = false + + parsedSig, err := schnorr.ParseSignature(finalSig) + if err != nil { + return nil, fmt.Errorf("parse cooperative signature: %w", err) + } + if !parsedSig.Verify(sigHash, htlcV3.TaprootKey) { + return nil, fmt.Errorf("cooperative signature does not verify") + } + + return finalSig, nil +} + // findStatelessSweepKey locates the client key in the Loop key family so lnd // receives the actual key locator when signing. func findStatelessSweepKey(ctx context.Context, wallet htlcWallet, diff --git a/loopd/sweep_htlc_test.go b/loopd/sweep_htlc_test.go index 9257dfd89..bb104dec1 100644 --- a/loopd/sweep_htlc_test.go +++ b/loopd/sweep_htlc_test.go @@ -9,7 +9,9 @@ import ( "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" @@ -22,10 +24,13 @@ import ( "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc/signrpc" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/zpay32" "github.com/stretchr/testify/require" ) @@ -454,6 +459,31 @@ func TestSweepHtlcStatelessValidation(t *testing.T) { }, expected: "invalid client_pubkey", }, + { + name: "cooperative payment address missing", + modify: func(req *looprpc.SweepHtlcRequest) { + req.Cooperative = &looprpc.CooperativeSweep{} + }, + expected: "32-byte payment_address required", + }, + { + name: "cooperative payment address length", + modify: func(req *looprpc.SweepHtlcRequest) { + req.Cooperative = &looprpc.CooperativeSweep{ + PaymentAddress: make([]byte, 31), + } + }, + expected: "32-byte payment_address required", + }, + { + name: "cooperative signing unavailable", + modify: func(req *looprpc.SweepHtlcRequest) { + req.Cooperative = &looprpc.CooperativeSweep{ + PaymentAddress: make([]byte, 32), + } + }, + expected: "cooperative signing is unavailable", + }, } store := &rejectingLoopOutStore{} @@ -465,16 +495,216 @@ func TestSweepHtlcStatelessValidation(t *testing.T) { _, err := sweepHtlc( t.Context(), req, lnd.ChainParams, store, lnd.ChainNotifier, lnd.WalletKit, - &realSweepSigner{}, + &realSweepSigner{}, nil, nil, ) require.ErrorContains(t, err, testCase.expected) }) } + require.Zero(t, store.calls) + + statefulRequest := newRequest() + statefulRequest.StatelessRecovery = nil + statefulRequest.Cooperative = &looprpc.CooperativeSweep{ + PaymentAddress: make([]byte, 32), + } + _, err = sweepHtlc( + t.Context(), statefulRequest, lnd.ChainParams, store, + lnd.ChainNotifier, lnd.WalletKit, &realSweepSigner{}, nil, + nil, + ) + require.ErrorContains( + t, err, "payment_address is only used in stateless mode", + ) require.Zero(t, store.calls) require.NoError(t, lnd.IsDone()) } +// TestSweepHtlcCooperative exercises both database-backed and stateless +// cooperative sweeps with two real MuSig2 signers. +func TestSweepHtlcCooperative(t *testing.T) { + defer test.Guard(t)() + setLogger(btclog.Disabled) + + const clientKeyIndex = int32(7) + + testCases := []struct { + name string + stateless bool + }{ + { + name: "stateful", + }, + { + name: "stateless", + stateless: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + lnd := test.NewMockLnd() + preimage := lntypes.Preimage{21, 22, 23, 24} + swapHash := preimage.Hash() + paymentAddr := [32]byte{31, 32, 33, 34} + + serverPrivKey, serverPubKey := test.CreateKey(8) + clientPrivKey, clientPubKey := test.CreateKey( + clientKeyIndex, + ) + serverLocator := keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.KeyFamily), + Index: 8, + } + clientLocator := keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.KeyFamily), + Index: uint32(clientKeyIndex), + } + + var serverKey, clientKey [33]byte + copy(serverKey[:], serverPubKey.SerializeCompressed()) + copy(clientKey[:], clientPubKey.SerializeCompressed()) + htlcKeys := loopdb.HtlcKeys{ + SenderScriptKey: serverKey, + SenderInternalPubKey: serverKey, + ReceiverScriptKey: clientKey, + ReceiverInternalPubKey: clientKey, + ClientScriptKeyLocator: clientLocator, + } + contract := loopdb.SwapContract{ + Preimage: preimage, + AmountRequested: 100_000, + HtlcKeys: htlcKeys, + CltvExpiry: 500, + InitiationHeight: 123, + ProtocolVersion: loopdb.ProtocolVersionMuSig2, + } + htlc, err := utils.GetHtlc( + swapHash, &contract, lnd.ChainParams, + ) + require.NoError(t, err) + + fundingTx := wire.NewMsgTx(2) + fundingTx.AddTxOut(&wire.TxOut{ + Value: 100_000, + PkScript: htlc.PkScript, + }) + outpoint := wire.OutPoint{Hash: fundingTx.TxHash()} + destAddr, err := btcutil.NewAddressWitnessPubKeyHash( + bytes.Repeat([]byte{5}, 20), lnd.ChainParams, + ) + require.NoError(t, err) + + clientSigner := newRealMuSig2TestSigner( + t, clientPrivKey, clientLocator, + ) + serverSigner := newRealMuSig2TestSigner( + t, serverPrivKey, serverLocator, + ) + cooperativeSigner := newRealCooperativeServerSigner( + t, serverSigner, serverLocator, + loopdb.ProtocolVersionMuSig2, swapHash, + paymentAddr, htlcKeys, htlc, + ) + + request := &looprpc.SweepHtlcRequest{ + Outpoint: outpoint.String(), + HtlcAddress: htlc.Address.EncodeAddress(), + DestAddress: destAddr.EncodeAddress(), + SatPerVbyte: 10, + Cooperative: &looprpc.CooperativeSweep{}, + } + var ( + store loopOutStore + wallet htlcWallet = lnd.WalletKit + ) + if testCase.stateless { + rejectingStore := &rejectingLoopOutStore{} + countingWallet := &countingSweepWallet{ + htlcWallet: lnd.WalletKit, + } + store = rejectingStore + wallet = countingWallet + request.Preimage = preimage[:] + request.StatelessRecovery = &looprpc.StatelessRecovery{ + ServerPubkey: serverKey[:], + ClientPubkey: clientKey[:], + CltvExpiry: contract.CltvExpiry, + SwapInitiationHeight: contract. + InitiationHeight, + KeyScanLimit: uint32(clientKeyIndex + 1), + } + request.Cooperative.PaymentAddress = paymentAddr[:] + defer func() { + require.Zero(t, rejectingStore.calls) + require.Equal( + t, int(clientKeyIndex+1), + countingWallet.deriveCalls, + ) + }() + } else { + invoice, err := zpay32.NewInvoice( + lnd.ChainParams, swapHash, time.Now(), + zpay32.Description("cooperative sweep"), + zpay32.Amount(lnwire.NewMSatFromSatoshis( + 100_000, + )), + zpay32.PaymentAddr(paymentAddr), + ) + require.NoError(t, err) + payReq, err := test.EncodePayReq(invoice) + require.NoError(t, err) + + storeMock := loopdb.NewStoreMock(t) + storeMock.LoopOutSwaps[swapHash] = + &loopdb.LoopOutContract{ + SwapContract: contract, + DestAddr: destAddr, + SwapInvoice: payReq, + } + store = storeMock + } + + ctx, cancel := context.WithTimeout( + t.Context(), 5*time.Second, + ) + defer cancel() + go sendSweepConfirmation(ctx, lnd, fundingTx) + + response, err := sweepHtlc( + ctx, request, lnd.ChainParams, store, + lnd.ChainNotifier, wallet, nil, + clientSigner, cooperativeSigner.sign, + ) + require.NoError(t, err) + require.NotNil(t, response.GetNotRequested()) + require.Equal(t, 1, cooperativeSigner.calls) + + var sweepTx wire.MsgTx + err = sweepTx.Deserialize(bytes.NewReader(response.SweepTx)) + require.NoError(t, err) + require.Len(t, sweepTx.TxIn, 1) + require.Len(t, sweepTx.TxIn[0].Witness, 1) + require.Len(t, sweepTx.TxIn[0].Witness[0], 64) + require.NoError(t, verifySweepHtlcWitness( + &sweepTx, fundingTx.TxOut[0], + )) + require.Equal( + t, int64(100_000-response.FeeSats), + sweepTx.TxOut[0].Value, + ) + + select { + case <-lnd.TxPublishChannel: + t.Fatal("unexpected publish") + + case <-time.After(100 * time.Millisecond): + } + require.NoError(t, lnd.IsDone()) + }) + } +} + // TestSweepHtlcStateless exercises public-key signing and the bounded key scan // fallback with real Schnorr signatures and script execution. func TestSweepHtlcStateless(t *testing.T) { @@ -616,7 +846,7 @@ func TestSweepHtlcStateless(t *testing.T) { resp, err := sweepHtlc( ctx, request, lnd.ChainParams, store, lnd.ChainNotifier, - wallet, signer, + wallet, signer, nil, nil, ) if testCase.expectErr != "" { require.ErrorContains( @@ -736,7 +966,7 @@ func TestSweepHtlcStatelessRejectsInvalidSignature(t *testing.T) { lnd.ChainNotifier, lnd.WalletKit, &realSweepSigner{ privateKey: wrongPrivKey, expectedPubKey: clientPubKey, - }, + }, nil, nil, ) require.ErrorContains(t, err, "invalid signature for client_pubkey") require.NoError(t, lnd.IsDone()) @@ -782,7 +1012,7 @@ func TestSweepHtlcStatelessAddressMismatch(t *testing.T) { SwapInitiationHeight: 123, }, }, lnd.ChainParams, store, lnd.ChainNotifier, - lnd.WalletKit, &realSweepSigner{}, + lnd.WalletKit, &realSweepSigner{}, nil, nil, ) require.ErrorContains( t, err, providedHtlc.Address.EncodeAddress(), @@ -845,7 +1075,7 @@ func TestSweepHtlcStatelessOnChainAddressMismatch(t *testing.T) { SwapInitiationHeight: 123, }, }, lnd.ChainParams, store, lnd.ChainNotifier, - lnd.WalletKit, &realSweepSigner{}, + lnd.WalletKit, &realSweepSigner{}, nil, nil, ) require.ErrorContains(t, err, observedAddr.EncodeAddress()) require.ErrorContains(t, err, htlc.Address.EncodeAddress()) @@ -942,6 +1172,248 @@ type realSweepSigner struct { keyLocatorCalls int } +// realMuSig2TestSigner adapts lnd's in-memory MuSig2 session manager to the +// lndclient signer interface. The manager performs all nonce and signature +// operations with the configured real private key. +type realMuSig2TestSigner struct { + manager *input.MusigSessionManager +} + +// newRealMuSig2TestSigner creates a real MuSig2 signer for one key locator. +func newRealMuSig2TestSigner(t *testing.T, privateKey *btcec.PrivateKey, + locator keychain.KeyLocator) *realMuSig2TestSigner { + + t.Helper() + + keyFetcher := func(keyDesc *keychain.KeyDescriptor) ( + *btcec.PrivateKey, error) { + + if keyDesc.KeyLocator != locator { + return nil, fmt.Errorf("unexpected key locator: %v", + keyDesc.KeyLocator) + } + + return privateKey, nil + } + + return &realMuSig2TestSigner{ + manager: input.NewMusigSessionManager(keyFetcher), + } +} + +// MuSig2CreateSession creates a real signing session. +func (s *realMuSig2TestSigner) MuSig2CreateSession(_ context.Context, + version input.MuSig2Version, signerLoc *keychain.KeyLocator, + signers [][]byte, opts ...lndclient.MuSig2SessionOpts) ( + *input.MuSig2SessionInfo, error) { + + parsedSigners, err := input.MuSig2ParsePubKeys(version, signers) + if err != nil { + return nil, err + } + + request := &signrpc.MuSig2SessionRequest{} + for _, opt := range opts { + opt(request) + } + + tweaks := &input.MuSig2Tweaks{} + if request.TaprootTweak != nil { + if request.TaprootTweak.KeySpendOnly { + tweaks.TaprootBIP0086Tweak = true + } else { + tweaks.TaprootTweak = request.TaprootTweak.ScriptRoot + } + } + + nonces := make( + [][musig2.PubNonceSize]byte, + len(request.OtherSignerPublicNonces), + ) + for i, rawNonce := range request.OtherSignerPublicNonces { + if len(rawNonce) != musig2.PubNonceSize { + return nil, fmt.Errorf("invalid nonce length: %d", + len(rawNonce)) + } + + copy(nonces[i][:], rawNonce) + } + + return s.manager.MuSig2CreateSession( + version, *signerLoc, parsedSigners, tweaks, nonces, nil, + ) +} + +// MuSig2RegisterNonces registers real public nonces with a session. +func (s *realMuSig2TestSigner) MuSig2RegisterNonces(_ context.Context, + sessionID [32]byte, nonces [][musig2.PubNonceSize]byte) ( + bool, error) { + + return s.manager.MuSig2RegisterNonces( + input.MuSig2SessionID(sessionID), nonces, + ) +} + +// MuSig2Sign creates a real partial MuSig2 signature. +func (s *realMuSig2TestSigner) MuSig2Sign(_ context.Context, + sessionID [32]byte, message [32]byte, cleanup bool) ([]byte, error) { + + partialSig, err := s.manager.MuSig2Sign( + input.MuSig2SessionID(sessionID), message, cleanup, + ) + if err != nil { + return nil, err + } + + serialized, err := input.SerializePartialSignature(partialSig) + if err != nil { + return nil, err + } + + return serialized[:], nil +} + +// MuSig2CombineSig combines real partial signatures into a Schnorr signature. +func (s *realMuSig2TestSigner) MuSig2CombineSig(_ context.Context, + sessionID [32]byte, otherPartialSigs [][]byte) (bool, []byte, error) { + + partialSigs := make( + []*musig2.PartialSignature, len(otherPartialSigs), + ) + for i, serialized := range otherPartialSigs { + partialSig, err := input.DeserializePartialSignature(serialized) + if err != nil { + return false, nil, err + } + + partialSigs[i] = partialSig + } + + finalSig, haveAllSigs, err := s.manager.MuSig2CombineSig( + input.MuSig2SessionID(sessionID), partialSigs, + ) + if err != nil || finalSig == nil { + return haveAllSigs, nil, err + } + + return haveAllSigs, finalSig.Serialize(), nil +} + +// MuSig2Cleanup removes a real signing session. +func (s *realMuSig2TestSigner) MuSig2Cleanup(_ context.Context, + sessionID [32]byte) error { + + return s.manager.MuSig2Cleanup(input.MuSig2SessionID(sessionID)) +} + +// realCooperativeServerSigner emulates the existing server signing RPC with a +// second real MuSig2 signer. +type realCooperativeServerSigner struct { + t *testing.T + signer *realMuSig2TestSigner + locator keychain.KeyLocator + protocolVersion loopdb.ProtocolVersion + swapHash lntypes.Hash + paymentAddr [32]byte + htlcKeys loopdb.HtlcKeys + htlc *swap.Htlc + calls int +} + +// newRealCooperativeServerSigner creates a real cooperative server signer. +func newRealCooperativeServerSigner(t *testing.T, + signer *realMuSig2TestSigner, locator keychain.KeyLocator, + protocolVersion loopdb.ProtocolVersion, swapHash lntypes.Hash, + paymentAddr [32]byte, htlcKeys loopdb.HtlcKeys, + htlc *swap.Htlc) *realCooperativeServerSigner { + + t.Helper() + + return &realCooperativeServerSigner{ + t: t, + signer: signer, + locator: locator, + protocolVersion: protocolVersion, + swapHash: swapHash, + paymentAddr: paymentAddr, + htlcKeys: htlcKeys, + htlc: htlc, + } +} + +// sign validates the server request and creates a real server partial +// signature over the PSBT transaction. +func (s *realCooperativeServerSigner) sign(ctx context.Context, + protocolVersion loopdb.ProtocolVersion, swapHash lntypes.Hash, + paymentAddr [32]byte, clientNonce, sweepTxPsbt []byte) ( + []byte, []byte, error) { + + s.calls++ + require.Equal(s.t, s.protocolVersion, protocolVersion) + require.Equal(s.t, s.swapHash, swapHash) + require.Equal(s.t, s.paymentAddr, paymentAddr) + require.Len(s.t, clientNonce, musig2.PubNonceSize) + + packet, err := psbt.NewFromRawBytes( + bytes.NewReader(sweepTxPsbt), false, + ) + require.NoError(s.t, err) + require.Len(s.t, packet.Inputs, 1) + require.NotNil(s.t, packet.Inputs[0].WitnessUtxo) + + prevOut := packet.Inputs[0].WitnessUtxo + prevOutFetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes( + packet.UnsignedTx, prevOutFetcher, + ) + sigHash, err := txscript.CalcTaprootSignatureHash( + sigHashes, txscript.SigHashDefault, packet.UnsignedTx, 0, + prevOutFetcher, + ) + require.NoError(s.t, err) + + htlcV3, ok := s.htlc.HtlcScript.(*swap.HtlcScriptV3) + require.True(s.t, ok) + var nonce [musig2.PubNonceSize]byte + copy(nonce[:], clientNonce) + + muSig2Version := input.MuSig2Version100RC2 + signers := [][]byte{ + s.htlcKeys.SenderInternalPubKey[:], + s.htlcKeys.ReceiverInternalPubKey[:], + } + if protocolVersion < loopdb.ProtocolVersionMuSig2 { + muSig2Version = input.MuSig2Version040 + signers = [][]byte{ + s.htlcKeys.SenderInternalPubKey[1:], + s.htlcKeys.ReceiverInternalPubKey[1:], + } + } + + session, err := s.signer.MuSig2CreateSession( + ctx, muSig2Version, &s.locator, signers, + lndclient.MuSig2TaprootTweakOpt( + htlcV3.RootHash[:], false, + ), + lndclient.MuSig2NonceOpt( + [][musig2.PubNonceSize]byte{nonce}, + ), + ) + require.NoError(s.t, err) + require.True(s.t, session.HaveAllNonces) + + var message [32]byte + copy(message[:], sigHash) + partialSig, err := s.signer.MuSig2Sign( + ctx, session.SessionID, message, true, + ) + require.NoError(s.t, err) + + return session.PublicNonce[:], partialSig, nil +} + // emptySweepSigner returns an invalid empty signature response. type emptySweepSigner struct{} @@ -1230,7 +1702,7 @@ func TestSweepHtlc(t *testing.T) { resp, err := sweepHtlc( ctx, req, lnd.ChainParams, store, lnd.ChainNotifier, lnd.WalletKit, - signer, + signer, nil, nil, ) // Handle confirmation registration caused by the call diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index b016ab3b8..923a87c38 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -1912,7 +1912,7 @@ func (x *ListSwapsResponse) GetNextStartTime() int64 { return 0 } -// SweepHtlcRequest instructs loopd to sweep a swap HTLC via its success path. +// SweepHtlcRequest instructs loopd to sweep a swap HTLC. type SweepHtlcRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Optional override for the sweep destination; defaults to a new address @@ -1933,8 +1933,11 @@ type SweepHtlcRequest struct { // If set, reconstruct the latest Loop Out HTLC without querying the swap // database. StatelessRecovery *StatelessRecovery `protobuf:"bytes,7,opt,name=stateless_recovery,json=statelessRecovery,proto3" json:"stateless_recovery,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // If set, request the server's MuSig2 signature and spend the HTLC via its + // cooperative key path instead of revealing the preimage. + Cooperative *CooperativeSweep `protobuf:"bytes,8,opt,name=cooperative,proto3" json:"cooperative,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SweepHtlcRequest) Reset() { @@ -2016,7 +2019,14 @@ func (x *SweepHtlcRequest) GetStatelessRecovery() *StatelessRecovery { return nil } -// SweepHtlcResponse returns the broadcast sweep transaction. +func (x *SweepHtlcRequest) GetCooperative() *CooperativeSweep { + if x != nil { + return x.Cooperative + } + return nil +} + +// SweepHtlcResponse returns the signed sweep transaction and publish outcome. type SweepHtlcResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Raw sweep transaction bytes. @@ -6814,6 +6824,54 @@ func (x *StatelessRecovery) GetKeyScanLimit() uint32 { return 0 } +// CooperativeSweep selects a cooperative MuSig2 key-path spend. +type CooperativeSweep struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The swap invoice payment address used to prove ownership to the server. + // Stateful mode obtains this value from the stored swap invoice. Stateless + // mode requires it here. + PaymentAddress []byte `protobuf:"bytes,1,opt,name=payment_address,json=paymentAddress,proto3" json:"payment_address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CooperativeSweep) Reset() { + *x = CooperativeSweep{} + mi := &file_client_proto_msgTypes[80] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CooperativeSweep) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CooperativeSweep) ProtoMessage() {} + +func (x *CooperativeSweep) ProtoReflect() protoreflect.Message { + mi := &file_client_proto_msgTypes[80] + 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 CooperativeSweep.ProtoReflect.Descriptor instead. +func (*CooperativeSweep) Descriptor() ([]byte, []int) { + return file_client_proto_rawDescGZIP(), []int{80} +} + +func (x *CooperativeSweep) GetPaymentAddress() []byte { + if x != nil { + return x.PaymentAddress + } + return nil +} + var File_client_proto protoreflect.FileDescriptor const file_client_proto_rawDesc = "" + @@ -6914,7 +6972,7 @@ const file_client_proto_rawDesc = "" + "\aLOOP_IN\x10\x02\"f\n" + "\x11ListSwapsResponse\x12)\n" + "\x05swaps\x18\x01 \x03(\v2\x13.looprpc.SwapStatusR\x05swaps\x12&\n" + - "\x0fnext_start_time\x18\x02 \x01(\x03R\rnextStartTime\"\x99\x02\n" + + "\x0fnext_start_time\x18\x02 \x01(\x03R\rnextStartTime\"\xd6\x02\n" + "\x10SweepHtlcRequest\x12!\n" + "\fdest_address\x18\x01 \x01(\tR\vdestAddress\x12\"\n" + "\rsat_per_vbyte\x18\x02 \x01(\rR\vsatPerVbyte\x12\x1a\n" + @@ -6922,7 +6980,8 @@ const file_client_proto_rawDesc = "" + "\bpreimage\x18\x04 \x01(\fR\bpreimage\x12\x18\n" + "\apublish\x18\x05 \x01(\bR\apublish\x12!\n" + "\fhtlc_address\x18\x06 \x01(\tR\vhtlcAddress\x12I\n" + - "\x12stateless_recovery\x18\a \x01(\v2\x1a.looprpc.StatelessRecoveryR\x11statelessRecovery\"\x86\x02\n" + + "\x12stateless_recovery\x18\a \x01(\v2\x1a.looprpc.StatelessRecoveryR\x11statelessRecovery\x12;\n" + + "\vcooperative\x18\b \x01(\v2\x19.looprpc.CooperativeSweepR\vcooperative\"\x86\x02\n" + "\x11SweepHtlcResponse\x12\x19\n" + "\bsweep_tx\x18\x01 \x01(\fR\asweepTx\x12\x19\n" + "\bfee_sats\x18\x02 \x01(\x04R\afeeSats\x12C\n" + @@ -7264,7 +7323,9 @@ const file_client_proto_rawDesc = "" + "\vcltv_expiry\x18\x03 \x01(\x05R\n" + "cltvExpiry\x124\n" + "\x16swap_initiation_height\x18\x04 \x01(\x05R\x14swapInitiationHeight\x12$\n" + - "\x0ekey_scan_limit\x18\x05 \x01(\rR\fkeyScanLimit*;\n" + + "\x0ekey_scan_limit\x18\x05 \x01(\rR\fkeyScanLimit\";\n" + + "\x10CooperativeSweep\x12'\n" + + "\x0fpayment_address\x18\x01 \x01(\fR\x0epaymentAddress*;\n" + "\vAddressType\x12\x18\n" + "\x14ADDRESS_TYPE_UNKNOWN\x10\x00\x12\x12\n" + "\x0eTAPROOT_PUBKEY\x10\x01*9\n" + @@ -7397,7 +7458,7 @@ func file_client_proto_rawDescGZIP() []byte { } var file_client_proto_enumTypes = make([]protoimpl.EnumInfo, 10) -var file_client_proto_msgTypes = make([]protoimpl.MessageInfo, 81) +var file_client_proto_msgTypes = make([]protoimpl.MessageInfo, 82) var file_client_proto_goTypes = []any{ (AddressType)(0), // 0: looprpc.AddressType (SwapType)(0), // 1: looprpc.SwapType @@ -7489,17 +7550,18 @@ var file_client_proto_goTypes = []any{ (*FixedPoint)(nil), // 87: looprpc.FixedPoint (*AssetLoopOutInfo)(nil), // 88: looprpc.AssetLoopOutInfo (*StatelessRecovery)(nil), // 89: looprpc.StatelessRecovery - nil, // 90: looprpc.LiquidityParameters.EasyAssetParamsEntry - (*lnrpc.OpenChannelRequest)(nil), // 91: lnrpc.OpenChannelRequest - (*swapserverrpc.RouteHint)(nil), // 92: looprpc.RouteHint - (*lnrpc.OutPoint)(nil), // 93: lnrpc.OutPoint + (*CooperativeSweep)(nil), // 90: looprpc.CooperativeSweep + nil, // 91: looprpc.LiquidityParameters.EasyAssetParamsEntry + (*lnrpc.OpenChannelRequest)(nil), // 92: lnrpc.OpenChannelRequest + (*swapserverrpc.RouteHint)(nil), // 93: looprpc.RouteHint + (*lnrpc.OutPoint)(nil), // 94: lnrpc.OutPoint } var file_client_proto_depIdxs = []int32{ - 91, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest + 92, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest 0, // 1: looprpc.LoopOutRequest.account_addr_type:type_name -> looprpc.AddressType 85, // 2: looprpc.LoopOutRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest 86, // 3: looprpc.LoopOutRequest.asset_rfq_info:type_name -> looprpc.AssetRfqInfo - 92, // 4: looprpc.LoopInRequest.route_hints:type_name -> looprpc.RouteHint + 93, // 4: looprpc.LoopInRequest.route_hints:type_name -> looprpc.RouteHint 1, // 5: looprpc.SwapStatus.type:type_name -> looprpc.SwapType 2, // 6: looprpc.SwapStatus.state:type_name -> looprpc.SwapState 8, // 7: looprpc.SwapStatus.static_loop_in_state:type_name -> looprpc.StaticAddressLoopInSwapState @@ -7509,116 +7571,117 @@ var file_client_proto_depIdxs = []int32{ 9, // 11: looprpc.ListSwapsFilter.swap_type:type_name -> looprpc.ListSwapsFilter.SwapTypeFilter 18, // 12: looprpc.ListSwapsResponse.swaps:type_name -> looprpc.SwapStatus 89, // 13: looprpc.SweepHtlcRequest.stateless_recovery:type_name -> looprpc.StatelessRecovery - 24, // 14: looprpc.SweepHtlcResponse.not_requested:type_name -> looprpc.PublishNotRequested - 25, // 15: looprpc.SweepHtlcResponse.published:type_name -> looprpc.PublishSucceeded - 26, // 16: looprpc.SweepHtlcResponse.failed:type_name -> looprpc.PublishFailed - 92, // 17: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint - 85, // 18: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest - 86, // 19: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo - 92, // 20: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint - 40, // 21: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token - 41, // 22: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats - 41, // 23: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats - 47, // 24: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule - 0, // 25: looprpc.LiquidityParameters.account_addr_type:type_name -> looprpc.AddressType - 90, // 26: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry - 4, // 27: looprpc.LiquidityParameters.loop_in_source:type_name -> looprpc.LoopInSource - 1, // 28: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType - 5, // 29: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType - 45, // 30: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters - 6, // 31: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason - 14, // 32: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest - 15, // 33: looprpc.SuggestSwapsResponse.loop_in:type_name -> looprpc.LoopInRequest - 83, // 34: looprpc.SuggestSwapsResponse.static_loop_in:type_name -> looprpc.StaticAddressLoopInRequest - 51, // 35: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified - 57, // 36: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation - 64, // 37: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut - 69, // 38: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo - 93, // 39: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint - 7, // 40: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState - 80, // 41: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit - 81, // 42: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal - 82, // 43: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap - 7, // 44: looprpc.Deposit.state:type_name -> looprpc.DepositState - 80, // 45: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit - 8, // 46: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState - 80, // 47: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit - 92, // 48: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint - 80, // 49: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit - 87, // 50: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint - 87, // 51: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint - 46, // 52: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams - 14, // 53: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest - 15, // 54: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest - 17, // 55: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest - 19, // 56: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest - 22, // 57: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest - 27, // 58: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest - 53, // 59: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest - 28, // 60: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest - 31, // 61: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest - 28, // 62: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest - 31, // 63: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest - 34, // 64: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest - 36, // 65: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest - 36, // 66: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest - 38, // 67: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest - 42, // 68: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest - 12, // 69: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest - 44, // 70: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest - 48, // 71: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest - 50, // 72: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest - 55, // 73: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest - 58, // 74: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest - 60, // 75: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest - 62, // 76: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest - 65, // 77: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest - 67, // 78: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest - 70, // 79: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest - 72, // 80: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest - 74, // 81: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest - 76, // 82: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest - 78, // 83: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest - 83, // 84: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest - 10, // 85: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest - 16, // 86: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse - 16, // 87: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse - 18, // 88: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus - 21, // 89: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse - 23, // 90: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse - 18, // 91: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus - 54, // 92: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse - 30, // 93: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse - 33, // 94: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse - 29, // 95: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse - 32, // 96: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse - 35, // 97: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse - 37, // 98: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse - 37, // 99: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse - 39, // 100: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse - 43, // 101: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse - 13, // 102: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse - 45, // 103: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters - 49, // 104: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse - 52, // 105: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse - 56, // 106: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse - 59, // 107: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse - 61, // 108: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse - 63, // 109: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse - 66, // 110: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse - 68, // 111: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse - 71, // 112: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse - 73, // 113: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse - 75, // 114: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse - 77, // 115: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse - 79, // 116: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse - 84, // 117: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse - 11, // 118: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse - 86, // [86:119] is the sub-list for method output_type - 53, // [53:86] is the sub-list for method input_type - 53, // [53:53] is the sub-list for extension type_name - 53, // [53:53] is the sub-list for extension extendee - 0, // [0:53] is the sub-list for field type_name + 90, // 14: looprpc.SweepHtlcRequest.cooperative:type_name -> looprpc.CooperativeSweep + 24, // 15: looprpc.SweepHtlcResponse.not_requested:type_name -> looprpc.PublishNotRequested + 25, // 16: looprpc.SweepHtlcResponse.published:type_name -> looprpc.PublishSucceeded + 26, // 17: looprpc.SweepHtlcResponse.failed:type_name -> looprpc.PublishFailed + 93, // 18: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint + 85, // 19: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest + 86, // 20: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo + 93, // 21: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint + 40, // 22: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token + 41, // 23: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats + 41, // 24: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats + 47, // 25: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule + 0, // 26: looprpc.LiquidityParameters.account_addr_type:type_name -> looprpc.AddressType + 91, // 27: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry + 4, // 28: looprpc.LiquidityParameters.loop_in_source:type_name -> looprpc.LoopInSource + 1, // 29: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType + 5, // 30: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType + 45, // 31: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters + 6, // 32: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason + 14, // 33: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest + 15, // 34: looprpc.SuggestSwapsResponse.loop_in:type_name -> looprpc.LoopInRequest + 83, // 35: looprpc.SuggestSwapsResponse.static_loop_in:type_name -> looprpc.StaticAddressLoopInRequest + 51, // 36: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified + 57, // 37: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation + 64, // 38: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut + 69, // 39: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo + 94, // 40: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint + 7, // 41: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState + 80, // 42: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit + 81, // 43: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal + 82, // 44: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap + 7, // 45: looprpc.Deposit.state:type_name -> looprpc.DepositState + 80, // 46: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit + 8, // 47: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState + 80, // 48: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit + 93, // 49: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint + 80, // 50: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit + 87, // 51: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint + 87, // 52: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint + 46, // 53: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams + 14, // 54: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest + 15, // 55: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest + 17, // 56: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest + 19, // 57: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest + 22, // 58: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest + 27, // 59: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest + 53, // 60: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest + 28, // 61: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest + 31, // 62: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest + 28, // 63: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest + 31, // 64: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest + 34, // 65: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest + 36, // 66: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest + 36, // 67: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest + 38, // 68: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest + 42, // 69: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest + 12, // 70: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest + 44, // 71: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest + 48, // 72: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest + 50, // 73: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest + 55, // 74: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest + 58, // 75: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest + 60, // 76: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest + 62, // 77: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest + 65, // 78: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest + 67, // 79: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest + 70, // 80: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest + 72, // 81: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest + 74, // 82: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest + 76, // 83: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest + 78, // 84: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest + 83, // 85: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest + 10, // 86: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest + 16, // 87: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse + 16, // 88: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse + 18, // 89: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus + 21, // 90: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse + 23, // 91: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse + 18, // 92: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus + 54, // 93: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse + 30, // 94: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse + 33, // 95: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse + 29, // 96: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse + 32, // 97: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse + 35, // 98: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse + 37, // 99: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse + 37, // 100: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse + 39, // 101: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse + 43, // 102: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse + 13, // 103: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse + 45, // 104: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters + 49, // 105: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse + 52, // 106: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse + 56, // 107: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse + 59, // 108: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse + 61, // 109: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse + 63, // 110: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse + 66, // 111: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse + 68, // 112: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse + 71, // 113: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse + 73, // 114: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse + 75, // 115: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse + 77, // 116: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse + 79, // 117: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse + 84, // 118: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse + 11, // 119: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse + 87, // [87:120] is the sub-list for method output_type + 54, // [54:87] is the sub-list for method input_type + 54, // [54:54] is the sub-list for extension type_name + 54, // [54:54] is the sub-list for extension extendee + 0, // [0:54] is the sub-list for field type_name } func init() { file_client_proto_init() } @@ -7643,7 +7706,7 @@ func file_client_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_client_proto_rawDesc), len(file_client_proto_rawDesc)), NumEnums: 10, - NumMessages: 81, + NumMessages: 82, NumExtensions: 0, NumServices: 1, }, diff --git a/looprpc/client.proto b/looprpc/client.proto index 346d1460a..36332dcdd 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -40,8 +40,8 @@ service SwapClient { rpc ListSwaps (ListSwapsRequest) returns (ListSwapsResponse); /* loop: `sweephtlc` - SweepHtlc spends a swap HTLC output via the preimage (success) path using - the swap's known preimage or an optionally supplied one. + SweepHtlc spends a swap HTLC output via either the preimage success path + or the cooperative key path, using stored or stateless recovery data. */ rpc SweepHtlc (SweepHtlcRequest) returns (SweepHtlcResponse); @@ -777,7 +777,7 @@ message ListSwapsResponse { int64 next_start_time = 2; } -// SweepHtlcRequest instructs loopd to sweep a swap HTLC via its success path. +// SweepHtlcRequest instructs loopd to sweep a swap HTLC. message SweepHtlcRequest { // Optional override for the sweep destination; defaults to a new address // derived from the connected lnd wallet. @@ -803,9 +803,13 @@ message SweepHtlcRequest { // If set, reconstruct the latest Loop Out HTLC without querying the swap // database. StatelessRecovery stateless_recovery = 7; + + // If set, request the server's MuSig2 signature and spend the HTLC via its + // cooperative key path instead of revealing the preimage. + CooperativeSweep cooperative = 8; } -// SweepHtlcResponse returns the broadcast sweep transaction. +// SweepHtlcResponse returns the signed sweep transaction and publish outcome. message SweepHtlcResponse { // Raw sweep transaction bytes. bytes sweep_tx = 1; @@ -2542,3 +2546,11 @@ message StatelessRecovery { // signing by public key fails. If zero, loopd uses its default limit. uint32 key_scan_limit = 5; } + +// CooperativeSweep selects a cooperative MuSig2 key-path spend. +message CooperativeSweep { + // The swap invoice payment address used to prove ownership to the server. + // Stateful mode obtains this value from the stored swap invoice. Stateless + // mode requires it here. + bytes payment_address = 1; +} diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index f82f4c7c2..2332cfc53 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -662,7 +662,7 @@ }, "/v1/loop/out/sweephtlc": { "post": { - "summary": "loop: `sweephtlc`\nSweepHtlc spends a swap HTLC output via the preimage (success) path using\nthe swap's known preimage or an optionally supplied one.", + "summary": "loop: `sweephtlc`\nSweepHtlc spends a swap HTLC output via either the preimage success path\nor the cooperative key path, using stored or stateless recovery data.", "operationId": "SwapClient_SweepHtlc", "responses": { "200": { @@ -681,7 +681,7 @@ "parameters": [ { "name": "body", - "description": "SweepHtlcRequest instructs loopd to sweep a swap HTLC via its success path.", + "description": "SweepHtlcRequest instructs loopd to sweep a swap HTLC.", "in": "body", "required": true, "schema": { @@ -1585,6 +1585,17 @@ } } }, + "looprpcCooperativeSweep": { + "type": "object", + "properties": { + "payment_address": { + "type": "string", + "format": "byte", + "description": "The swap invoice payment address used to prove ownership to the server.\nStateful mode obtains this value from the stored swap invoice. Stateless\nmode requires it here." + } + }, + "description": "CooperativeSweep selects a cooperative MuSig2 key-path spend." + }, "looprpcDeposit": { "type": "object", "properties": { @@ -3209,9 +3220,13 @@ "stateless_recovery": { "$ref": "#/definitions/looprpcStatelessRecovery", "description": "If set, reconstruct the latest Loop Out HTLC without querying the swap\ndatabase." + }, + "cooperative": { + "$ref": "#/definitions/looprpcCooperativeSweep", + "description": "If set, request the server's MuSig2 signature and spend the HTLC via its\ncooperative key path instead of revealing the preimage." } }, - "description": "SweepHtlcRequest instructs loopd to sweep a swap HTLC via its success path." + "description": "SweepHtlcRequest instructs loopd to sweep a swap HTLC." }, "looprpcSweepHtlcResponse": { "type": "object", @@ -3236,7 +3251,7 @@ "$ref": "#/definitions/looprpcPublishFailed" } }, - "description": "SweepHtlcResponse returns the broadcast sweep transaction." + "description": "SweepHtlcResponse returns the signed sweep transaction and publish outcome." }, "looprpcTokensResponse": { "type": "object", diff --git a/looprpc/client_grpc.pb.go b/looprpc/client_grpc.pb.go index b03cc9e87..665a81073 100644 --- a/looprpc/client_grpc.pb.go +++ b/looprpc/client_grpc.pb.go @@ -38,8 +38,8 @@ type SwapClientClient interface { // status. ListSwaps(ctx context.Context, in *ListSwapsRequest, opts ...grpc.CallOption) (*ListSwapsResponse, error) // loop: `sweephtlc` - // SweepHtlc spends a swap HTLC output via the preimage (success) path using - // the swap's known preimage or an optionally supplied one. + // SweepHtlc spends a swap HTLC output via either the preimage success path + // or the cooperative key path, using stored or stateless recovery data. SweepHtlc(ctx context.Context, in *SweepHtlcRequest, opts ...grpc.CallOption) (*SweepHtlcResponse, error) // loop: `swapinfo` // SwapInfo returns all known details about a single swap. @@ -498,8 +498,8 @@ type SwapClientServer interface { // status. ListSwaps(context.Context, *ListSwapsRequest) (*ListSwapsResponse, error) // loop: `sweephtlc` - // SweepHtlc spends a swap HTLC output via the preimage (success) path using - // the swap's known preimage or an optionally supplied one. + // SweepHtlc spends a swap HTLC output via either the preimage success path + // or the cooperative key path, using stored or stateless recovery data. SweepHtlc(context.Context, *SweepHtlcRequest) (*SweepHtlcResponse, error) // loop: `swapinfo` // SwapInfo returns all known details about a single swap.