Skip to content
Merged
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
40 changes: 37 additions & 3 deletions cmd/commands/cmd_splice.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package commands

import (
"errors"
"fmt"
"io"

"github.com/lightningnetwork/lnd"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/urfave/cli"
)
Expand Down Expand Up @@ -53,8 +55,8 @@ var spliceCommand = cli.Command{
{
Name: "in",
Usage: "Add funds from the wallet to the channel.",
ArgsUsage: "--chan_point=X --amt=N | --max",
Flags: spliceFlags,
ArgsUsage: "--chan_point=X --amt=N | --max [--utxo=O ...]",
Flags: append(spliceFlags, utxoFlag),
Action: actionDecorator(spliceIn),
},
{
Expand All @@ -77,12 +79,33 @@ var spliceCommand = cli.Command{
Flags: append(estimateFlags(), cli.StringFlag{
Name: "addr",
Usage: "for out: where the funds would go",
}),
}, utxoFlag),
Action: actionDecorator(spliceEstimate),
},
},
}

// utxoFlag chooses the wallet coins a splice in may spend.
var utxoFlag = cli.StringSliceFlag{
Name: "utxo",
Usage: "for in: a wallet coin, txid:index, the splice may spend; " +
"repeat it for several. Only these are spent (the amount, " +
"or with --max all of them); without it the wallet chooses",
}

// spliceUtxos parses the --utxo flags.
func spliceUtxos(ctx *cli.Context) ([]*lnrpc.OutPoint, error) {
if !ctx.IsSet("utxo") {
return nil, nil
}
outpoints, err := lnd.UtxosToOutpoints(ctx.StringSlice("utxo"))
if err != nil {
return nil, fmt.Errorf("unable to decode utxos: %w", err)
}

return outpoints, nil
}

