diff --git a/cmd/loop/openchannel.go b/cmd/loop/openchannel.go new file mode 100644 index 00000000..e7b18c60 --- /dev/null +++ b/cmd/loop/openchannel.go @@ -0,0 +1,366 @@ +package main + +import ( + "context" + "encoding/hex" + "fmt" + "strconv" + + "github.com/lightninglabs/loop/looprpc" + lndcommands "github.com/lightningnetwork/lnd/cmd/commands" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/urfave/cli/v3" +) + +const ( + defaultUtxoMinConf = 1 +) + +var ( + channelTypeTweakless = "tweakless" + channelTypeAnchors = "anchors" + channelTypeSimpleTaproot = "taproot" +) + +var openChannelCommand = &cli.Command{ + Name: "openchannel", + Usage: "Open a channel to a an existing peer.", + Description: ` + Attempt to open a new channel to an existing peer with the key + node-key. + + The channel will be initialized with local-amt satoshis locally and + push-amt satoshis for the remote node. Note that the push-amt is + deducted from the specified local-amt which implies that the local-amt + must be greater than the push-amt. Also note that specifying push-amt + means you give that amount to the remote node as part of the channel + opening. Once the channel is open, a channelPoint (txid:vout) of the + funding output is returned. + + If the remote peer supports the option upfront shutdown feature bit + (query listpeers to see their supported feature bits), an address to + enforce payout of funds on cooperative close can optionally be provided. + Note that if you set this value, you will not be able to cooperatively + close out to another address. + + One can also specify a short string memo to record some useful + information about the channel using the --memo argument. This is stored + locally only, and is purely for reference. It has no bearing on the + channel's operation. Max allowed length is 500 characters.`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "node_key", + Usage: "the identity public key of the target " + + "node/peer serialized in compressed format", + }, + &cli.IntFlag{ + Name: "local_amt", + Usage: "the number of satoshis the wallet should " + + "commit to the channel", + }, + &cli.Uint64Flag{ + Name: "base_fee_msat", + Usage: "the base fee in milli-satoshis that will " + + "be charged for each forwarded HTLC, " + + "regardless of payment size", + }, + &cli.Uint64Flag{ + Name: "fee_rate_ppm", + Usage: "the fee rate ppm (parts per million) that " + + "will be charged proportionally based on the " + + "value of each forwarded HTLC, the lowest " + + "possible rate is 0 with a granularity of " + + "0.000001 (millionths)", + }, + &cli.IntFlag{ + Name: "push_amt", + Usage: "the number of satoshis to give the remote " + + "side as part of the initial commitment " + + "state, this is equivalent to first opening " + + "a channel and sending the remote party " + + "funds, but done all in one step", + }, + &cli.Int64Flag{ + Name: "sat_per_byte", + Usage: "Deprecated, use sat_per_vbyte instead.", + Hidden: true, + }, + &cli.Uint64Flag{ + Name: "sat_per_vbyte", + Usage: "(optional) a manual fee expressed in " + + "sat/vbyte that should be used when crafting " + + "the transaction", + }, + &cli.BoolFlag{ + Name: "private", + Usage: "make the channel private, such that it won't " + + "be announced to the greater network, and " + + "nodes other than the two channel endpoints " + + "must be explicitly told about it to be able " + + "to route through it", + }, + &cli.Int64Flag{ + Name: "min_htlc_msat", + Usage: "(optional) the minimum value we will require " + + "for incoming HTLCs on the channel", + }, + &cli.Uint64Flag{ + Name: "remote_csv_delay", + Usage: "(optional) the number of blocks we will " + + "require our channel counterparty to wait " + + "before accessing its funds in case of " + + "unilateral close. If this is not set, we " + + "will scale the value according to the " + + "channel size", + }, + &cli.Uint64Flag{ + Name: "max_local_csv", + Usage: "(optional) the maximum number of blocks that " + + "we will allow the remote peer to require we " + + "wait before accessing our funds in the case " + + "of a unilateral close.", + }, + &cli.StringFlag{ + Name: "close_address", + Usage: "(optional) an address to enforce payout of " + + "our funds to on cooperative close. Note " + + "that if this value is set on channel open, " + + "you will *not* be able to cooperatively " + + "close to a different address.", + }, + &cli.Uint64Flag{ + Name: "remote_max_value_in_flight_msat", + Usage: "(optional) the maximum value in msat that " + + "can be pending within the channel at any " + + "given time", + }, + &cli.StringFlag{ + Name: "channel_type", + Usage: fmt.Sprintf("(optional) the type of channel to "+ + "propose to the remote peer (%q, %q, %q)", + channelTypeTweakless, channelTypeAnchors, + channelTypeSimpleTaproot), + }, + &cli.BoolFlag{ + Name: "zero_conf", + Usage: "(optional) whether a zero-conf channel open " + + "should be attempted.", + }, + &cli.BoolFlag{ + Name: "scid_alias", + Usage: "(optional) whether a scid-alias channel type" + + " should be negotiated.", + }, + &cli.Uint64Flag{ + Name: "remote_reserve_sats", + Usage: "(optional) the minimum number of satoshis we " + + "require the remote node to keep as a direct " + + "payment. If not specified, a default of 1% " + + "of the channel capacity will be used.", + }, + &cli.StringFlag{ + Name: "memo", + Usage: `(optional) a note-to-self containing some useful + information about the channel. This is stored + locally only, and is purely for reference. It + has no bearing on the channel's operation. Max + allowed length is 500 characters`, + }, + &cli.BoolFlag{ + Name: "fundmax", + Usage: "if set, the wallet will attempt to commit " + + "the maximum possible local amount to the " + + "channel. This must not be set at the same " + + "time as local_amt", + }, + &cli.StringSliceFlag{ + Name: "utxo", + Usage: "a utxo specified as outpoint(tx:idx) which " + + "will be used to fund a channel. This flag " + + "can be repeatedly used to fund a channel " + + "with a selection of utxos. The selected " + + "funds can either be entirely spent by " + + "specifying the fundmax flag or partially by " + + "selecting a fraction of the sum of the " + + "outpoints in local_amt", + }, + }, + Action: openChannel, +} + +func openChannel(ctx context.Context, cmd *cli.Command) error { + var ( + args = cmd.Args() + remaining []string + err error + ) + + client, cleanup, err := getClient(cmd) + if err != nil { + return err + } + defer cleanup() + + // Show command help if no arguments provided + if cmd.NArg() == 0 && cmd.NumFlags() == 0 { + _ = cli.ShowCommandHelp(ctx, cmd, "openchannel") + return nil + } + + // Check that only the field sat_per_vbyte or the deprecated field + // sat_per_byte is used. + feeRateFlag, err := checkNotBothSet( + cmd, "sat_per_vbyte", "sat_per_byte", + ) + if err != nil { + return err + } + + var feeRateSatPerVbyte uint64 + switch feeRateFlag { + case "sat_per_vbyte": + feeRateSatPerVbyte = cmd.Uint64(feeRateFlag) + + case "sat_per_byte": + feeRateSatPerByte := cmd.Int64(feeRateFlag) + if feeRateSatPerByte < 0 { + return fmt.Errorf("%v must be non-negative", feeRateFlag) + } + feeRateSatPerVbyte = uint64(feeRateSatPerByte) + } + + minConfs := defaultUtxoMinConf + req := &lnrpc.OpenChannelRequest{ + SatPerVbyte: feeRateSatPerVbyte, + FundMax: cmd.Bool("fundmax"), + MinHtlcMsat: cmd.Int64("min_htlc_msat"), + RemoteCsvDelay: uint32(cmd.Uint64("remote_csv_delay")), + MinConfs: int32(minConfs), + SpendUnconfirmed: minConfs == 0, + CloseAddress: cmd.String("close_address"), + RemoteMaxValueInFlightMsat: cmd.Uint64("remote_max_value_in_flight_msat"), + MaxLocalCsv: uint32(cmd.Uint64("max_local_csv")), + ZeroConf: cmd.Bool("zero_conf"), + ScidAlias: cmd.Bool("scid_alias"), + RemoteChanReserveSat: cmd.Uint64("remote_reserve_sats"), + Memo: cmd.String("memo"), + } + + switch { + case cmd.IsSet("node_key"): + nodePubHex, err := hex.DecodeString(cmd.String("node_key")) + if err != nil { + return fmt.Errorf("unable to decode node "+ + "public key: %w", err) + } + req.NodePubkey = nodePubHex + + case args.Present(): + nodePubHex, err := hex.DecodeString(args.First()) + if err != nil { + return fmt.Errorf("unable to decode node "+ + "public key: %w", err) + } + remaining = args.Tail() + req.NodePubkey = nodePubHex + + default: + return fmt.Errorf("node id argument missing") + } + + if cmd.IsSet("utxo") { + utxos := cmd.StringSlice("utxo") + + outpoints, err := lndcommands.UtxosToOutpoints(utxos) + if err != nil { + return fmt.Errorf("unable to decode utxos: %w", err) + } + + req.Outpoints = outpoints + } + + // The fundmax flag is NOT allowed to be combined with local_amt above. + // It is allowed to be combined with push_amt, but only if explicitly + // set. + if cmd.Bool("fundmax") && cmd.IsSet("local_amt") { + return fmt.Errorf("local_amt and fundmax are mutually " + + "exclusive") + } + + switch { + case cmd.IsSet("local_amt"): + req.LocalFundingAmount = int64(cmd.Int("local_amt")) + + case !cmd.Bool("fundmax"): + return fmt.Errorf("either local_amt or fundmax must be " + + "specified") + } + + if cmd.IsSet("push_amt") { + req.PushSat = int64(cmd.Int("push_amt")) + } else if len(remaining) > 0 { + req.PushSat, err = strconv.ParseInt(remaining[0], 10, 64) + if err != nil { + return fmt.Errorf("unable to decode push amt: %w", err) + } + } + + if cmd.IsSet("base_fee_msat") { + req.BaseFee = cmd.Uint64("base_fee_msat") + req.UseBaseFee = true + } + + if cmd.IsSet("fee_rate_ppm") { + req.FeeRate = cmd.Uint64("fee_rate_ppm") + req.UseFeeRate = true + } + + req.Private = cmd.Bool("private") + + // Parse the channel type and map it to its RPC representation. + channelType := cmd.String("channel_type") + switch channelType { + case "": + break + case channelTypeTweakless: + req.CommitmentType = lnrpc.CommitmentType_STATIC_REMOTE_KEY + + case channelTypeAnchors: + req.CommitmentType = lnrpc.CommitmentType_ANCHORS + + case channelTypeSimpleTaproot: + req.CommitmentType = lnrpc.CommitmentType_SIMPLE_TAPROOT + default: + return fmt.Errorf("unsupported channel type %v", channelType) + } + + wrappedReq := &looprpc.StaticOpenChannelRequest{ + OpenChannelRequest: req, + } + + resp, err := client.StaticOpenChannel(ctx, wrappedReq) + if err != nil { + return err + } + + printRespJSON(resp) + + return nil +} + +// checkNotBothSet accepts two flag names, a and b, and checks that only flag a +// or flag b can be set, but not both. It returns the name of the flag or an +// error. +func checkNotBothSet(cmd *cli.Command, a, b string) (string, error) { + if cmd.IsSet(a) && cmd.IsSet(b) { + return "", fmt.Errorf( + "either %s or %s should be set, but not both", a, b, + ) + } + + if cmd.IsSet(a) { + return a, nil + } + + return b, nil +} diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index 8ce2d538..0e0ded7f 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -2,18 +2,15 @@ package main import ( "context" - "encoding/hex" "errors" "fmt" - "strconv" - "strings" - "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/looprpc" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" "github.com/lightninglabs/loop/swapserverrpc" + lndcommands "github.com/lightningnetwork/lnd/cmd/commands" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/routing/route" "github.com/urfave/cli/v3" @@ -36,6 +33,7 @@ var staticAddressCommands = &cli.Command{ withdrawalCommand, summaryCommand, staticAddressLoopInCommand, + openChannelCommand, }, } @@ -194,7 +192,7 @@ func withdraw(ctx context.Context, cmd *cli.Command) error { case isAllSelected: case isUtxoSelected: utxos := cmd.StringSlice("utxo") - outpoints, err = utxosToOutpoints(utxos) + outpoints, err = lndcommands.UtxosToOutpoints(utxos) if err != nil { return err } @@ -239,6 +237,7 @@ var listDepositsCommand = &cli.Command{ "of the following: \n" + "deposited\nwithdrawing\nwithdrawn\n" + "looping_in\nlooped_in\n" + + "opening_channel\nchannel_published\n" + "publish_expired_deposit\n" + "sweep_htlc_timeout\nhtlc_timeout_swept\n" + "wait_for_expiry_sweep\nexpired\nfailed\n.", @@ -278,6 +277,12 @@ func listDeposits(ctx context.Context, cmd *cli.Command) error { case "looped_in": filterState = looprpc.DepositState_LOOPED_IN + case "opening_channel": + filterState = looprpc.DepositState_OPENING_CHANNEL + + case "channel_published": + filterState = looprpc.DepositState_CHANNEL_PUBLISHED + case "publish_expired_deposit": filterState = looprpc.DepositState_PUBLISH_EXPIRED @@ -379,7 +384,7 @@ var summaryCommand = &cli.Command{ Usage: "Display a summary of static address related information.", Description: ` Displays various static address related information about deposits, - withdrawals and swaps. + withdrawals, swaps and channel openings. `, Action: summary, } @@ -407,46 +412,6 @@ func summary(ctx context.Context, cmd *cli.Command) error { return nil } -func utxosToOutpoints(utxos []string) ([]*lnrpc.OutPoint, error) { - outpoints := make([]*lnrpc.OutPoint, 0, len(utxos)) - if len(utxos) == 0 { - return nil, fmt.Errorf("no utxos specified") - } - - for _, utxo := range utxos { - outpoint, err := NewProtoOutPoint(utxo) - if err != nil { - return nil, err - } - outpoints = append(outpoints, outpoint) - } - - return outpoints, nil -} - -// NewProtoOutPoint parses an OutPoint into its corresponding lnrpc.OutPoint -// type. -func NewProtoOutPoint(op string) (*lnrpc.OutPoint, error) { - parts := strings.Split(op, ":") - if len(parts) != 2 { - return nil, errors.New("outpoint should be of the form " + - "txid:index") - } - txid := parts[0] - if hex.DecodedLen(len(txid)) != chainhash.HashSize { - return nil, fmt.Errorf("invalid hex-encoded txid %v", txid) - } - outputIndex, err := strconv.Atoi(parts[1]) - if err != nil { - return nil, fmt.Errorf("invalid output index: %v", err) - } - - return &lnrpc.OutPoint{ - TxidStr: txid, - OutputIndex: uint32(outputIndex), - }, nil -} - var staticAddressLoopInCommand = &cli.Command{ Name: "in", Usage: "Loop in funds from static address deposits.",