Skip to content

Commit 13d4c9f

Browse files
authored
Add support for RBF-ing splice transactions (#2925)
If the latest splice transaction doesn't confirm, we allow exchanging `tx_init_rbf` and `tx_ack_rbf` to create another splice transaction to replace it. We use the same funding contribution as the previous splice. When 0-conf isn't used, we reject `splice_init` while the previous splice transaction hasn't confirmed. Our peer should either use RBF instead of creating a new splice, or they should wait for our node to receive the block that confirmed the previous transaction. This protects against chains of unconfirmed transactions. When using 0-conf, we reject `tx_init_rbf` and allow creating chains of unconfirmed splice transactions: using RBF with 0-conf can lead to one side stealing funds, which is why we prevent it. If our peer was buying liquidity but tries to cancel the purchase with an RBF attempt, we reject it: this prevents edge cases where the seller may end up adding liquidity to the channel without being paid in return.
1 parent f1e0735 commit 13d4c9f

File tree

19 files changed

+1411
-533
lines changed

19 files changed

+1411
-533
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Eclair will not allow remote peers to open new obsolete channels that do not sup
3838
- `channelstats` now takes optional parameters `--count` and `--skip` to control pagination. By default, it will return first 10 entries. (#2890)
3939
- `createinvoice` now takes an optional `--privateChannelIds` parameter that can be used to add routing hints through private channels. (#2909)
4040
- `nodes` allows filtering nodes that offer liquidity ads (#2848)
41+
- `rbfsplice` lets any channel participant RBF the current unconfirmed splice transaction (#2887)
4142

4243
### Miscellaneous improvements and bug fixes
4344

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

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ trait Eclair {
9696

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

99+
def rbfSplice(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]]
100+
99101
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]]]]
100102

101103
def forceClose(channels: List[ApiTypes.ChannelIdentifier])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_FORCECLOSE]]]]
@@ -232,17 +234,18 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
232234
}
233235

234236
override def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]] = {
235-
sendToChannelTyped(channel = Left(channelId),
236-
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong), requestFunding_opt = None))
237+
sendToChannelTyped(
238+
channel = Left(channelId),
239+
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong), requestFunding_opt = None)
240+
)
237241
}
238242

239243
override def spliceIn(channelId: ByteVector32, amountIn: Satoshi, pushAmount_opt: Option[MilliSatoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
240-
sendToChannelTyped(channel = Left(channelId),
241-
cmdBuilder = CMD_SPLICE(_,
242-
spliceIn_opt = Some(SpliceIn(additionalLocalFunding = amountIn, pushAmount = pushAmount_opt.getOrElse(0.msat))),
243-
spliceOut_opt = None,
244-
requestFunding_opt = None,
245-
))
244+
val spliceIn = SpliceIn(additionalLocalFunding = amountIn, pushAmount = pushAmount_opt.getOrElse(0.msat))
245+
sendToChannelTyped(
246+
channel = Left(channelId),
247+
cmdBuilder = CMD_SPLICE(_, spliceIn_opt = Some(spliceIn), spliceOut_opt = None, requestFunding_opt = None)
248+
)
246249
}
247250

248251
override def spliceOut(channelId: ByteVector32, amountOut: Satoshi, scriptOrAddress: Either[ByteVector, String])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
@@ -253,12 +256,18 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
253256
case Right(script) => Script.write(script)
254257
}
255258
}
256-
sendToChannelTyped(channel = Left(channelId),
257-
cmdBuilder = CMD_SPLICE(_,
258-
spliceIn_opt = None,
259-
spliceOut_opt = Some(SpliceOut(amount = amountOut, scriptPubKey = script)),
260-
requestFunding_opt = None,
261-
))
259+
val spliceOut = SpliceOut(amount = amountOut, scriptPubKey = script)
260+
sendToChannelTyped(
261+
channel = Left(channelId),
262+
cmdBuilder = CMD_SPLICE(_, spliceIn_opt = None, spliceOut_opt = Some(spliceOut), requestFunding_opt = None)
263+
)
264+
}
265+
266+
override def rbfSplice(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]] = {
267+
sendToChannelTyped(
268+
channel = Left(channelId),
269+
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong), requestFunding_opt = None)
270+
)
262271
}
263272

264273
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]]]] = {
@@ -579,9 +588,9 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
579588
case Left(channelId) => appKit.register ? Register.Forward(null, channelId, request)
580589
case Right(shortChannelId) => appKit.register ? Register.ForwardShortId(null, shortChannelId, request)
581590
}).map {
582-
case t: R@unchecked => t
583-
case t: Register.ForwardFailure[C]@unchecked => throw ChannelNotFound(Left(t.fwd.channelId))
584-
case t: Register.ForwardShortIdFailure[C]@unchecked => throw ChannelNotFound(Right(t.fwd.shortChannelId))
591+
case t: R @unchecked => t
592+
case t: Register.ForwardFailure[C] @unchecked => throw ChannelNotFound(Left(t.fwd.channelId))
593+
case t: Register.ForwardShortIdFailure[C] @unchecked => throw ChannelNotFound(Right(t.fwd.shortChannelId))
585594
}
586595

587596
private def sendToChannelTyped[C <: Command, R <: CommandResponse[C]](channel: ApiTypes.ChannelIdentifier, cmdBuilder: akka.actor.typed.ActorRef[Any] => C)(implicit timeout: Timeout): Future[R] =
@@ -592,9 +601,9 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
592601
case Right(shortChannelId) => Register.ForwardShortId(replyTo, shortChannelId, cmd)
593602
}
594603
}.map {
595-
case t: R@unchecked => t
596-
case t: Register.ForwardFailure[C]@unchecked => throw ChannelNotFound(Left(t.fwd.channelId))
597-
case t: Register.ForwardShortIdFailure[C]@unchecked => throw ChannelNotFound(Right(t.fwd.shortChannelId))
604+
case t: R @unchecked => t
605+
case t: Register.ForwardFailure[C] @unchecked => throw ChannelNotFound(Left(t.fwd.channelId))
606+
case t: Register.ForwardShortIdFailure[C] @unchecked => throw ChannelNotFound(Right(t.fwd.shortChannelId))
598607
}
599608

600609
/**

0 commit comments

Comments
 (0)