From c206d062d5b6ac74c30a9940d55ef597f6f99cf8 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Thu, 1 Oct 2020 16:21:48 +0200 Subject: [PATCH] lncli: add new PSBT wallet commands --- cmd/lncli/walletrpc_active.go | 351 +++++++++++++++++++++++++++++++++- 1 file changed, 348 insertions(+), 3 deletions(-) diff --git a/cmd/lncli/walletrpc_active.go b/cmd/lncli/walletrpc_active.go index b9e71da0..8dde7a1f 100644 --- a/cmd/lncli/walletrpc_active.go +++ b/cmd/lncli/walletrpc_active.go @@ -4,6 +4,9 @@ package main import ( "context" + "encoding/base64" + "encoding/hex" + "encoding/json" "errors" "fmt" "sort" @@ -14,6 +17,20 @@ import ( "github.com/urfave/cli" ) +var ( + // psbtCommand is a wallet subcommand that is responsible for PSBT + // operations. + psbtCommand = cli.Command{ + Name: "psbt", + Usage: "Interact with partially signed bitcoin transactions " + + "(PSBTs).", + Subcommands: []cli.Command{ + fundPsbtCommand, + finalizePsbtCommand, + }, + } +) + // walletCommands will return the set of commands to enable for walletrpc // builds. func walletCommands() []cli.Command { @@ -29,6 +46,8 @@ func walletCommands() []cli.Command { bumpCloseFeeCommand, listSweepsCommand, labelTxCommand, + releaseOutputCommand, + psbtCommand, }, }, } @@ -304,9 +323,8 @@ func getWaitingCloseCommitments(client lnrpc.LightningClient, } var listSweepsCommand = cli.Command{ - Name: "listsweeps", - Category: "On-chain", - Usage: "Lists all sweeps that have been published by our node.", + Name: "listsweeps", + Usage: "Lists all sweeps that have been published by our node.", Flags: []cli.Flag{ cli.BoolFlag{ Name: "verbose", @@ -396,3 +414,330 @@ func labelTransaction(ctx *cli.Context) error { return nil } + +// fundPsbtResponse is a struct that contains JSOn annotations for nice result +// serialization. +type fundPsbtResponse struct { + Psbt string `json:"psbt"` + ChangeOutputIndex int32 `json:"change_output_index"` + Locks []*walletrpc.UtxoLease `json:"locks"` +} + +var fundPsbtCommand = cli.Command{ + Name: "fund", + Usage: "Fund a Partially Signed Bitcoin Transaction (PSBT).", + ArgsUsage: "[--template_psbt=T | [--outputs=O [--inputs=I]]] " + + "[--conf_target=C | --sat_per_vbyte=S]", + Description: ` + The fund command creates a fully populated PSBT that contains enough + inputs to fund the outputs specified in either the PSBT or the + --outputs flag. + + If there are no inputs specified in the template (or --inputs flag), + coin selection is performed automatically. If inputs are specified, the + wallet assumes that full coin selection happened externally and it will + not add any additional inputs to the PSBT. If the specified inputs + aren't enough to fund the outputs with the given fee rate, an error is + returned. + + After either selecting or verifying the inputs, all input UTXOs are + locked with an internal app ID. + + The 'outputs' flag decodes addresses and the amount to send respectively + in the following JSON format: + + --outputs='{"ExampleAddr": NumCoinsInSatoshis, "SecondAddr": Sats}' + + The optional 'inputs' flag decodes a JSON list of UTXO outpoints as + returned by the listunspent command for example: + + --inputs='[":",":",...]' + `, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "template_psbt", + Usage: "the outputs to fund and optional inputs to " + + "spend provided in the base64 PSBT format", + }, + cli.StringFlag{ + Name: "outputs", + Usage: "a JSON compatible map of destination " + + "addresses to amounts to send, must not " + + "include a change address as that will be " + + "added automatically by the wallet", + }, + cli.StringFlag{ + Name: "inputs", + Usage: "an optional JSON compatible list of UTXO " + + "outpoints to use as the PSBT's inputs", + }, + cli.Uint64Flag{ + Name: "conf_target", + Usage: "the number of blocks that the transaction " + + "should be confirmed on-chain within", + Value: 6, + }, + cli.Uint64Flag{ + Name: "sat_per_vbyte", + Usage: "a manual fee expressed in sat/vbyte that " + + "should be used when creating the transaction", + }, + }, + Action: actionDecorator(fundPsbt), +} + +func fundPsbt(ctx *cli.Context) error { + // Display the command's help message if there aren't any flags + // specified. + if ctx.NumFlags() == 0 { + return cli.ShowCommandHelp(ctx, "fund") + } + + req := &walletrpc.FundPsbtRequest{} + + // Parse template flags. + switch { + // The PSBT flag is mutally exclusive with the outputs/inputs flags. + case ctx.IsSet("template_psbt") && + (ctx.IsSet("inputs") || ctx.IsSet("outputs")): + + return fmt.Errorf("cannot set template_psbt and inputs/" + + "outputs flags at the same time") + + // Use a pre-existing PSBT as the transaction template. + case len(ctx.String("template_psbt")) > 0: + psbtBase64 := ctx.String("template_psbt") + psbtBytes, err := base64.StdEncoding.DecodeString(psbtBase64) + if err != nil { + return err + } + + req.Template = &walletrpc.FundPsbtRequest_Psbt{ + Psbt: psbtBytes, + } + + // The user manually specified outputs and optional inputs in JSON + // format. + case len(ctx.String("outputs")) > 0: + var ( + tpl = &walletrpc.TxTemplate{} + amountToAddr map[string]uint64 + ) + + // Parse the address to amount map as JSON now. At least one + // entry must be present. + jsonMap := []byte(ctx.String("outputs")) + if err := json.Unmarshal(jsonMap, &amountToAddr); err != nil { + return fmt.Errorf("error parsing outputs JSON: %v", + err) + } + if len(amountToAddr) == 0 { + return fmt.Errorf("at least one output must be " + + "specified") + } + tpl.Outputs = amountToAddr + + // Inputs are optional. + if len(ctx.String("inputs")) > 0 { + var inputs []string + + jsonList := []byte(ctx.String("inputs")) + if err := json.Unmarshal(jsonList, &inputs); err != nil { + return fmt.Errorf("error parsing inputs JSON: "+ + "%v", err) + } + + for idx, input := range inputs { + op, err := NewProtoOutPoint(input) + if err != nil { + return fmt.Errorf("error parsing "+ + "UTXO outpoint %d: %v", idx, + err) + } + tpl.Inputs = append(tpl.Inputs, op) + } + } + + req.Template = &walletrpc.FundPsbtRequest_Raw{ + Raw: tpl, + } + + default: + return fmt.Errorf("must specify either template_psbt or " + + "outputs flag") + } + + // Parse fee flags. + switch { + case ctx.IsSet("conf_target") && ctx.IsSet("sat_per_vbyte"): + return fmt.Errorf("cannot set conf_target and sat_per_vbyte " + + "at the same time") + + case ctx.Uint64("conf_target") > 0: + req.Fees = &walletrpc.FundPsbtRequest_TargetConf{ + TargetConf: uint32(ctx.Uint64("conf_target")), + } + + case ctx.Uint64("sat_per_vbyte") > 0: + req.Fees = &walletrpc.FundPsbtRequest_SatPerVbyte{ + SatPerVbyte: uint32(ctx.Uint64("sat_per_vbyte")), + } + } + + walletClient, cleanUp := getWalletClient(ctx) + defer cleanUp() + + response, err := walletClient.FundPsbt(context.Background(), req) + if err != nil { + return err + } + + printJSON(&fundPsbtResponse{ + Psbt: base64.StdEncoding.EncodeToString( + response.FundedPsbt, + ), + ChangeOutputIndex: response.ChangeOutputIndex, + Locks: response.LockedUtxos, + }) + + return nil +} + +// finalizePsbtResponse is a struct that contains JSON annotations for nice +// result serialization. +type finalizePsbtResponse struct { + Psbt string `json:"psbt"` + FinalTx string `json:"final_tx"` +} + +var finalizePsbtCommand = cli.Command{ + Name: "finalize", + Usage: "Finalize a Partially Signed Bitcoin Transaction (PSBT).", + ArgsUsage: "funded_psbt", + Description: ` + The finalize command expects a partial transaction with all inputs + and outputs fully declared and tries to sign all inputs that belong to + the wallet. Lnd must be the last signer of the transaction. That means, + if there are any unsigned non-witness inputs or inputs without UTXO + information attached or inputs without witness data that do not belong + to lnd's wallet, this method will fail. If no error is returned, the + PSBT is ready to be extracted and the final TX within to be broadcast. + + This method does NOT publish the transaction after it's been finalized + successfully. + `, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "funded_psbt", + Usage: "the base64 encoded PSBT to finalize", + }, + }, + Action: actionDecorator(finalizePsbt), +} + +func finalizePsbt(ctx *cli.Context) error { + // Display the command's help message if we do not have the expected + // number of arguments/flags. + if ctx.NArg() != 1 && ctx.NumFlags() != 1 { + return cli.ShowCommandHelp(ctx, "finalize") + } + + var ( + args = ctx.Args() + psbtBase64 string + ) + switch { + case ctx.IsSet("funded_psbt"): + psbtBase64 = ctx.String("funded_psbt") + case args.Present(): + psbtBase64 = args.First() + default: + return fmt.Errorf("funded_psbt argument missing") + } + + psbtBytes, err := base64.StdEncoding.DecodeString(psbtBase64) + if err != nil { + return err + } + req := &walletrpc.FinalizePsbtRequest{ + FundedPsbt: psbtBytes, + } + + walletClient, cleanUp := getWalletClient(ctx) + defer cleanUp() + + response, err := walletClient.FinalizePsbt(context.Background(), req) + if err != nil { + return err + } + + printJSON(&finalizePsbtResponse{ + Psbt: base64.StdEncoding.EncodeToString(response.SignedPsbt), + FinalTx: hex.EncodeToString(response.RawFinalTx), + }) + + return nil +} + +var releaseOutputCommand = cli.Command{ + Name: "releaseoutput", + Usage: "Release an output previously locked by lnd.", + ArgsUsage: "outpoint", + Description: ` + The releaseoutput command unlocks an output, allowing it to be available + for coin selection if it remains unspent. + + The internal lnd app lock ID is used when releasing the output. + Therefore only UTXOs locked by the fundpsbt command can currently be + released with this command. + `, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "outpoint", + Usage: "the output to unlock", + }, + }, + Action: actionDecorator(releaseOutput), +} + +func releaseOutput(ctx *cli.Context) error { + // Display the command's help message if we do not have the expected + // number of arguments/flags. + if ctx.NArg() != 1 && ctx.NumFlags() != 1 { + return cli.ShowCommandHelp(ctx, "releaseoutput") + } + + var ( + args = ctx.Args() + outpointStr string + ) + switch { + case ctx.IsSet("outpoint"): + outpointStr = ctx.String("outpoint") + case args.Present(): + outpointStr = args.First() + default: + return fmt.Errorf("outpoint argument missing") + } + + outpoint, err := NewProtoOutPoint(outpointStr) + if err != nil { + return fmt.Errorf("error parsing outpoint: %v", err) + } + req := &walletrpc.ReleaseOutputRequest{ + Outpoint: outpoint, + Id: walletrpc.LndInternalLockID[:], + } + + walletClient, cleanUp := getWalletClient(ctx) + defer cleanUp() + + response, err := walletClient.ReleaseOutput(context.Background(), req) + if err != nil { + return err + } + + printRespJSON(response) + + return nil +}