diff --git a/cmd/pool/main.go b/cmd/pool/main.go index 2945ad1..902fbec 100644 --- a/cmd/pool/main.go +++ b/cmd/pool/main.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/hex" - "encoding/json" "errors" "fmt" "io/ioutil" @@ -17,6 +16,7 @@ import ( "github.com/lightninglabs/lndclient" "github.com/lightninglabs/pool" "github.com/lightninglabs/pool/poolrpc" + "github.com/lightninglabs/protobuf-hex-display/json" "github.com/lightninglabs/protobuf-hex-display/jsonpb" "github.com/lightninglabs/protobuf-hex-display/proto" "github.com/lightningnetwork/lnd/lncfg" @@ -133,6 +133,7 @@ func main() { } app.Commands = append(app.Commands, accountsCommands...) app.Commands = append(app.Commands, ordersCommands...) + app.Commands = append(app.Commands, sidecarCommands...) app.Commands = append(app.Commands, auctionCommands...) app.Commands = append(app.Commands, listAuthCommand) app.Commands = append(app.Commands, getInfoCommand) diff --git a/cmd/pool/order.go b/cmd/pool/order.go index aae6439..e035d3a 100644 --- a/cmd/pool/order.go +++ b/cmd/pool/order.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "bytes" "context" "encoding/hex" "fmt" @@ -283,7 +284,7 @@ func ordersSubmitAsk(ctx *cli.Context) error { // nolint: dupl ask.LeaseDurationBlocks, chainfee.SatPerKWeight( ask.Details.MaxBatchFeeRateSatPerKw, - ), true, + ), true, nil, ); err != nil { return fmt.Errorf("unable to print order details: %v", err) } @@ -313,7 +314,7 @@ func ordersSubmitAsk(ctx *cli.Context) error { // nolint: dupl func printOrderDetails(client poolrpc.TraderClient, amt, minChanAmt, selfChanBalance btcutil.Amount, rate order.FixedRatePremium, leaseDuration uint32, maxBatchFeeRate chainfee.SatPerKWeight, - isAsk bool) error { + isAsk bool, sidecarTicket *sidecar.Ticket) error { auctionFee, err := client.AuctionFee( context.Background(), &poolrpc.AuctionFeeRequest{}, @@ -356,6 +357,12 @@ func printOrderDetails(client poolrpc.TraderClient, amt, fmt.Printf("Self channel balance: %v\n", selfChanBalance) } + if sidecarTicket != nil { + fmt.Println("Sidecar order: ") + fmt.Printf(" Recipient node: %x\n", + sidecarTicket.Recipient.NodePubKey.SerializeCompressed()) + } + return nil } @@ -413,6 +420,17 @@ var ordersSubmitBidCommand = cli.Command{ "from our account into the channel; can be " + "used to create up to 50/50 balanced channels", }, + cli.StringFlag{ + Name: "sidecar_ticket", + Usage: "instead of leasing a channel for the node " + + "connected to this pool instance, lease a " + + "channel for another node; use the " + + "information within the ticket to identify " + + "the receiver of the sidecar channel; using " + + "a sidecar ticket will also overwrite the " + + "amt, min_chan_amt, lease_duration_blocks " + + "and self_chan_balance fields", + }, }, sharedFlags...), Action: ordersSubmitBid, } @@ -444,10 +462,54 @@ func ordersSubmitBid(ctx *cli.Context) error { // nolint: dupl bid := &poolrpc.Bid{ LeaseDurationBlocks: uint32(ctx.Uint64("lease_duration_blocks")), - Version: uint32(order.VersionSelfChanBalance), + Version: uint32(order.VersionSidecarChannel), MinNodeTier: nodeTier, } + // Let's find out if this is an order for a sidecar channel because if + // it is, we can take some of the information out of the ticket and + // don't require the user to enter them manually again. + var ticket *sidecar.Ticket + if ctx.IsSet("sidecar_ticket") { + // The ticket is expected in the string encoded version which + // has a prefix and a checksum. We're supposed to send it to + // the daemon in its raw format though. So let's decode and + // check it in the process. + ticket, err = sidecar.DecodeString(ctx.String("sidecar_ticket")) + if err != nil { + return fmt.Errorf("unable to parse sidecar ticket: %v", + err) + } + + // Let's make sure the ticket is in the correct state. This will + // be checked by the server as well but we want to make sure we + // don't run into a nil reference when printing the order + // details below. + if ticket.State != sidecar.StateRegistered || + ticket.Recipient == nil { + + return fmt.Errorf("unexpected sidecar ticket state "+ + "%d, possibly not registered with recipient "+ + "node yet", ticket.State) + } + + // With the ticket parsed and formally checked, we can now pre- + // fill the amount and min channel amount. Those values must + // match the offered capacity, otherwise the push amount won't + // work as expected and the protocol would get more complex as + // well. + amtStr := fmt.Sprintf("%d", ticket.Offer.Capacity) + pushAmtStr := fmt.Sprintf("%d", ticket.Offer.PushAmt) + _ = ctx.Set("amt", amtStr) + _ = ctx.Set("min_chan_amt", amtStr) + _ = ctx.Set("self_chan_balance", pushAmtStr) + bid.LeaseDurationBlocks = ticket.Offer.LeaseDurationBlocks + + // Looks good so far. The rest will be checked server side. For + // now we can just add the ticket to the order. + bid.SidecarTicket = ctx.String("sidecar_ticket") + } + params, err := parseCommonParams(ctx, bid.LeaseDurationBlocks) if err != nil { return fmt.Errorf("unable to parse order params: %v", err) @@ -495,7 +557,7 @@ func ordersSubmitBid(ctx *cli.Context) error { // nolint: dupl bid.LeaseDurationBlocks, chainfee.SatPerKWeight( bid.Details.MaxBatchFeeRateSatPerKw, - ), false, + ), false, ticket, ); err != nil { return fmt.Errorf("unable to print order details: %v", err) } @@ -517,8 +579,38 @@ func ordersSubmitBid(ctx *cli.Context) error { // nolint: dupl if err != nil { return err } + printRespJSON(resp) + // If there was a sidecar ticket, we now also need to display the + // updated ticket that contains the order nonce (and a signature over + // that). This ticket needs to be given to the recipient for them to + // initiate the last step of the sidecar channel protocol. + // The signed ticket is available in the output of the ListOrders call. + if ticket != nil { + newBidNonce := resp.GetAcceptedOrderNonce() + allOrders, err := client.ListOrders( + context.Background(), &poolrpc.ListOrdersRequest{ + ActiveOnly: true, + }, + ) + if err != nil { + return fmt.Errorf("error listing orders to print "+ + "updated sidecar ticket: %v", err) + } + + // Find the order we just created. + for _, bid := range allOrders.Bids { + if bytes.Equal(newBidNonce, bid.Details.OrderNonce) { + printJSON(struct { + Ticket string `json:"ticket"` + }{ + Ticket: bid.SidecarTicket, + }) + } + } + } + return nil } diff --git a/cmd/pool/sidecar.go b/cmd/pool/sidecar.go new file mode 100644 index 0000000..b28ceab --- /dev/null +++ b/cmd/pool/sidecar.go @@ -0,0 +1,287 @@ +package main + +import ( + "context" + "encoding/hex" + "fmt" + "strconv" + + "github.com/lightninglabs/pool/order" + "github.com/lightninglabs/pool/poolrpc" + "github.com/lightninglabs/pool/sidecar" + "github.com/urfave/cli" +) + +var sidecarCommands = []cli.Command{ + { + Name: "sidecar", + Aliases: []string{"s"}, + Usage: "Manage sidecar channels.", + Category: "Orders", + Subcommands: []cli.Command{ + sidecarOfferCommand, + sidecarPrintTicketCommand, + sidecarRegisterCommand, + sidecarExpectChannelCommand, + }, + }, +} + +var sidecarOfferCommand = cli.Command{ + Name: "offer", + Aliases: []string{"o"}, + Usage: "offer a sidecar channel", + ArgsUsage: "capacity self_chan_balance lease_duration_blocks", + Description: ` + Creates an offer for providing a sidecar channel to another node.`, + Flags: []cli.Flag{ + cli.Uint64Flag{ + Name: "capacity", + Usage: "the total channel capacity of the sidecar " + + "channel to offer", + }, + cli.Uint64Flag{ + Name: "self_chan_balance", + Usage: "the number of satoshis that should be pushed " + + "to the recipient of the sidecar channel as " + + "initial outbound channel balance; amount " + + "will be deducted from account that submits " + + "bid order, reimbursement must happen out of " + + "band, not part of the sidecar protocol", + }, + cli.Uint64Flag{ + Name: "lease_duration_blocks", + Usage: "the number of blocks the resulting leased " + + "channel should be open for", + Value: uint64(order.LegacyLeaseDurationBucket), + }, + }, + Action: sidecarOffer, +} + +func sidecarOffer(ctx *cli.Context) error { + // Show help if no arguments or flags are provided. + if ctx.NArg() == 0 && ctx.NumFlags() == 0 { + _ = cli.ShowCommandHelp(ctx, "offer") + return nil + } + + var ( + args = ctx.Args() + capacity, pushAmt uint64 + duration uint32 + ) + + switch { + case ctx.IsSet("capacity"): + capacity = ctx.Uint64("capacity") + case args.Present(): + parsed, err := parseAmt(args.First()) + if err != nil { + return fmt.Errorf("unable to decode capacity: %v", err) + } + capacity = uint64(parsed) + args = args.Tail() + } + + switch { + case ctx.IsSet("self_chan_balance"): + pushAmt = ctx.Uint64("self_chan_balance") + case args.Present(): + parsed, err := parseAmt(args.First()) + if err != nil { + return fmt.Errorf("unable to decode self channel "+ + "balance: %v", err) + } + pushAmt = uint64(parsed) + args = args.Tail() + } + + switch { + case ctx.IsSet("lease_duration_blocks"): + duration = uint32(ctx.Uint64("lease_duration_blocks")) + case args.Present(): + duration64, err := strconv.ParseInt(args.First(), 10, 32) + if err != nil { + return fmt.Errorf("unable to parse lease duration "+ + "blocks: %v", err) + } + duration = uint32(duration64) + args = args.Tail() + } + + client, cleanup, err := getClient(ctx) + if err != nil { + return err + } + defer cleanup() + + resp, err := client.OfferSidecar( + context.Background(), &poolrpc.OfferSidecarRequest{ + ChannelCapacitySat: capacity, + SelfChanBalance: pushAmt, + LeaseDurationBlocks: duration, + }, + ) + if err != nil { + return err + } + + printRespJSON(resp) + + return nil +} + +type jsonTicket struct { + ID string + Version uint8 + State string + Capacity uint64 + PushAmount uint64 + LeaseDurationBlocks uint32 + OfferSigningPubKey string + RecipientNodePubKey string + RecipientMultiSigPubKey string + RecipientMultiSigKeyIndex uint32 + OrderNonce string +} + +var sidecarPrintTicketCommand = cli.Command{ + Name: "printticket", + Aliases: []string{"p"}, + Usage: "decode and print the content of a sidecar ticket", + ArgsUsage: "ticket", + Description: ` + Tries to decode the given ticket from the human readable (prefixed) + base64 encoded version.`, + Action: sidecarPrintTicket, +} + +func sidecarPrintTicket(ctx *cli.Context) error { + // Show help if no arguments or flags are provided. + if ctx.NArg() != 1 || ctx.NumFlags() != 0 { + _ = cli.ShowCommandHelp(ctx, "printticket") + return nil + } + + ticketStr := ctx.Args().First() + + ticket, err := sidecar.DecodeString(ticketStr) + if err != nil { + return fmt.Errorf("error decoding base64 ticket: %v", err) + } + + jsonTicket := &jsonTicket{ + ID: hex.EncodeToString(ticket.ID[:]), + Version: uint8(ticket.Version), + State: ticket.State.String(), + Capacity: uint64(ticket.Offer.Capacity), + PushAmount: uint64(ticket.Offer.PushAmt), + LeaseDurationBlocks: ticket.Offer.LeaseDurationBlocks, + } + + if ticket.Offer.SignPubKey != nil { + jsonTicket.OfferSigningPubKey = hex.EncodeToString( + ticket.Offer.SignPubKey.SerializeCompressed(), + ) + } + if ticket.Recipient != nil { + if ticket.Recipient.NodePubKey != nil { + jsonTicket.RecipientNodePubKey = hex.EncodeToString( + ticket.Recipient.NodePubKey.SerializeCompressed(), + ) + } + if ticket.Recipient.MultiSigPubKey != nil { + jsonTicket.RecipientMultiSigPubKey = hex.EncodeToString( + ticket.Recipient.MultiSigPubKey.SerializeCompressed(), + ) + } + jsonTicket.RecipientMultiSigKeyIndex = ticket.Recipient.MultiSigKeyIndex + } + if ticket.Order != nil { + jsonTicket.OrderNonce = hex.EncodeToString( + ticket.Order.BidNonce[:], + ) + } + + printJSON(jsonTicket) + + return nil +} + +var sidecarRegisterCommand = cli.Command{ + Name: "register", + Aliases: []string{"r"}, + Usage: "register an incoming sidecar channel and add node info to " + + "ticket", + ArgsUsage: "ticket", + Description: ` + Registers a sidecar ticket for an incoming sidecar channel with the node + and adds its recipient information to it, resulting in an updated ticket + that needs to be handed back to the provider.`, + Action: sidecarRegister, +} + +func sidecarRegister(ctx *cli.Context) error { + // Show help if no arguments or flags are provided. + if ctx.NArg() != 1 || ctx.NumFlags() != 0 { + _ = cli.ShowCommandHelp(ctx, "register") + return nil + } + + client, cleanup, err := getClient(ctx) + if err != nil { + return err + } + defer cleanup() + + resp, err := client.RegisterSidecar( + context.Background(), &poolrpc.RegisterSidecarRequest{ + Ticket: ctx.Args().First(), + }, + ) + if err != nil { + return err + } + + printRespJSON(resp) + + return nil +} + +var sidecarExpectChannelCommand = cli.Command{ + Name: "expectchannel", + Aliases: []string{"e"}, + Usage: "start waiting for sidecar channel", + ArgsUsage: "ticket", + Description: ` + Connect to the auctioneer and wait for a sidecar order to be matched and + a channel being opened to us.`, + Action: sidecarExpectChannel, +} + +func sidecarExpectChannel(ctx *cli.Context) error { + // Show help if no arguments or flags are provided. + if ctx.NArg() == 0 && ctx.NumFlags() == 0 { + _ = cli.ShowCommandHelp(ctx, "expectchannel") + return nil + } + + client, cleanup, err := getClient(ctx) + if err != nil { + return err + } + defer cleanup() + + resp, err := client.ExpectSidecarChannel( + context.Background(), &poolrpc.ExpectSidecarChannelRequest{ + Ticket: ctx.Args().First(), + }) + if err != nil { + return err + } + + printRespJSON(resp) + + return nil +}