Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update to select funding inputs before sending open_channel2 and splice_init #2903

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
36 changes: 35 additions & 1 deletion docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,39 @@

## Major changes

### Channel Splicing

With this release, we add support for the final version of [splicing](https://github.com/lightning/bolts/pull/1160) that was recently added to the BOLTs.
Splicing allows node operators to change the size of their existing channels, which makes it easier and more efficient to allocate liquidity where it is most needed.
Most node operators can now have a single channel with each of their peer, which costs less on-chain fees and resources, and makes path-finding easier.

The size of an existing channel can be increased with the `splicein` API:

```sh
eclair-cli splicein --channelId=<channel_id> --amountIn=<amount_satoshis>
```

Once that transaction confirms, the additional liquidity can be used to send outgoing payments.
If the transaction doesn't confirm, the node operator can speed up confirmation with the `rbfsplice` API:

```sh
eclair-cli rbfsplice --channelId=<channel_id> --targetFeerateSatByte=<feerate_satoshis_per_byte> --fundingFeeBudgetSatoshis=<maximum_on_chain_fee_satoshis>
```

If the node operator wants to reduce the size of a channel, or send some of the channel funds to an on-chain address, they can use the `spliceout` API:

```sh
eclair-cli spliceout --channelId=<channel_id> --amountOut=<amount_satoshis> --scriptPubKey=<on_chain_address>
```

That operation can also be RBF-ed with the `rbfsplice` API to speed up confirmation if necessary.

Note that when 0-conf is used for the channel, it is not possible to RBF splice transactions.
Node operators should instead create a new splice transaction (with `splicein` or `spliceout`) to CPFP the previous transaction.

Note that eclair had already introduced support for a splicing prototype in v0.9.0, which helped improve the BOLT proposal.
We're removing support for the previous splicing prototype feature: users that depended on this protocol must upgrade to create official splice transactions.

### Update minimal version of Bitcoin Core

With this release, eclair requires using Bitcoin Core 27.1.
Expand All @@ -26,7 +59,8 @@ Eclair will not allow remote peers to open new obsolete channels that do not sup

### API changes

- `channelstats` now takes optional parameters `--count` and `--skip` to control pagination. By default, it will return first 10 entries. (#2890)
- `channelstats` now accept `--count` and `--skip` parameters to limit the number of retrieved items (#2890)
- `rbfsplice` lets any channel participant RBF the current unconfirmed splice transaction (#2887)

### Miscellaneous improvements and bug fixes

Expand Down
5 changes: 5 additions & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ eclair {
// node that you trust using override-init-features (see below).
option_zeroconf = disabled
keysend = disabled
option_splice = optional
trampoline_payment_prototype = disabled
async_payment_prototype = disabled
}
Expand Down Expand Up @@ -196,6 +197,10 @@ eclair {

min-time-between-updates = 1 hour // minimum time between channel updates because the balance changed
}

// if > 0, excess channel funding up to this amount will be added to the amount contributed when funding
// dual-funded channels or splices. This is useful to avoid paying fees to add a small change output.
max-excess = 0
}

balance-check-interval = 1 hour
Expand Down
52 changes: 32 additions & 20 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,16 @@ trait Eclair {

def disconnect(nodeId: PublicKey)(implicit timeout: Timeout): Future[String]

def open(nodeId: PublicKey, fundingAmount: Satoshi, pushAmount_opt: Option[MilliSatoshi], channelType_opt: Option[SupportedChannelType], fundingFeerate_opt: Option[FeeratePerByte], fundingFeeBudget_opt: Option[Satoshi], announceChannel_opt: Option[Boolean], openTimeout_opt: Option[Timeout])(implicit timeout: Timeout): Future[OpenChannelResponse]
def open(nodeId: PublicKey, fundingAmount: Satoshi, maxExcess_opt: Option[Satoshi], pushAmount_opt: Option[MilliSatoshi], channelType_opt: Option[SupportedChannelType], fundingFeerate_opt: Option[FeeratePerByte], fundingFeeBudget_opt: Option[Satoshi], announceChannel_opt: Option[Boolean], openTimeout_opt: Option[Timeout])(implicit timeout: Timeout): Future[OpenChannelResponse]

def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]]

def spliceIn(channelId: ByteVector32, amountIn: Satoshi, pushAmount_opt: Option[MilliSatoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]]

def spliceOut(channelId: ByteVector32, amountOut: Satoshi, scriptOrAddress: Either[ByteVector, String])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]]

def rbfSplice(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]]

def close(channels: List[ApiTypes.ChannelIdentifier], scriptPubKey_opt: Option[ByteVector], closingFeerates_opt: Option[ClosingFeerates])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_CLOSE]]]]

def forceClose(channels: List[ApiTypes.ChannelIdentifier])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_FORCECLOSE]]]]
Expand Down Expand Up @@ -206,7 +208,7 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
(appKit.switchboard ? Peer.Disconnect(nodeId)).mapTo[Peer.DisconnectResponse].map(_.toString)
}