// estimateFlags are the splice flags an estimate takes: all but
// force_low_fee, which it can't carry; an estimate below the floor says so.
func estimateFlags() []cli.Flag {
Expand Down Expand Up @@ -118,6 +141,10 @@ func runSplice(ctx *cli.Context, dir lnrpc.SpliceDirection) error {
if !ctx.IsSet("amt") && !ctx.Bool("max") {
return errors.New("amt or max required")
}
outpoints, err := spliceUtxos(ctx)
if err != nil {
return err
}

stream, err := client.SpliceChannel(ctxc, &lnrpc.SpliceChannelRequest{
ChannelPoint: chanPoint,
Expand All @@ -128,6 +155,7 @@ func runSplice(ctx *cli.Context, dir lnrpc.SpliceDirection) error {
TargetConf: int32(ctx.Int64("conf_target")),
Addr: ctx.String("addr"),
ForceLowFee: ctx.Bool("force_low_fee"),
Outpoints: outpoints,
})
if err != nil {
return err
Expand Down Expand Up @@ -172,6 +200,11 @@ func spliceEstimate(ctx *cli.Context) error {
return errors.New("in or out required")
}

outpoints, err := spliceUtxos(ctx)
if err != nil {
return err
}

resp, err := client.EstimateSplice(ctxc, &lnrpc.EstimateSpliceRequest{
ChannelPoint: chanPoint,
Direction: dir,
Expand All @@ -180,6 +213,7 @@ func spliceEstimate(ctx *cli.Context) error {
SatPerVbyte: ctx.Uint64("sat_per_vbyte"),
TargetConf: int32(ctx.Int64("conf_target")),
Addr: ctx.String("addr"),
Outpoints: outpoints,
})
if err != nil {
return err
Expand Down
8 changes: 6 additions & 2 deletions docs/blake2b.md
Original file line number Diff line number Diff line change
Expand Up @@ -516,12 +516,16 @@ From the command line:

```sh
lncli splice estimate in --chan_point=<txid:index> --amt=<sats>
lncli splice in --chan_point=<txid:index> --amt=<sats> [--sat_per_vbyte=N]
lncli splice in --chan_point=<txid:index> --amt=<sats> [--sat_per_vbyte=N] [--utxo=<txid:index> ...]
lncli splice out --chan_point=<txid:index> --amt=<sats> [--addr=<address>]
```

`--max` takes what the wallet can spend, or what the channel lets go,
after the fee. Without `--addr` funds moved out go to a new address of
after the fee. `--utxo` (repeatable; `outpoints` in the RPC) limits the
coins a splice in may spend to those: the amount comes from them, or with
`--max` all of them go in. The coins left out count towards what the
wallet keeps for fee-bumping its anchor channels' closes, so the chosen ones
needn't keep it back themselves. Without `--addr` funds moved out go to a new address of
the wallet; a channel opened with a close address only moves funds there.
The amount moved out is what arrives; the fee comes from this node's side
of the channel. `lncli listchannels` shows a splice in progress as
Expand Down
686 changes: 358 additions & 328 deletions lnrpc/lightning.pb.go

Large diffs are not rendered by default.

18 changes: 17 additions & 1 deletion lnrpc/lightning.proto
Original file line number Diff line number Diff line change
Expand Up @@ -1777,6 +1777,13 @@ message EstimateSpliceRequest {
node's wallet.
*/
string addr = 7;

/*
For SPLICE_IN, the only coins of the wallet the splice may spend (coin
control), at most 251. Only confirmed P2WKH and P2TR coins can be spent,
never coinbase outputs. Empty: the wallet chooses.
*/
repeated OutPoint outpoints = 8;
}

message EstimateSpliceResponse {
Expand Down Expand Up @@ -1827,7 +1834,9 @@ message EstimateSpliceResponse {

/*
Empty when the splice is possible; otherwise why not: "insufficient_funds",
"immature_inputs", "below_min", "above_max_out", "fee_below_floor",
"immature_inputs", "unknown_coin" (an outpoint asked for isn't a coin the
splice can spend: only confirmed P2WKH and P2TR coins, never coinbase
outputs, at most 251), "below_min", "above_max_out", "fee_below_floor",
"reserve_on_change", "close_address_set", "node_cap", or one of
Channel.splice_status's values.
*/
Expand Down Expand Up @@ -1864,6 +1873,13 @@ message SpliceChannelRequest {
fee.
*/
bool force_low_fee = 8;

/*
For SPLICE_IN, the only coins of the wallet the splice may spend (coin
control), at most 251. Only confirmed P2WKH and P2TR coins can be spent,
never coinbase outputs. Empty: the wallet chooses.
*/
repeated OutPoint outpoints = 9;
}

message SpliceUpdate {
Expand Down
18 changes: 17 additions & 1 deletion lnrpc/lightning.swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -5126,6 +5126,14 @@
"addr": {
"type": "string",
"description": "For SPLICE_OUT, the address the funds go to. Empty: a new address of this\nnode's wallet."
},
"outpoints": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/lnrpcOutPoint"
},
"description": "For SPLICE_IN, the only coins of the wallet the splice may spend (coin\ncontrol), at most 251. Only confirmed P2WKH and P2TR coins can be spent,\nnever coinbase outputs. Empty: the wallet chooses."
}
}
},
Expand Down Expand Up @@ -5202,7 +5210,7 @@
},
"refusal_code": {
"type": "string",
"description": "Empty when the splice is possible; otherwise why not: \"insufficient_funds\",\n\"immature_inputs\", \"below_min\", \"above_max_out\", \"fee_below_floor\",\n\"reserve_on_change\", \"close_address_set\", \"node_cap\", or one of\nChannel.splice_status's values."
"description": "Empty when the splice is possible; otherwise why not: \"insufficient_funds\",\n\"immature_inputs\", \"unknown_coin\" (an outpoint asked for isn't a coin the\nsplice can spend: only confirmed P2WKH and P2TR coins, never coinbase\noutputs, at most 251), \"below_min\", \"above_max_out\", \"fee_below_floor\",\n\"reserve_on_change\", \"close_address_set\", \"node_cap\", or one of\nChannel.splice_status's values."
}
}
},
Expand Down Expand Up @@ -8094,6 +8102,14 @@
"force_low_fee": {
"type": "boolean",
"description": "Accept a fee rate below min_sat_per_vbyte. Never below the minimum relay\nfee."
},
"outpoints": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/lnrpcOutPoint"
},
"description": "For SPLICE_IN, the only coins of the wallet the splice may spend (coin\ncontrol), at most 251. Only confirmed P2WKH and P2TR coins can be spent,\nnever coinbase outputs. Empty: the wallet chooses."
}
}
},
Expand Down
72 changes: 72 additions & 0 deletions lnwallet/splice/fund_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,75 @@ func TestMoveOut(t *testing.T) {
func lntypesWeight(w int64) lntypes.WeightUnit {
return lntypes.WeightUnit(w)
}

// TestOnlyCoins checks coin control for funds in: only the coins asked for
// are candidates, the coins left out count towards the wallet's reserve, and
// a coin the splice can't spend, or one asked for twice, is refused.
func TestOnlyCoins(t *testing.T) {
t.Parallel()

const feeRate = chainfee.SatPerKWeight(2_500)
change := p2tr(0xee)

big := coin(1, p2wpkh(1), 500_000)
small := coin(2, p2tr(2), 120_000)
other := coin(3, p2wpkh(3), 80_000)
all := []Coin{big, small, other}

// No outpoints: everything, the reserve unchanged.
coins, keep, err := OnlyCoins(all, nil, 50_000)
require.NoError(t, err)
require.Equal(t, all, coins)
require.Equal(t, btcutil.Amount(50_000), keep)

// Only small: the other two, 580,000, more than cover the reserve.
coins, keep, err = OnlyCoins(all, []wire.OutPoint{small.OutPoint},
50_000)
require.NoError(t, err)
require.Equal(t, []Coin{small}, coins)
require.Zero(t, keep)

// With an amount the larger coin isn't touched, even though the
// wallet would pick it first.
sel, err := SelectAddFunds(
testShape, coins, 100_000, false, feeRate, change, dustLimit,
keep,
)
require.NoError(t, err)
require.Equal(t, []Coin{small}, sel.Inputs)

// With max, all of the chosen coin goes in: no change kept back for
// a reserve the rest of the wallet holds.
sel, err = SelectAddFunds(
testShape, coins, 0, true, feeRate, change, dustLimit, keep,
)
require.NoError(t, err)
require.Equal(t, []Coin{small}, sel.Inputs)
require.Zero(t, sel.Change)

// Coins left out worth less than the reserve: the rest stays with
// the chosen ones.
_, keep, err = OnlyCoins(
all, []wire.OutPoint{big.OutPoint, small.OutPoint}, 100_000,
)
require.NoError(t, err)
require.Equal(t, btcutil.Amount(20_000), keep)

// A coin the splice can't spend, or one given twice, is refused.
unknown := coin(9, p2wpkh(9), 1_000).OutPoint
_, _, err = OnlyCoins(all, []wire.OutPoint{unknown}, 0)
require.ErrorIs(t, err, ErrUnknownCoin)
_, _, err = OnlyCoins(
all, []wire.OutPoint{small.OutPoint, small.OutPoint}, 0,
)
require.ErrorIs(t, err, ErrUnknownCoin)

// More coins than a splice takes: refused, not quietly cut.
many := make([]wire.OutPoint, interactivetx.MaxInputsOrOutputs)
for i := range many {
many[i] = wire.OutPoint{Index: uint32(i)}
}
_, _, err = OnlyCoins(all, many, 0)
require.ErrorIs(t, err, ErrUnknownCoin)
require.ErrorContains(t, err, "at most 251")
}
15 changes: 14 additions & 1 deletion lnwallet/splice/initiator.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/keychain"
Expand Down Expand Up @@ -42,6 +43,10 @@ type Request struct {

// Destination is where funds moved out go.
Destination []byte

// Outpoints, for funds in, are the only coins the splice may spend
// (coin control); empty, the wallet chooses among all of them.
Outpoints []wire.OutPoint
}

// State is a splice's progress as reported to whoever started it.
Expand Down Expand Up @@ -238,10 +243,16 @@ func (m *Machine) prepareFunds(a *active, req Request) error {
if err != nil {
return err
}
coins, keep, err := OnlyCoins(
coins, req.Outpoints, reserve,
)
if err != nil {
return err
}
sel, err = SelectAddFunds(
shape, coins, req.Amount, req.UseMax,
req.FeeRate, changeScript, m.cfg.DustLimit,
reserve,
keep,
)
if err != nil {
return err
Expand All @@ -259,6 +270,8 @@ func (m *Machine) prepareFunds(a *active, req Request) error {
return refused(codeInsufficientFunds, "%v", err)
case errors.Is(err, ErrBelowDust):
return refused("below_min", "%v", err)
case errors.Is(err, ErrUnknownCoin):
return refused(codeUnknownCoin, "%v", err)
case err != nil:
return refused(codeInternal, "%v", err)
}
Expand Down
1 change: 1 addition & 0 deletions lnwallet/splice/machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ const (
codePeerContribUnsupp = "peer_contribution_unsupported"
codePeerMisbehaved = "peer_misbehaved"
codeInsufficientFunds = "insufficient_funds"
codeUnknownCoin = "unknown_coin"
codeDisconnected = "peer_disconnected"
)

Expand Down
59 changes: 59 additions & 0 deletions lnwallet/splice/wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import (
"strings"
"time"

"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcwallet/wtxmgr"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/interactivetx"
"github.com/lightningnetwork/lnd/lnwire"
)

Expand Down Expand Up @@ -191,6 +193,63 @@ type CoinSource interface {
FetchTx(chainhash.Hash) (*wire.MsgTx, error)
}

// ErrUnknownCoin is returned when a coin asked for isn't one a splice can
// spend: not in the wallet's default account, unconfirmed, spent or leased,
// not P2WKH or P2TR (the only kinds a splice spends), or a coinbase output
// (never spent by a splice; miners paying to their LND wallet meet this one).
// Also when more coins are asked for than a splice takes.
var ErrUnknownCoin = errors.New("coin not spendable by a splice (a splice " +
"spends only confirmed P2WKH and P2TR coins of the default account, " +
"never coinbase outputs)")

// OnlyCoins narrows coins to the ones at outpoints, for a splice that may
// spend only those (coin control), and lowers the reserve kept for
// fee-bumping anchor closes by what the coins left out hold: they stay in
// the wallet, so the chosen ones needn't keep it themselves. With no
// outpoints it returns coins and reserve as they are.
func OnlyCoins(coins []Coin, outpoints []wire.OutPoint,
reserve btcutil.Amount) ([]Coin, btcutil.Amount, error) {

if len(outpoints) == 0 {
return coins, reserve, nil
}

// A splice takes at most this many of our inputs (SelectAddFunds caps
// the candidates there): more would quietly leave the rest out.
if len(outpoints) > interactivetx.MaxInputsOrOutputs-1 {
return nil, 0, fmt.Errorf("%w: %d coins chosen, a splice takes "+
"at most %d", ErrUnknownCoin, len(outpoints),
interactivetx.MaxInputsOrOutputs-1)
}

want := make(map[wire.OutPoint]bool, len(outpoints))
for _, op := range outpoints {
if want[op] {
return nil, 0, fmt.Errorf("%w: %v given twice",
ErrUnknownCoin, op)
}
want[op] = true
}

var (
chosen []Coin
left btcutil.Amount
)
for _, c := range coins {
if want[c.OutPoint] {
chosen = append(chosen, c)
delete(want, c.OutPoint)
} else {
left += c.Value
}
}
for op := range want {
return nil, 0, fmt.Errorf("%w: %v", ErrUnknownCoin, op)
}

return chosen, max(0, reserve-left), nil
}

// ListCoins returns the confirmed coins of the default account a splice can
// spend, with their transactions. The caller holds the wallet's coin
// selection lock through selection and leasing.
Expand Down
Loading
Loading