Skip to content

Commit

Permalink
Use official splice messages
Browse files Browse the repository at this point in the history
We replace our experimental version of `splice_init`, `splice_ack` and
`splice_locked` by their official version. If our peer is using the
experimental feature bit, we convert our outgoing messages to use the
experimental encoding and incoming messages to the official messages.

We also change the TLV fields added to `tx_add_input`, `tx_signatures`
and `splice_locked` to match the spec version. We always write both the
official and experimental TLV to updated nodes (because the experimental
one is odd and will be ignored) but we drop the official TLV if our
peer is using the experimental feature, because it won't understand the
even TLV field.

This guarantees backwards-compatibility with peers who only support the
experimental feature.
  • Loading branch information
t-bast committed Jan 2, 2025
1 parent 29628d5 commit 364c6d1
Show file tree
Hide file tree
Showing 22 changed files with 257 additions and 92 deletions.
33 changes: 32 additions & 1 deletion docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,38 @@

## Major changes

<insert 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.

### Peer storage

Expand Down
1 change: 1 addition & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,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
on_the_fly_funding = disabled
Expand Down
17 changes: 7 additions & 10 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 @@ -305,6 +304,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 @@ -322,12 +326,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
}

/**
* Activate this feature to provide on-the-fly funding to remote nodes, as specified in bLIP 36: https://github.com/lightning/blips/blob/master/blip-0036.md.
* TODO: add NodeFeature once bLIP is merged.
Expand Down Expand Up @@ -369,9 +367,9 @@ object Features {
PaymentMetadata,
ZeroConf,
KeySend,
Splicing,
TrampolinePaymentPrototype,
AsyncPaymentPrototype,
SplicePrototype,
OnTheFlyFunding,
FundingFeeCredit
)
Expand All @@ -387,7 +385,6 @@ object Features {
TrampolinePaymentPrototype -> (PaymentSecret :: Nil),
KeySend -> (VariableLengthOnion :: Nil),
AsyncPaymentPrototype -> (TrampolinePaymentPrototype :: Nil),
OnTheFlyFunding -> (SplicePrototype :: Nil),
FundingFeeCredit -> (OnTheFlyFunding :: Nil)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -670,9 +670,11 @@ case class Commitment(fundingTxIndex: Long,
log.info(s"built remote commit number=${remoteCommit.index + 1} toLocalMsat=${spec.toLocal.toLong} toRemoteMsat=${spec.toRemote.toLong} htlc_in={} htlc_out={} feeratePerKw=${spec.commitTxFeerate} txid=${remoteCommitTx.tx.txid} fundingTxId=$fundingTxId", spec.htlcs.collect(DirectedHtlc.outgoing).map(_.id).mkString(","), spec.htlcs.collect(DirectedHtlc.incoming).map(_.id).mkString(","))
Metrics.recordHtlcsInFlight(spec, remoteCommit.spec)

val commitSig = CommitSig(params.channelId, sig, htlcSigs.toList, TlvStream(Set(
if (batchSize > 1) Some(CommitSigTlv.BatchTlv(batchSize)) else None
).flatten[CommitSigTlv]))
val tlvs = Set(
if (batchSize > 1) Some(CommitSigTlv.BatchTlv(batchSize, fundingTxId)) else None,
if (batchSize > 1) Some(CommitSigTlv.ExperimentalBatchTlv(batchSize)) else None,
).flatten[CommitSigTlv]
val commitSig = CommitSig(params.channelId, sig, htlcSigs.toList, TlvStream(tlvs))
val nextRemoteCommit = NextRemoteCommit(commitSig, RemoteCommit(remoteCommit.index + 1, spec, remoteCommitTx.tx.txid, remoteNextPerCommitmentPoint))
(copy(nextRemoteCommit_opt = Some(nextRemoteCommit)), commitSig)
}
Expand Down Expand Up @@ -1072,8 +1074,10 @@ case class Commitments(params: ChannelParams,
}
val channelKeyPath = keyManager.keyPath(params.localParams, params.channelConfig)
val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, localCommitIndex + 1)
// Signatures are sent in order (most recent first), calling `zip` will drop trailing sigs that are for deactivated/pruned commitments.
val active1 = active.zip(commits).map { case (commitment, commit) =>
val active1 = active.zipWithIndex.map { case (commitment, idx) =>
// If the funding_txid isn't provided, we assume that signatures are sent in order (most recent first).
// This matches the behavior of peers who only support the experimental version of splicing.
val commit = commits.find(_.fundingTxId_opt.contains(commitment.fundingTxId)).getOrElse(commits(idx))
commitment.receiveCommit(keyManager, params, changes, localPerCommitmentPoint, commit) match {
case Left(f) => return Left(f)
case Right(commitment1) => commitment1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -842,7 +842,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
}

case Event(cmd: CMD_SPLICE, d: DATA_NORMAL) =>
if (!d.commitments.params.remoteParams.initFeatures.hasFeature(Features.SplicePrototype)) {
if (!d.commitments.params.remoteParams.initFeatures.hasFeature(Features.Splicing)) {
log.warning("cannot initiate splice, peer doesn't support splicing")
cmd.replyTo ! RES_FAILURE(cmd, CommandUnavailableInThisState(d.channelId, "splice", NORMAL))
stay()
Expand Down Expand Up @@ -2702,7 +2702,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
/** For splices we will send one commit_sig per active commitments. */
private def aggregateSigs(commit: CommitSig): Option[Seq[CommitSig]] = {
sigStash = sigStash :+ commit
log.debug("received sig for batch of size={}", commit.batchSize)
log.debug("received sig for batch of size={} for fundingTxId={}", commit.batchSize, commit.fundingTxId_opt)
if (sigStash.size == commit.batchSize) {
val sigs = sigStash
sigStash = Nil
Expand Down
27 changes: 21 additions & 6 deletions eclair-core/src/main/scala/fr/acinq/eclair/io/PeerConnection.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ package fr.acinq.eclair.io

import akka.actor.{ActorRef, FSM, OneForOneStrategy, PoisonPill, Props, Stash, SupervisorStrategy, Terminated}
import akka.event.Logging.MDC
import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32}
import fr.acinq.bitcoin.scalacompat.BlockHash
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.eclair.Logs.LogCategory
import fr.acinq.eclair.crypto.Noise.KeyPair
Expand All @@ -28,7 +28,7 @@ import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes
import fr.acinq.eclair.router.Router._
import fr.acinq.eclair.wire.protocol
import fr.acinq.eclair.wire.protocol._
import fr.acinq.eclair.{FSMDiagnosticActorLogging, FeatureCompatibilityResult, Features, InitFeature, Logs, TimestampMilli, TimestampSecond}
import fr.acinq.eclair.{FSMDiagnosticActorLogging, Features, InitFeature, Logs, TimestampMilli, TimestampSecond}
import scodec.Attempt
import scodec.bits.ByteVector

Expand Down Expand Up @@ -206,11 +206,20 @@ class PeerConnection(keyPair: KeyPair, conf: PeerConnection.Conf, switchboard: A
stay()

case Event(msg: LightningMessage, d: ConnectedData) if sender() != d.transport => // if the message doesn't originate from the transport, it is an outgoing message
d.transport forward msg
val useExperimentalSplice = d.remoteInit.features.unknown.map(_.bitIndex).contains(155)
msg match {
// If our peer is using the experimental splice version, we convert splice messages.
case msg: SpliceInit if useExperimentalSplice => d.transport forward ExperimentalSpliceInit.from(msg)
case msg: SpliceAck if useExperimentalSplice => d.transport forward ExperimentalSpliceAck.from(msg)
case msg: SpliceLocked if useExperimentalSplice => d.transport forward ExperimentalSpliceLocked.from(msg)
case msg: TxAddInput if useExperimentalSplice => d.transport forward msg.copy(tlvStream = TlvStream(msg.tlvStream.records.filterNot(_.isInstanceOf[TxAddInputTlv.SharedInputTxId])))
case msg: CommitSig if useExperimentalSplice => d.transport forward msg.copy(tlvStream = TlvStream(msg.tlvStream.records.filterNot(_.isInstanceOf[CommitSigTlv.BatchTlv])))
case msg: TxSignatures if useExperimentalSplice => d.transport forward msg.copy(tlvStream = TlvStream(msg.tlvStream.records.filterNot(_.isInstanceOf[TxSignaturesTlv.PreviousFundingTxSig])))
case _ => d.transport forward msg
}
msg match {
// If we send any channel management message to this peer, the connection should be persistent.
case _: ChannelMessage if !d.isPersistent =>
stay() using d.copy(isPersistent = true)
case _: ChannelMessage if !d.isPersistent => stay() using d.copy(isPersistent = true)
case _ => stay()
}

Expand Down Expand Up @@ -343,7 +352,13 @@ class PeerConnection(keyPair: KeyPair, conf: PeerConnection.Conf, switchboard: A
case Event(msg: LightningMessage, d: ConnectedData) =>
// we acknowledge and pass all other messages to the peer
d.transport ! TransportHandler.ReadAck(msg)
d.peer ! msg
msg match {
// If our peer is using the experimental splice version, we convert splice messages.
case msg: ExperimentalSpliceInit => d.peer ! msg.toSpliceInit()
case msg: ExperimentalSpliceAck => d.peer ! msg.toSpliceAck()
case msg: ExperimentalSpliceLocked => d.peer ! msg.toSpliceLocked()
case _ => d.peer ! msg
}
stay()

case Event(readAck: TransportHandler.ReadAck, d: ConnectedData) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package fr.acinq.eclair.wire.protocol

import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.bitcoin.scalacompat.TxId
import fr.acinq.eclair.UInt64
import fr.acinq.eclair.wire.protocol.CommonCodecs._
import fr.acinq.eclair.wire.protocol.TlvCodecs.{tlvField, tlvStream, tu16}
Expand Down Expand Up @@ -73,16 +74,25 @@ object UpdateFailMalformedHtlcTlv {
sealed trait CommitSigTlv extends Tlv

object CommitSigTlv {
/**
* While a splice is ongoing and not locked, we have multiple valid commitments.
* We send one [[CommitSig]] message for each valid commitment.
*
* @param size the number of [[CommitSig]] messages in the batch.
* @param fundingTxId the funding transaction spent by this commitment.
*/
case class BatchTlv(size: Int, fundingTxId: TxId) extends CommitSigTlv

/** @param size the number of [[CommitSig]] messages in the batch */
case class BatchTlv(size: Int) extends CommitSigTlv
private val batchTlv: Codec[BatchTlv] = tlvField(uint16 :: txIdAsHash)

object BatchTlv {
val codec: Codec[BatchTlv] = tlvField(tu16)
}
/** Similar to [[BatchTlv]] for peers who only support the experimental version of splicing. */
case class ExperimentalBatchTlv(size: Int) extends CommitSigTlv

private val experimentalBatchTlv: Codec[ExperimentalBatchTlv] = tlvField(tu16)

val commitSigTlvCodec: Codec[TlvStream[CommitSigTlv]] = tlvStream(discriminated[CommitSigTlv].by(varint)
.typecase(UInt64(0x47010005), BatchTlv.codec)
.typecase(UInt64(0), batchTlv)
.typecase(UInt64(0x47010005), experimentalBatchTlv)
)

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,13 @@ object TxAddInputTlv {
/** When doing a splice, the initiator must provide the previous funding txId instead of the whole transaction. */
case class SharedInputTxId(txId: TxId) extends TxAddInputTlv

/** Same as [[SharedInputTxId]] for peers who only support the experimental version of splicing. */
case class ExperimentalSharedInputTxId(txId: TxId) extends TxAddInputTlv

val txAddInputTlvCodec: Codec[TlvStream[TxAddInputTlv]] = tlvStream(discriminated[TxAddInputTlv].by(varint)
// Note that we actually encode as a tx_hash to be consistent with other lightning messages.
.typecase(UInt64(1105), tlvField(txIdAsHash.as[SharedInputTxId]))
.typecase(UInt64(0), tlvField(txIdAsHash.as[SharedInputTxId]))
.typecase(UInt64(1105), tlvField(txIdAsHash.as[ExperimentalSharedInputTxId]))
)
}

Expand Down Expand Up @@ -69,8 +73,12 @@ object TxSignaturesTlv {
/** When doing a splice, each peer must provide their signature for the previous 2-of-2 funding output. */
case class PreviousFundingTxSig(sig: ByteVector64) extends TxSignaturesTlv

/** Same as [[PreviousFundingTxSig]] for peers who only support the experimental version of splicing. */
case class ExperimentalPreviousFundingTxSig(sig: ByteVector64) extends TxSignaturesTlv

val txSignaturesTlvCodec: Codec[TlvStream[TxSignaturesTlv]] = tlvStream(discriminated[TxSignaturesTlv].by(varint)
.typecase(UInt64(601), tlvField(bytes64.as[PreviousFundingTxSig]))
.typecase(UInt64(0), tlvField(bytes64.as[PreviousFundingTxSig]))
.typecase(UInt64(601), tlvField(bytes64.as[ExperimentalPreviousFundingTxSig]))
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,17 +418,36 @@ object LightningMessageCodecs {
("fundingPubkey" | publicKey) ::
("tlvStream" | SpliceInitTlv.spliceInitTlvCodec)).as[SpliceInit]

val experimentalSpliceInitCodec: Codec[ExperimentalSpliceInit] = (
("channelId" | bytes32) ::
("fundingContribution" | satoshiSigned) ::
("feerate" | feeratePerKw) ::
("lockTime" | uint32) ::
("fundingPubkey" | publicKey) ::
("tlvStream" | SpliceInitTlv.spliceInitTlvCodec)).as[ExperimentalSpliceInit]

val spliceAckCodec: Codec[SpliceAck] = (
("channelId" | bytes32) ::
("fundingContribution" | satoshiSigned) ::
("fundingPubkey" | publicKey) ::
("tlvStream" | SpliceAckTlv.spliceAckTlvCodec)).as[SpliceAck]

val experimentalSpliceAckCodec: Codec[ExperimentalSpliceAck] = (
("channelId" | bytes32) ::
("fundingContribution" | satoshiSigned) ::
("fundingPubkey" | publicKey) ::
("tlvStream" | SpliceAckTlv.spliceAckTlvCodec)).as[ExperimentalSpliceAck]

val spliceLockedCodec: Codec[SpliceLocked] = (
("channelId" | bytes32) ::
("fundingTxHash" | txIdAsHash) ::
("tlvStream" | SpliceLockedTlv.spliceLockedTlvCodec)).as[SpliceLocked]

val experimentalSpliceLockedCodec: Codec[ExperimentalSpliceLocked] = (
("channelId" | bytes32) ::
("fundingTxHash" | txIdAsHash) ::
("tlvStream" | SpliceLockedTlv.spliceLockedTlvCodec)).as[ExperimentalSpliceLocked]

val stfuCodec: Codec[Stfu] = (
("channelId" | bytes32) ::
("initiator" | byte.xmap[Boolean](b => b != 0, b => if (b) 1 else 0))).as[Stfu]
Expand Down Expand Up @@ -508,6 +527,9 @@ object LightningMessageCodecs {
.typecase(72, txInitRbfCodec)
.typecase(73, txAckRbfCodec)
.typecase(74, txAbortCodec)
.typecase(77, spliceLockedCodec)
.typecase(80, spliceInitCodec)
.typecase(81, spliceAckCodec)
.typecase(128, updateAddHtlcCodec)
.typecase(130, updateFulfillHtlcCodec)
.typecase(131, updateFailHtlcCodec)
Expand Down Expand Up @@ -539,9 +561,9 @@ object LightningMessageCodecs {
.typecase(41045, addFeeCreditCodec)
.typecase(41046, currentFeeCreditCodec)
//
.typecase(37000, spliceInitCodec)
.typecase(37002, spliceAckCodec)
.typecase(37004, spliceLockedCodec)
.typecase(37000, experimentalSpliceInitCodec)
.typecase(37002, experimentalSpliceAckCodec)
.typecase(37004, experimentalSpliceLockedCodec)
//

//
Expand Down
Loading

0 comments on commit 364c6d1

Please sign in to comment.