override def open(nodeId: PublicKey, fundingAmount: Satoshi, pushAmount_opt: Option[MilliSatoshi], channelType_opt: Option[SupportedChannelType], fundingFeerate_opt: Option[FeeratePerByte], fundingFeeBudget_opt: Option[Satoshi], announceChannel_opt: Option[Boolean], openTimeout_opt: Option[Timeout])(implicit timeout: Timeout): Future[OpenChannelResponse] = {
override def open(nodeId: PublicKey, fundingAmount: Satoshi, maxExcess_opt: Option[Satoshi], pushAmount_opt: Option[MilliSatoshi], channelType_opt: Option[SupportedChannelType], fundingFeerate_opt: Option[FeeratePerByte], fundingFeeBudget_opt: Option[Satoshi], announceChannel_opt: Option[Boolean], openTimeout_opt: Option[Timeout])(implicit timeout: Timeout): Future[OpenChannelResponse] = {
// we want the open timeout to expire *before* the default ask timeout, otherwise user will get a generic response
val openTimeout = openTimeout_opt.getOrElse(Timeout(20 seconds))
// if no budget is provided for the mining fee of the funding tx, we use a default of 0.1% of the funding amount as a safety measure
Expand All @@ -216,6 +218,7 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
open = Peer.OpenChannel(
remoteNodeId = nodeId,
fundingAmount = fundingAmount,
maxExcess_opt = maxExcess_opt,
channelType_opt = channelType_opt,
pushAmount_opt = pushAmount_opt,
fundingTxFeerate_opt = fundingFeerate_opt.map(FeeratePerKw(_)),
Expand All @@ -227,16 +230,18 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
}

override def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]] = {
sendToChannelTyped(channel = Left(channelId),
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong)))
sendToChannelTyped(
channel = Left(channelId),
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong))
)
}

override def spliceIn(channelId: ByteVector32, amountIn: Satoshi, pushAmount_opt: Option[MilliSatoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
sendToChannelTyped(channel = Left(channelId),
cmdBuilder = CMD_SPLICE(_,
spliceIn_opt = Some(SpliceIn(additionalLocalFunding = amountIn, pushAmount = pushAmount_opt.getOrElse(0.msat))),
spliceOut_opt = None
))
val spliceIn = SpliceIn(additionalLocalFunding = amountIn, pushAmount = pushAmount_opt.getOrElse(0.msat))
sendToChannelTyped(
channel = Left(channelId),
cmdBuilder = CMD_SPLICE(_, spliceIn_opt = Some(spliceIn), spliceOut_opt = None)
)
}

override def spliceOut(channelId: ByteVector32, amountOut: Satoshi, scriptOrAddress: Either[ByteVector, String])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
Expand All @@ -247,11 +252,18 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
case Right(script) => Script.write(script)
}
}
sendToChannelTyped(channel = Left(channelId),
cmdBuilder = CMD_SPLICE(_,
spliceIn_opt = None,
spliceOut_opt = Some(SpliceOut(amount = amountOut, scriptPubKey = script))
))
val spliceOut = SpliceOut(amount = amountOut, scriptPubKey = script)
sendToChannelTyped(
channel = Left(channelId),
cmdBuilder = CMD_SPLICE(_, spliceIn_opt = None, spliceOut_opt = Some(spliceOut))
)
}

override def rbfSplice(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]] = {
sendToChannelTyped(
channel = Left(channelId),
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong))
)
}

override def close(channels: List[ApiTypes.ChannelIdentifier], scriptPubKey_opt: Option[ByteVector], closingFeerates_opt: Option[ClosingFeerates])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_CLOSE]]]] = {
Expand Down Expand Up @@ -558,9 +570,9 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
case Left(channelId) => appKit.register ? Register.Forward(null, channelId, request)
case Right(shortChannelId) => appKit.register ? Register.ForwardShortId(null, shortChannelId, request)
}).map {
case t: R@unchecked => t
case t: Register.ForwardFailure[C]@unchecked => throw ChannelNotFound(Left(t.fwd.channelId))
case t: Register.ForwardShortIdFailure[C]@unchecked => throw ChannelNotFound(Right(t.fwd.shortChannelId))
case t: R @unchecked => t
case t: Register.ForwardFailure[C] @unchecked => throw ChannelNotFound(Left(t.fwd.channelId))
case t: Register.ForwardShortIdFailure[C] @unchecked => throw ChannelNotFound(Right(t.fwd.shortChannelId))
}

