Skip to content

Commit

Permalink
Add support for RBF-ing splice transactions
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
t-bast committed Oct 13, 2024
1 parent cf6b4e3 commit 29085e9
Show file tree
Hide file tree
Showing 19 changed files with 1,347 additions and 512 deletions.
1 change: 1 addition & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Eclair will not allow remote peers to open new obsolete channels that do not sup
- `channelstats` now takes optional parameters `--count` and `--skip` to control pagination. By default, it will return first 10 entries. (#2890)
- `createinvoice` now takes an optional `--privateChannelIds` parameter that can be used to add routing hints through private channels. (#2909)
- `nodes` allows filtering nodes that offer liquidity ads (#2848)
- `rbfsplice` lets any channel participant RBF the current unconfirmed splice transaction (#2887)

### Miscellaneous improvements and bug fixes

Expand Down
49 changes: 29 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 @@ -94,6 +94,8 @@ trait Eclair {

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 @@ -228,17 +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), requestFunding_opt = None))
sendToChannelTyped(
channel = Left(channelId),
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong), requestFunding_opt = None)
)
}

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,
requestFunding_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, requestFunding_opt = None)
)
}

override def spliceOut(channelId: ByteVector32, amountOut: Satoshi, scriptOrAddress: Either[ByteVector, String])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
Expand All @@ -249,12 +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)),
requestFunding_opt = None,
))
val spliceOut = SpliceOut(amount = amountOut, scriptPubKey = script)
sendToChannelTyped(
channel = Left(channelId),
cmdBuilder = CMD_SPLICE(_, spliceIn_opt = None, spliceOut_opt = Some(spliceOut), requestFunding_opt = None)
)
}

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), requestFunding_opt = None)
)
}

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 @@ -575,9 +584,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 @@ -588,9 +597,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
Loading

0 comments on commit 29085e9

Please sign in to comment.