From c8922279533e147b818ddbad2d16ab8799a28111 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Tue, 31 Mar 2020 09:13:20 +0200 Subject: [PATCH] lncli: add PSBT to openchannel command We add a new flag --psbt to the openchannel command which triggers an interactive conversation between the command line and the user. --- cmd/lncli/cmd_open_channel.go | 450 +++++++++++++++++++++++++++++++--- 1 file changed, 417 insertions(+), 33 deletions(-) diff --git a/cmd/lncli/cmd_open_channel.go b/cmd/lncli/cmd_open_channel.go index ef2ccc2e3..d86b65cf0 100644 --- a/cmd/lncli/cmd_open_channel.go +++ b/cmd/lncli/cmd_open_channel.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/rand" + "encoding/base64" "encoding/hex" "fmt" "io" @@ -9,10 +11,39 @@ import ( "strings" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcutil" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet/chanfunding" + "github.com/lightningnetwork/lnd/signal" "github.com/urfave/cli" ) +const ( + defaultUtxoMinConf = 1 + userMsgFund = `PSBT funding initiated with peer %x. +Please create a PSBT that sends %v (%d satoshi) to the funding address %s. + +Note: The whole process should be completed within 10 minutes, otherwise there +is a risk of the remote node timing out and canceling the funding process. + +Example with bitcoind: + bitcoin-cli walletcreatefundedpsbt [] '[{"%s":%.8f}]' + +If you are using a wallet that can fund a PSBT directly (currently not possible +with bitcoind), you can use this PSBT that contains the same address and amount: +%s + +Paste the funded PSBT here to continue the funding flow. +Base64 encoded PSBT: ` + + userMsgSign = ` +PSBT verified by lnd, please continue the funding flow by signing the PSBT by +all required parties/devices. Once the transaction is fully signed, paste it +again here. + +Base64 encoded signed PSBT: ` +) + // TODO(roasbeef): change default number of confirmations var openChannelCommand = cli.Command{ Name: "openchannel", @@ -104,7 +135,7 @@ var openChannelCommand = cli.Command{ Usage: "(optional) the minimum number of confirmations " + "each one of your outputs used for the funding " + "transaction must satisfy", - Value: 1, + Value: defaultUtxoMinConf, }, cli.StringFlag{ Name: "close_address", @@ -113,6 +144,21 @@ var openChannelCommand = cli.Command{ "value is set on channel open, you will *not* be " + "able to cooperatively close to a different address.", }, + cli.BoolFlag{ + Name: "psbt", + Usage: "start an interactive mode that initiates " + + "funding through a partially signed bitcoin " + + "transaction (PSBT), allowing the channel " + + "funds to be added and signed from a hardware " + + "or other offline device.", + }, + cli.StringFlag{ + Name: "base_psbt", + Usage: "when using the interactive PSBT mode to open " + + "a new channel, use this base64 encoded PSBT " + + "as a base and add the new channel output to " + + "it instead of creating a new, empty one.", + }, }, Action: actionDecorator(openChannel), } @@ -128,7 +174,7 @@ func openChannel(ctx *cli.Context) error { // Show command help if no arguments provided if ctx.NArg() == 0 && ctx.NumFlags() == 0 { - cli.ShowCommandHelp(ctx, "openchannel") + _ = cli.ShowCommandHelp(ctx, "openchannel") return nil } @@ -209,6 +255,12 @@ func openChannel(ctx *cli.Context) error { req.Private = ctx.Bool("private") + // PSBT funding is a more involved, interactive process that is too + // large to also fit into this already long function. + if ctx.Bool("psbt") { + return openChannelPsbt(ctx, client, req) + } + stream, err := client.OpenChannel(ctxb, req) if err != nil { return err @@ -224,54 +276,386 @@ func openChannel(ctx *cli.Context) error { switch update := resp.Update.(type) { case *lnrpc.OpenStatusUpdate_ChanPending: - txid, err := chainhash.NewHash(update.ChanPending.Txid) + err := printChanPending(update) if err != nil { return err } - printJSON(struct { - FundingTxid string `json:"funding_txid"` - }{ - FundingTxid: txid.String(), - }, - ) - if !ctx.Bool("block") { return nil } case *lnrpc.OpenStatusUpdate_ChanOpen: - channelPoint := update.ChanOpen.ChannelPoint + return printChanOpen(update) + } + } +} - // A channel point's funding txid can be get/set as a - // byte slice or a string. In the case it is a string, - // decode it. - var txidHash []byte - switch channelPoint.GetFundingTxid().(type) { - case *lnrpc.ChannelPoint_FundingTxidBytes: - txidHash = channelPoint.GetFundingTxidBytes() - case *lnrpc.ChannelPoint_FundingTxidStr: - s := channelPoint.GetFundingTxidStr() - h, err := chainhash.NewHashFromStr(s) - if err != nil { - return err - } +// openChannelPsbt starts an interactive channel open protocol that uses a +// partially signed bitcoin transaction (PSBT) to fund the channel output. The +// protocol involves several steps between the RPC server and the CLI client: +// +// RPC server CLI client +// | | +// | |<------open channel (stream)-----| +// | |-------ready for funding----->| | +// | |<------PSBT verify------------| | +// | |-------ready for signing----->| | +// | |<------PSBT finalize----------| | +// | |-------channel pending------->| | +// | |-------channel open------------->| +// | | +func openChannelPsbt(ctx *cli.Context, client lnrpc.LightningClient, + req *lnrpc.OpenChannelRequest) error { - txidHash = h[:] + var ( + pendingChanID [32]byte + shimPending = true + basePsbtBytes []byte + quit = make(chan struct{}) + srvMsg = make(chan *lnrpc.OpenStatusUpdate, 1) + srvErr = make(chan error, 1) + ctxc, cancel = context.WithCancel(context.Background()) + ) + defer cancel() + + // Make sure the user didn't supply any command line flags that are + // incompatible with PSBT funding. + err := checkPsbtFlags(req) + if err != nil { + return err + } + + // If the user supplied a base PSBT, only make sure it's valid base64. + // The RPC server will make sure it's also a valid PSBT. + basePsbt := ctx.String("base_psbt") + if basePsbt != "" { + basePsbtBytes, err = base64.StdEncoding.DecodeString(basePsbt) + if err != nil { + return fmt.Errorf("error parsing base PSBT: %v", err) + } + } + + // Generate a new, random pending channel ID that we'll use as the main + // identifier when sending update messages to the RPC server. + if _, err := rand.Read(pendingChanID[:]); err != nil { + return fmt.Errorf("unable to generate random chan ID: %v", err) + } + fmt.Printf("Starting PSBT funding flow with pending channel ID %x.\n", + pendingChanID) + + // maybeCancelShim is a helper function that cancels the funding shim + // with the RPC server in case we end up aborting early. + maybeCancelShim := func() { + // If the user canceled while there was still a shim registered + // with the wallet, release the resources now. + if shimPending { + fmt.Printf("Canceling PSBT funding flow for pending "+ + "channel ID %x.\n", pendingChanID) + cancelMsg := &lnrpc.FundingTransitionMsg{ + Trigger: &lnrpc.FundingTransitionMsg_ShimCancel{ + ShimCancel: &lnrpc.FundingShimCancel{ + PendingChanId: pendingChanID[:], + }, + }, + } + err := sendFundingState(ctxc, ctx, cancelMsg) + if err != nil { + fmt.Printf("Error canceling shim: %v\n", err) + } + shimPending = false + } + + // Abort the stream connection to the server. + cancel() + } + defer maybeCancelShim() + + // Create the PSBT funding shim that will tell the funding manager we + // want to use a PSBT. + req.FundingShim = &lnrpc.FundingShim{ + Shim: &lnrpc.FundingShim_PsbtShim{ + PsbtShim: &lnrpc.PsbtShim{ + PendingChanId: pendingChanID[:], + BasePsbt: basePsbtBytes, + }, + }, + } + + // Start the interactive process by opening the stream connection to the + // daemon. If the user cancels by pressing we need to cancel + // the shim. To not just kill the process on interrupt, we need to + // explicitly capture the signal. + stream, err := client.OpenChannel(ctxc, req) + if err != nil { + return fmt.Errorf("opening stream to server failed: %v", err) + } + signal.Intercept() + + // We also need to spawn a goroutine that reads from the server. This + // will copy the messages to the channel as long as they come in or add + // exactly one error to the error stream and then bail out. + go func() { + for { + // Recv blocks until a message or error arrives. + resp, err := stream.Recv() + if err == io.EOF { + srvErr <- fmt.Errorf("lnd shutting down: %v", + err) + return + } else if err != nil { + srvErr <- fmt.Errorf("got error from server: "+ + "%v", err) + return } - txid, err := chainhash.NewHash(txidHash) + // Don't block on sending in case of shutting down. + select { + case srvMsg <- resp: + case <-quit: + return + } + } + }() + + // Spawn another goroutine that only handles abort from user or errors + // from the server. Both will trigger an attempt to cancel the shim with + // the server. + go func() { + select { + case <-signal.ShutdownChannel(): + fmt.Printf("\nInterrupt signal received.\n") + close(quit) + + case err := <-srvErr: + fmt.Printf("\nError received: %v\n", err) + + // If the remote peer canceled on us, the reservation + // has already been deleted. We don't need to try to + // remove it again, this would just produce another + // error. + cancelErr := chanfunding.ErrRemoteCanceled.Error() + if err != nil && strings.Contains(err.Error(), cancelErr) { + shimPending = false + } + close(quit) + + case <-quit: + } + }() + + // Our main event loop where we wait for triggers + for { + var srvResponse *lnrpc.OpenStatusUpdate + select { + case srvResponse = <-srvMsg: + case <-quit: + return nil + } + + switch update := srvResponse.Update.(type) { + case *lnrpc.OpenStatusUpdate_PsbtFund: + // First tell the user how to create the PSBT with the + // address and amount we now know. + amt := btcutil.Amount(update.PsbtFund.FundingAmount) + addr := update.PsbtFund.FundingAddress + fmt.Printf( + userMsgFund, req.NodePubkey, amt, amt, addr, + addr, amt.ToBTC(), + base64.StdEncoding.EncodeToString( + update.PsbtFund.Psbt, + ), + ) + + // Read the user's response and send it to the server to + // verify everything's correct before anything is + // signed. + psbtBase64, err := readLine(quit) + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("reading from console "+ + "failed: %v", err) + } + psbt, err := base64.StdEncoding.DecodeString( + strings.TrimSpace(psbtBase64), + ) + if err != nil { + return fmt.Errorf("base64 decode failed: %v", + err) + } + verifyMsg := &lnrpc.FundingTransitionMsg{ + Trigger: &lnrpc.FundingTransitionMsg_PsbtVerify{ + PsbtVerify: &lnrpc.FundingPsbtVerify{ + FundedPsbt: psbt, + PendingChanId: pendingChanID[:], + }, + }, + } + err = sendFundingState(ctxc, ctx, verifyMsg) + if err != nil { + return fmt.Errorf("verifying PSBT by lnd "+ + "failed: %v", err) + } + + // Now that we know the PSBT looks good, we can let it + // be signed by the user. + fmt.Print(userMsgSign) + + // Read the signed PSBT and send it to lnd. + psbtBase64, err = readLine(quit) + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("reading from console "+ + "failed: %v", err) + } + psbt, err = base64.StdEncoding.DecodeString( + strings.TrimSpace(psbtBase64), + ) + if err != nil { + return fmt.Errorf("base64 decode failed: %v", + err) + } + finalizeMsg := &lnrpc.FundingTransitionMsg{ + Trigger: &lnrpc.FundingTransitionMsg_PsbtFinalize{ + PsbtFinalize: &lnrpc.FundingPsbtFinalize{ + SignedPsbt: psbt, + PendingChanId: pendingChanID[:], + }, + }, + } + err = sendFundingState(ctxc, ctx, finalizeMsg) + if err != nil { + return fmt.Errorf("finalizing PSBT funding "+ + "flow failed: %v", err) + } + + case *lnrpc.OpenStatusUpdate_ChanPending: + // As soon as the channel is pending, there is no more + // shim that needs to be canceled. If the user + // interrupts now, we don't need to clean up anything. + shimPending = false + + err := printChanPending(update) if err != nil { return err } - index := channelPoint.OutputIndex - printJSON(struct { - ChannelPoint string `json:"channel_point"` - }{ - ChannelPoint: fmt.Sprintf("%v:%v", txid, index), - }, - ) + if !ctx.Bool("block") { + return nil + } + + case *lnrpc.OpenStatusUpdate_ChanOpen: + return printChanOpen(update) } } } + +// printChanOpen prints the channel point of the channel open message. +func printChanOpen(update *lnrpc.OpenStatusUpdate_ChanOpen) error { + channelPoint := update.ChanOpen.ChannelPoint + + // A channel point's funding txid can be get/set as a + // byte slice or a string. In the case it is a string, + // decode it. + var txidHash []byte + switch channelPoint.GetFundingTxid().(type) { + case *lnrpc.ChannelPoint_FundingTxidBytes: + txidHash = channelPoint.GetFundingTxidBytes() + case *lnrpc.ChannelPoint_FundingTxidStr: + s := channelPoint.GetFundingTxidStr() + h, err := chainhash.NewHashFromStr(s) + if err != nil { + return err + } + + txidHash = h[:] + } + + txid, err := chainhash.NewHash(txidHash) + if err != nil { + return err + } + + index := channelPoint.OutputIndex + printJSON(struct { + ChannelPoint string `json:"channel_point"` + }{ + ChannelPoint: fmt.Sprintf("%v:%v", txid, index), + }) + return nil +} + +// printChanPending prints the funding transaction ID of the channel pending +// message. +func printChanPending(update *lnrpc.OpenStatusUpdate_ChanPending) error { + txid, err := chainhash.NewHash(update.ChanPending.Txid) + if err != nil { + return err + } + + printJSON(struct { + FundingTxid string `json:"funding_txid"` + }{ + FundingTxid: txid.String(), + }) + return nil +} + +// readLine reads a line from standard in but does not block in case of a +// system interrupt like syscall.SIGINT (Ctrl+C). +func readLine(quit chan struct{}) (string, error) { + msg := make(chan string, 1) + + // In a normal console, reading from stdin won't signal EOF when the + // user presses Ctrl+C. That's why we need to put this in a separate + // goroutine so it doesn't block. + go func() { + for { + var str string + _, _ = fmt.Scan(&str) + msg <- str + return + } + }() + for { + select { + case <-quit: + return "", io.EOF + + case str := <-msg: + return str, nil + } + } +} + +// checkPsbtFlags make sure a request to open a channel doesn't set any +// parameters that are incompatible with the PSBT funding flow. +func checkPsbtFlags(req *lnrpc.OpenChannelRequest) error { + if req.MinConfs != defaultUtxoMinConf || req.SpendUnconfirmed { + return fmt.Errorf("specifying minimum confirmations for PSBT " + + "funding is not supported") + } + if req.TargetConf != 0 || req.SatPerByte != 0 { + return fmt.Errorf("setting fee estimation parameters not " + + "supported for PSBT funding") + } + return nil +} + +// sendFundingState sends a single funding state step message by using a new +// client connection. This is necessary if the whole funding flow takes longer +// than the default macaroon timeout, then we cannot use a single client +// connection. +func sendFundingState(cancelCtx context.Context, cliCtx *cli.Context, + msg *lnrpc.FundingTransitionMsg) error { + + client, cleanUp := getClient(cliCtx) + defer cleanUp() + + _, err := client.FundingStateStep(cancelCtx, msg) + return err +}