Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
190 changes: 182 additions & 8 deletions cmd/loop/sweephtlc.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,24 @@ 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 " +
"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.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "outpoint",
Expand All @@ -41,6 +55,40 @@ 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: "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",
Expand Down Expand Up @@ -69,14 +117,111 @@ 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,
}
}

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"),
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,
Cooperative: cooperativeSweep,
})
if err != nil {
return err
Expand Down Expand Up @@ -117,3 +262,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
}
134 changes: 134 additions & 0 deletions cmd/loop/sweephtlc_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading
Loading