Skip to content

Commit 7af46f2

Browse files
committed
Use official splice messages
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. We do the same thing for the `commit_sig` TLV. For peers who support the official splicing version, we insert the `start_batch` message before the batch of `commit_sig` messages. This guarantees backwards-compatibility with peers who only support the experimental feature.
1 parent e7b9b89 commit 7af46f2

26 files changed

Lines changed: 541 additions & 118 deletions

File tree

docs/release-notes/eclair-vnext.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,39 @@
66

77
<insert changes>
88

9+
### Channel Splicing
10+
11+
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.
12+
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.
13+
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.
14+
15+
The size of an existing channel can be increased with the `splicein` API:
16+
17+
```sh
18+
eclair-cli splicein --channelId=<channel_id> --amountIn=<amount_satoshis>
19+
```
20+
21+
Once that transaction confirms, the additional liquidity can be used to send outgoing payments.
22+
If the transaction doesn't confirm, the node operator can speed up confirmation with the `rbfsplice` API:
23+
24+
```sh
25+
eclair-cli rbfsplice --channelId=<channel_id> --targetFeerateSatByte=<feerate_satoshis_per_byte> --fundingFeeBudgetSatoshis=<maximum_on_chain_fee_satoshis>
26+
```
27+
28+
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:
29+
30+
```sh
31+
eclair-cli spliceout --channelId=<channel_id> --amountOut=<amount_satoshis> --scriptPubKey=<on_chain_address>
32+
```
33+
34+
That operation can also be RBF-ed with the `rbfsplice` API to speed up confirmation if necessary.
35+
36+
Note that when 0-conf is used for the channel, it is not possible to RBF splice transactions.
37+
Node operators should instead create a new splice transaction (with `splicein` or `spliceout`) to CPFP the previous transaction.
38+
39+
Note that eclair had already introduced support for a splicing prototype in v0.9.0, which helped improve the BOLT proposal.
40+
We're removing support for the previous splicing prototype feature: users that depended on this protocol must upgrade to create official splice transactions.
41+
942
### Package relay
1043

1144
With Bitcoin Core 28.1, eclair starts relying on the `submitpackage` RPC during channel force-close.