private def sendToChannelTyped[C <: Command, R <: CommandResponse[C]](channel: ApiTypes.ChannelIdentifier, cmdBuilder: akka.actor.typed.ActorRef[Any] => C)(implicit timeout: Timeout): Future[R] =
Expand All @@ -571,9 +583,9 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
case Right(shortChannelId) => Register.ForwardShortId(replyTo, shortChannelId, cmd)
}
}.map {
case t: R@unchecked => t
case t: Register.ForwardFailure[C]@unchecked => throw ChannelNotFound(Left(t.fwd.channelId))
case t: Register.ForwardShortIdFailure[C]@unchecked => throw ChannelNotFound(Right(t.fwd.shortChannelId))
case t: R @unchecked => t
case t: Register.ForwardFailure[C] @unchecked => throw ChannelNotFound(Left(t.fwd.channelId))
case t: Register.ForwardShortIdFailure[C] @unchecked => throw ChannelNotFound(Right(t.fwd.shortChannelId))
}

/**
Expand Down
16 changes: 7 additions & 9 deletions eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,7 @@ object Features {
val mandatory = 28
}

// TODO: this should also extend NodeFeature once the spec is finalized
case object Quiescence extends Feature with InitFeature {
case object Quiescence extends Feature with InitFeature with NodeFeature {
val rfcName = "option_quiesce"
val mandatory = 34
}
Expand Down Expand Up @@ -300,6 +299,11 @@ object Features {
val mandatory = 54
}

case object Splicing extends Feature with InitFeature with NodeFeature {
val rfcName = "option_splice"
val mandatory = 62
}

// TODO: @t-bast: update feature bits once spec-ed (currently reserved here: https://github.com/lightningnetwork/lightning-rfc/issues/605)
// We're not advertising these bits yet in our announcements, clients have to assume support.
// This is why we haven't added them yet to `areSupported`.
Expand All @@ -317,12 +321,6 @@ object Features {
val mandatory = 152
}

// TODO: @pm47 custom splices implementation for phoenix, to be replaced once splices is spec-ed (currently reserved here: https://github.com/lightning/bolts/issues/605)
case object SplicePrototype extends Feature with InitFeature {
val rfcName = "splice_prototype"
val mandatory = 154
}

val knownFeatures: Set[Feature] = Set(
DataLossProtect,
InitialRoutingSync,
Expand All @@ -346,9 +344,9 @@ object Features {
PaymentMetadata,
ZeroConf,
KeySend,
Splicing,
TrampolinePaymentPrototype,
AsyncPaymentPrototype,
SplicePrototype,
)

// Features may depend on other features, as specified in Bolt 9.
Expand Down
5 changes: 4 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,8 @@ object NodeParams extends Logging {
val maxNoChannels = config.getInt("peer-connection.max-no-channels")
require(maxNoChannels > 0, "peer-connection.max-no-channels must be > 0")

val maxExcess_opt = if (config.getInt("channel.max-excess") > 0) Some(Satoshi(config.getInt("channel.max-excess"))) else None

NodeParams(
nodeKeyManager = nodeKeyManager,
channelKeyManager = channelKeyManager,
Expand Down Expand Up @@ -524,7 +526,8 @@ object NodeParams extends Logging {
quiescenceTimeout = FiniteDuration(config.getDuration("channel.quiescence-timeout").getSeconds, TimeUnit.SECONDS),
balanceThresholds = config.getConfigList("channel.channel-update.balance-thresholds").asScala.map(conf => BalanceThreshold(Satoshi(conf.getLong("available-sat")), Satoshi(conf.getLong("max-htlc-sat")))).toSeq,
minTimeBetweenUpdates = FiniteDuration(config.getDuration("channel.channel-update.min-time-between-updates").getSeconds, TimeUnit.SECONDS),
acceptIncomingStaticRemoteKeyChannels = config.getBoolean("channel.accept-incoming-static-remote-key-channels")
acceptIncomingStaticRemoteKeyChannels = config.getBoolean("channel.accept-incoming-static-remote-key-channels"),
maxExcess_opt = maxExcess_opt,
),
onChainFeeConf = OnChainFeeConf(
feeTargets = feeTargets,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ trait OnChainChannelFunder {
* Fund the provided transaction by adding inputs (and a change output if necessary).
* Callers must verify that the resulting transaction isn't sending funds to unexpected addresses (malicious bitcoin node).
*/
def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long] = Map.empty, feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse]
def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long] = Map.empty, feeBudget_opt: Option[Satoshi], addExcessToRecipientPosition_opt: Option[Int], maxExcess_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse]

/**
* Sign a PSBT. Result may be partially signed: only inputs known to our bitcoin wallet will be signed. *
Expand All @@ -55,7 +55,7 @@ trait OnChainChannelFunder {
def publishTransaction(tx: Transaction)(implicit ec: ExecutionContext): Future[TxId]

/** Create a fully signed channel funding transaction with the provided pubkeyScript. */
def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRate: FeeratePerKw, feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[MakeFundingTxResponse]
def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRate: FeeratePerKw, feeBudget_opt: Option[Satoshi], maxExcess_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[MakeFundingTxResponse]

/**
* Committing *must* include publishing the transaction on the network.
Expand Down
Loading
Loading