eclair-core/src/main/resources/reference.conf

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ eclair {
8888
option_zeroconf = disabled
8989
keysend = disabled
9090
option_simple_close=optional
91+
option_splice = optional
9192
trampoline_payment_prototype = disabled
9293
async_payment_prototype = disabled
9394
on_the_fly_funding = disabled
@@ -109,7 +110,7 @@ eclair {
109110
funding {
110111
// Each RBF attempt adds more data that we need to store and process, so we want to limit our peers to a reasonable use of RBF.
111112
remote-rbf-limits {
112-
max-attempts = 5 // maximum number of RBF attempts our peer is allowed to make
113+
max-attempts = 10 // maximum number of RBF attempts our peer is allowed to make
113114
attempt-delta-blocks = 6 // minimum number of blocks between RBF attempts
114115
}
115116
// Duration after which we abort a channel creation. If our peer seems unresponsive and doesn't complete the

eclair-core/src/main/scala/fr/acinq/eclair/Features.scala

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -264,8 +264,7 @@ object Features {
264264
val mandatory = 28
265265
}
266266

267-
// TODO: this should also extend NodeFeature once the spec is finalized
268-
case object Quiescence extends Feature with InitFeature {
267+
case object Quiescence extends Feature with InitFeature with NodeFeature {
269268
val rfcName = "option_quiesce"
270269
val mandatory = 34
271270
}
@@ -314,6 +313,11 @@ object Features {
314313
val mandatory = 60
315314
}
316315

316+
case object Splicing extends Feature with InitFeature with NodeFeature {
317+
val rfcName = "option_splice"
318+
val mandatory = 62
319+
}
320+
317321
/** This feature bit indicates that the node is a mobile wallet that can be woken up via push notifications. */
318322
case object WakeUpNotificationClient extends Feature with InitFeature {
319323
val rfcName = "wake_up_notification_client"
@@ -337,12 +341,6 @@ object Features {
337341
val mandatory = 152
338342
}
339343

340-
// 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)
341-
case object SplicePrototype extends Feature with InitFeature {
342-
val rfcName = "splice_prototype"
343-
val mandatory = 154
344-
}
345-
346344
/**
347345
* 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.
348346
* TODO: add NodeFeature once bLIP is merged.
@@ -386,10 +384,10 @@ object Features {
386384
ZeroConf,
387385
KeySend,
388386
SimpleClose,
387+
Splicing,
389388
WakeUpNotificationClient,
390389
TrampolinePaymentPrototype,
391390
AsyncPaymentPrototype,
392-
SplicePrototype,
393391
OnTheFlyFunding,
394392
FundingFeeCredit
395393
)
@@ -406,7 +404,6 @@ object Features {
406404
KeySend -> (VariableLengthOnion :: Nil),
407405
SimpleClose -> (ShutdownAnySegwit :: Nil),
408406
AsyncPaymentPrototype -> (TrampolinePaymentPrototype :: Nil),
409-
OnTheFlyFunding -> (SplicePrototype :: Nil),
410407
FundingFeeCredit -> (OnTheFlyFunding :: Nil)
411408
)
412409

eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ case class RemoteCommit(index: Long, spec: CommitmentSpec, txid: TxId, remotePer
231231
params.commitmentFormat match {
232232
case _: SegwitV0CommitmentFormat =>
233233
val sig = remoteCommitTx.sign(fundingKey, remoteFundingPubKey).sig
234-
CommitSig(params.channelId, sig, htlcSigs.toList)
234+
CommitSig(params.channelId, sig, htlcSigs.toList, TlvStream(CommitSigTlv.FundingTx(commitInput.outPoint.txid)))
235235
case _: SimpleTaprootChannelCommitmentFormat => ???
236236
}
237237
}
@@ -658,7 +658,8 @@ case class Commitment(fundingTxIndex: Long,
658658
Metrics.recordHtlcsInFlight(spec, remoteCommit.spec)
659659

660660
val tlvs = Set(
661-
if (batchSize > 1) Some(CommitSigTlv.BatchTlv(batchSize)) else None
661+
Some(CommitSigTlv.FundingTx(fundingTxId)),
662+
if (batchSize > 1) Some(CommitSigTlv.ExperimentalBatchTlv(batchSize)) else None,
662663
).flatten[CommitSigTlv]
663664
val commitSig = params.commitmentFormat match {
664665
case _: SegwitV0CommitmentFormat =>
@@ -1042,8 +1043,10 @@ case class Commitments(params: ChannelParams,
10421043
case commitSig: CommitSig => Seq(commitSig)
10431044
}
10441045
val commitKeys = LocalCommitmentKeys(params, channelKeys, localCommitIndex + 1)
1045-
// Signatures are sent in order (most recent first), calling `zip` will drop trailing sigs that are for deactivated/pruned commitments.
1046-
val active1 = active.zip(sigs).map { case (commitment, commit) =>
1046+
val active1 = active.zipWithIndex.map { case (commitment, idx) =>
1047+
// If the funding_txid isn't provided, we assume that signatures are sent in order (most recent first).
1048+
// This matches the behavior of peers who only support the experimental version of splicing.
1049+
val commit = sigs.find(_.fundingTxId_opt.contains(commitment.fundingTxId)).getOrElse(sigs(idx))
10471050
commitment.receiveCommit(params, channelKeys, commitKeys, changes, commit) match {
10481051
case Left(f) => return Left(f)
10491052
case Right(commitment1) => commitment1

eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -883,7 +883,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
883883
}
884884

885885
case Event(cmd: CMD_SPLICE, d: DATA_NORMAL) =>
886-
if (!d.commitments.params.remoteParams.initFeatures.hasFeature(Features.SplicePrototype)) {
886+
if (!d.commitments.params.remoteParams.initFeatures.hasFeature(Features.Splicing)) {
887887
log.warning("cannot initiate splice, peer doesn't support splicing")
888888
cmd.replyTo ! RES_FAILURE(cmd, CommandUnavailableInThisState(d.channelId, "splice", NORMAL))
889889
stay()
@@ -2327,7 +2327,8 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
23272327
}
23282328
case _ => Set.empty
23292329
}
2330-
val lastFundingLockedTlvs: Set[ChannelReestablishTlv] = if (d.commitments.params.remoteParams.initFeatures.hasFeature(Features.SplicePrototype)) {
2330+
val remoteFeatures = d.commitments.params.remoteParams.initFeatures
2331+
val lastFundingLockedTlvs: Set[ChannelReestablishTlv] = if (remoteFeatures.hasFeature(Features.Splicing) || remoteFeatures.unknown.contains(UnknownFeature(154)) || remoteFeatures.unknown.contains(UnknownFeature(155))) {
23312332
d.commitments.lastLocalLocked_opt.map(c => ChannelReestablishTlv.MyCurrentFundingLockedTlv(c.fundingTxId)).toSet ++
23322333
d.commitments.lastRemoteLocked_opt.map(c => ChannelReestablishTlv.YourLastFundingLockedTlv(c.fundingTxId)).toSet
23332334
} else Set.empty
@@ -2456,7 +2457,8 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
24562457
// We only send channel_ready for initial funding transactions.
24572458
case Some(c) if c.fundingTxIndex != 0 => ()
24582459
case Some(c) =>
2459-
val remoteSpliceSupport = d.commitments.params.remoteParams.initFeatures.hasFeature(Features.SplicePrototype)
2460+
val remoteFeatures = d.commitments.params.remoteParams.initFeatures
2461+
val remoteSpliceSupport = remoteFeatures.hasFeature(Features.Splicing) || remoteFeatures.unknown.contains(UnknownFeature(154)) || remoteFeatures.unknown.contains(UnknownFeature(155))
24602462
// If our peer has not received our channel_ready, we retransmit it.
24612463
val notReceivedByRemote = remoteSpliceSupport && channelReestablish.yourLastFundingLocked_opt.isEmpty
24622464
// If next_local_commitment_number is 1 in both the channel_reestablish it sent and received, then the node

eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -855,7 +855,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
855855
case _: SegwitV0CommitmentFormat =>
856856
val localSigOfRemoteTx = remoteCommitTx.sign(localFundingKey, fundingParams.remoteFundingPubKey).sig
857857
val htlcSignatures = sortedHtlcTxs.map(_.sign(remoteCommitmentKeys, channelParams.commitmentFormat)).toList
858-
val localCommitSig = CommitSig(fundingParams.channelId, localSigOfRemoteTx, htlcSignatures)
858+
val localCommitSig = CommitSig(fundingParams.channelId, localSigOfRemoteTx, htlcSignatures, TlvStream(CommitSigTlv.FundingTx(fundingTx.txid)))
859859
val localCommit = UnsignedLocalCommit(purpose.localCommitIndex, localSpec, localCommitTx, htlcTxs = Nil)
860860
val remoteCommit = RemoteCommit(purpose.remoteCommitIndex, remoteSpec, remoteCommitTx.tx.txid, purpose.remotePerCommitmentPoint)
861861
signFundingTx(completeTx, localCommitSig, localCommit, remoteCommit)

eclair-core/src/main/scala/fr/acinq/eclair/io/PeerConnection.scala

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes
2828
import fr.acinq.eclair.router.Router._
2929
import fr.acinq.eclair.wire.protocol
3030
import fr.acinq.eclair.wire.protocol._
31-
import fr.acinq.eclair.{FSMDiagnosticActorLogging, Features, InitFeature, Logs, TimestampMilli, TimestampSecond}
31+
import fr.acinq.eclair.{FSMDiagnosticActorLogging, Features, InitFeature, Logs, TimestampMilli, TimestampSecond, UnknownFeature}
3232
import scodec.Attempt
3333
import scodec.bits.ByteVector
3434

@@ -206,9 +206,19 @@ class PeerConnection(keyPair: KeyPair, conf: PeerConnection.Conf, switchboard: A
206206
stay()
207207

208208
case Event(msg: LightningMessage, d: ConnectedData) if sender() != d.transport => // if the message doesn't originate from the transport, it is an outgoing message
209+
val useExperimentalSplice = d.remoteInit.features.unknown.contains(UnknownFeature(154)) || d.remoteInit.features.unknown.contains(UnknownFeature(155))
209210
msg match {
210-
case batch: CommitSigBatch => batch.messages.foreach(msg => d.transport forward msg)
211-
case msg => d.transport forward msg
211+
// If our peer is using the experimental splice version, we convert splice messages.
212+
case msg: SpliceInit if useExperimentalSplice => d.transport forward ExperimentalSpliceInit.from(msg)
213+
case msg: SpliceAck if useExperimentalSplice => d.transport forward ExperimentalSpliceAck.from(msg)
214+
case msg: SpliceLocked if useExperimentalSplice => d.transport forward ExperimentalSpliceLocked.from(msg)
215+
case msg: TxAddInput if useExperimentalSplice => d.transport forward msg.copy(tlvStream = TlvStream(msg.tlvStream.records.filterNot(_.isInstanceOf[TxAddInputTlv.SharedInputTxId])))
216+
case msg: TxSignatures if useExperimentalSplice => d.transport forward msg.copy(tlvStream = TlvStream(msg.tlvStream.records.filterNot(_.isInstanceOf[TxSignaturesTlv.PreviousFundingTxSig])))
217+
case batch: CommitSigBatch if useExperimentalSplice => batch.messages.foreach(msg => d.transport forward msg.copy(tlvStream = TlvStream(msg.tlvStream.records.filterNot(_.isInstanceOf[CommitSigTlv.FundingTx]))))
218+
case batch: CommitSigBatch =>
219+
d.transport forward StartBatch.commitSigBatch(batch.channelId, batch.batchSize)
220+
batch.messages.foreach(msg => d.transport forward msg.copy(tlvStream = TlvStream(msg.tlvStream.records.filterNot(_.isInstanceOf[CommitSigTlv.ExperimentalBatchTlv]))))
221+
case _ => d.transport forward msg
212222
}
213223
msg match {
214224
// If we send any channel management message to this peer, the connection should be persistent.
@@ -348,8 +358,51 @@ class PeerConnection(keyPair: KeyPair, conf: PeerConnection.Conf, switchboard: A
348358
// We immediately forward messages to the peer, unless they are part of a batch, in which case we wait to
349359
// receive the whole batch before forwarding.
350360
msg match {
361+
case msg: StartBatch =>
362+
if (!msg.messageType_opt.contains(132)) {
363+
log.debug("ignoring start_batch: we only support batching commit_sig messages")
364+
d.transport ! Warning(msg.channelId, "invalid start_batch message: we only support batching commit_sig messages")
365+
stay()
366+
} else if (msg.batchSize > 20) {
367+
log.debug("ignoring start_batch with batch_size = {} > 20", msg.batchSize)
368+
d.transport ! Warning(msg.channelId, "invalid start_batch message: batch_size must not be greater than 20")
369+
stay()
370+
} else {
371+
log.debug("starting commit_sig batch of size {} for channel_id={}", msg.batchSize, msg.channelId)
372+
d.commitSigBatch_opt match {
373+
case Some(pending) if pending.received.nonEmpty =>
374+
log.warning("starting batch with incomplete previous batch ({}/{} received)", pending.received.size, pending.batchSize)
375+
// This is a spec violation from our peer: this will likely lead to a force-close.
376+
d.transport ! Warning(msg.channelId, "invalid start_batch message: the previous batch is not done yet")
377+
d.peer ! CommitSigBatch(pending.received)
378+
case _ => ()
379+
}
380+
stay() using d.copy(commitSigBatch_opt = Some(PendingCommitSigBatch(msg.channelId, msg.batchSize, Nil)))
381+
}
382+
case msg: HasChannelId if d.commitSigBatch_opt.nonEmpty =>
383+
// We only support batches of commit_sig messages: other messages will simply be relayed individually.
384+
val pending = d.commitSigBatch_opt.get
385+
msg match {
386+
case msg: CommitSig if msg.channelId == pending.channelId =>
387+
val received1 = pending.received :+ msg
388+
if (received1.size == pending.batchSize) {
389+
log.debug("received last commit_sig in batch for channel_id={}", msg.channelId)
390+
d.peer ! CommitSigBatch(received1)
391+
stay() using d.copy(commitSigBatch_opt = None)
392+
} else {
393+
log.debug("received commit_sig {}/{} in batch for channel_id={}", received1.size, pending.batchSize, msg.channelId)
394+
stay() using d.copy(commitSigBatch_opt = Some(pending.copy(received = received1)))
395+
}
396+
case _ =>
397+
log.warning("received {} as part of a batch: we don't support batching that kind of messages", msg.getClass.getSimpleName)
398+
if (pending.received.nonEmpty) d.peer ! CommitSigBatch(pending.received)
399+
d.peer ! msg
400+
stay() using d.copy(commitSigBatch_opt = None)
401+
}
351402
case msg: CommitSig =>
352-
msg.tlvStream.get[CommitSigTlv.BatchTlv].map(_.size) match {
403+
// We keep supporting the experimental version of splicing that older Phoenix wallets use.
404+
// Once we're confident that enough Phoenix users have upgraded, we should remove this branch.
405+
msg.tlvStream.get[CommitSigTlv.ExperimentalBatchTlv].map(_.size) match {
353406
case Some(batchSize) if batchSize > 25 =>
354407
log.warning("received legacy batch of commit_sig exceeding our threshold ({} > 25), processing messages individually", batchSize)
355408
// We don't want peers to be able to exhaust our memory by sending batches of dummy messages that we keep in RAM.
@@ -381,6 +434,16 @@ class PeerConnection(keyPair: KeyPair, conf: PeerConnection.Conf, switchboard: A
381434
d.peer ! msg
382435
stay()
383436
}
437+
// If our peer is using the experimental splice version, we convert splice messages.
438+
case msg: ExperimentalSpliceInit =>
439+
d.peer ! msg.toSpliceInit()
440+
stay()
441+
case msg: ExperimentalSpliceAck =>
442+
d.peer ! msg.toSpliceAck()
443+
stay()
444+
case msg: ExperimentalSpliceLocked =>
445+
d.peer ! msg.toSpliceLocked()
446+
stay()
384447
case _ =>
385448
d.peer ! msg
386449
stay()
@@ -613,6 +676,7 @@ object PeerConnection {
613676
gossipTimestampFilter: Option[GossipTimestampFilter] = None,
614677
behavior: Behavior = Behavior(),
615678
expectedPong_opt: Option[ExpectedPong] = None,
679+
commitSigBatch_opt: Option[PendingCommitSigBatch] = None,
616680
legacyCommitSigBatch_opt: Option[PendingCommitSigBatch] = None,
617681
isPersistent: Boolean) extends Data with HasTransport
618682

eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,16 @@ sealed trait ChannelReestablishTlv extends Tlv
234234

235235
object ChannelReestablishTlv {
236236

237+
/**
238+
* When disconnected in the middle of an interactive-tx session, this field is used to request a retransmission of
239+
* [[TxSignatures]] for the given [[txId]].
240+
*/
237241
case class NextFundingTlv(txId: TxId) extends ChannelReestablishTlv
242+
243+
/** The txid of the last [[ChannelReady]] or [[SpliceLocked]] message received before disconnecting, if any. */
238244
case class YourLastFundingLockedTlv(txId: TxId) extends ChannelReestablishTlv
245+
246+
/** The txid of our latest outgoing [[ChannelReady]] or [[SpliceLocked]] for this channel. */
239247
case class MyCurrentFundingLockedTlv(txId: TxId) extends ChannelReestablishTlv
240248

241249
object NextFundingTlv {
@@ -245,6 +253,7 @@ object ChannelReestablishTlv {
245253
object YourLastFundingLockedTlv {
246254
val codec: Codec[YourLastFundingLockedTlv] = tlvField("your_last_funding_locked_txid" | txIdAsHash)
247255
}
256+
248257
object MyCurrentFundingLockedTlv {
249258
val codec: Codec[MyCurrentFundingLockedTlv] = tlvField("my_current_funding_locked_txid" | txIdAsHash)
250259
}
@@ -301,3 +310,14 @@ object ClosingTlv {
301310
)
302311

303312
}
313+
314+
sealed trait StartBatchTlv extends Tlv
315+
316+
object StartBatchTlv {
317+
/** Type of [[LightningMessage]] that is included in the batch, when batching a single message type. */
318+
case class MessageType(tag: Int) extends StartBatchTlv
319+
320+
val startBatchTlvCodec: Codec[TlvStream[StartBatchTlv]] = tlvStream(discriminated[StartBatchTlv].by(varint)
321+
.typecase(UInt64(1), tlvField(uint16.as[MessageType]))
322+
)
323+
}

0 commit comments

Comments
 (0)