Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,27 @@ $ ./eclair-cli open --nodeId=03864e... --fundingSatoshis=100000 --channelType=an
$ ./eclair-cli open --nodeId=03864e... --fundingSatoshis=100000 --channelType=anchor_outputs_zero_fee_htlc_tx+scid_alias+zeroconf --announceChannel=false
```

### Experimental support for dual-funding

This release adds experimental support for dual-funded channels, as specified [here](https://github.com/lightning/bolts/pull/851).
Dual-funded channels have many benefits:

- both peers can contribute to channel funding
- the funding transaction can be RBF-ed

This feature is turned off by default, because there may still be breaking changes in the specification.
To turn it on, simply enable the feature in your `eclair.conf`:

```conf
eclair.features.option_dual_fund = optional
```

If your peer also supports the feature, eclair will automatically use dual-funding when opening a channel.
If the channel doesn't confirm, you can use the `rbfopen` RPC to initiate an RBF attempt and speed up confirmation.

In this first version, the non-initiator cannot yet contribute funds to the channel.
This will be added in future updates.

### Changes to features override

Eclair supports overriding features on a per-peer basis, using the `eclair.override-init-features` field in `eclair.conf`.
Expand All @@ -91,9 +112,10 @@ upgrading to this release.

### API changes

- `channelbalances`: retrieves information about the balances of all local channels (#2196)
- `stop`: stops eclair. Please note that the recommended way of stopping eclair is simply to kill its process (#2233)
- `channelbalances` retrieves information about the balances of all local channels (#2196)
- `channelbalances` and `usablebalances` return a `shortIds` object instead of a single `shortChannelId` (#2323)
- `stop` stops eclair: please note that the recommended way of stopping eclair is simply to kill its process (#2233)
- `rbfopen` lets the initiator of a dual-funded channel RBF the funding transaction (#2275)

### Miscellaneous improvements and bug fixes

Expand Down
1 change: 1 addition & 0 deletions eclair-core/eclair-cli
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ and COMMAND is one of the available commands:

=== Channel ===
- open
- rbfopen
- close
- forceclose
- channel
Expand Down
7 changes: 7 additions & 0 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ trait Eclair {

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

def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, 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 @@ -196,6 +198,11 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
} yield res
}

override def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]] = {
val cmd = CMD_BUMP_FUNDING_FEE(ActorRef.noSender, targetFeerate, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong))
sendToChannel(Left(channelId), cmd)
}

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]]]] = {
sendToChannels(channels, CMD_CLOSE(ActorRef.noSender, scriptPubKey_opt, closingFeerates_opt))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ final case class ClosingFeerates(preferred: FeeratePerKw, min: FeeratePerKw, max
sealed trait CloseCommand extends HasReplyToCommand
final case class CMD_CLOSE(replyTo: ActorRef, scriptPubKey: Option[ByteVector], feerates: Option[ClosingFeerates]) extends CloseCommand
final case class CMD_FORCECLOSE(replyTo: ActorRef) extends CloseCommand

final case class CMD_BUMP_FUNDING_FEE(replyTo: ActorRef, targetFeerate: FeeratePerKw, lockTime: Long) extends HasReplyToCommand
final case class CMD_UPDATE_RELAY_FEE(replyTo: ActorRef, feeBase: MilliSatoshi, feeProportionalMillionths: Long, cltvExpiryDelta_opt: Option[CltvExpiryDelta]) extends HasReplyToCommand
final case class CMD_GET_CHANNEL_STATE(replyTo: ActorRef) extends HasReplyToCommand
final case class CMD_GET_CHANNEL_DATA(replyTo: ActorRef) extends HasReplyToCommand
Expand Down Expand Up @@ -413,6 +415,13 @@ case class DualFundedUnconfirmedFundingTx(sharedTx: SignedSharedTransaction) ext
/** Once a dual funding tx has been signed, we must remember the associated commitments. */
case class DualFundingTx(fundingTx: SignedSharedTransaction, commitments: Commitments)

sealed trait RbfStatus
object RbfStatus {
case object NoRbf extends RbfStatus
case class RbfRequested(cmd: CMD_BUMP_FUNDING_FEE) extends RbfStatus
case class RbfInProgress(rbf: typed.ActorRef[InteractiveTxBuilder.Command]) extends RbfStatus
}

sealed trait ChannelData extends PossiblyHarmful {
def channelId: ByteVector32
}
Expand Down Expand Up @@ -495,7 +504,7 @@ final case class DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED(commitments: Commitments,
previousFundingTxs: List[DualFundingTx],
waitingSince: BlockHeight, // how long have we been waiting for a funding tx to confirm
lastChecked: BlockHeight, // last time we checked if the channel was double-spent
rbfAttempt: Option[typed.ActorRef[InteractiveTxBuilder.Command]],
rbfStatus: RbfStatus,
deferred: Option[ChannelReady]) extends PersistentChannelData
final case class DATA_WAIT_FOR_DUAL_FUNDING_READY(commitments: Commitments,
shortIds: ShortIds,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ case class InvalidFundingSignature (override val channelId: Byte
case class InvalidRbfFeerate (override val channelId: ByteVector32, proposed: FeeratePerKw, expected: FeeratePerKw) extends ChannelException(channelId, s"invalid rbf attempt: the feerate must be at least $expected, you proposed $proposed")
case class InvalidRbfAlreadyInProgress (override val channelId: ByteVector32) extends ChannelException(channelId, "invalid rbf attempt: the current rbf attempt must be completed or aborted first")
case class InvalidRbfTxConfirmed (override val channelId: ByteVector32) extends ChannelException(channelId, "no need to rbf, transaction is already confirmed")
case class InvalidRbfNonInitiator (override val channelId: ByteVector32) extends ChannelException(channelId, "cannot initiate rbf: we're not the initiator of this interactive-tx attempt")
case class InvalidRbfAttempt (override val channelId: ByteVector32) extends ChannelException(channelId, "invalid rbf attempt")
case class NoMoreHtlcsClosingInProgress (override val channelId: ByteVector32) extends ChannelException(channelId, "cannot send new htlcs, closing in progress")
case class NoMoreFeeUpdateClosingInProgress (override val channelId: ByteVector32) extends ChannelException(channelId, "cannot send new update_fee, closing in progress")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ case class Commitments(channelId: ByteVector32,
commitTx
}

val fundingTxId: ByteVector32 = commitInput.outPoint.txid

val commitmentFormat: CommitmentFormat = channelFeatures.commitmentFormat

val channelType: SupportedChannelType = channelFeatures.channelType
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ object InteractiveTxBuilder {
dustLimit: Satoshi,
targetFeerate: FeeratePerKw) {
val fundingAmount: Satoshi = localAmount + remoteAmount
// BOLT 2: MUST set `feerate` greater than or equal to 25/24 times the `feerate` of the previously constructed transaction, rounded down.
val minNextFeerate: FeeratePerKw = targetFeerate * 25 / 24
}

case class InteractiveTxSession(toSend: Seq[Either[TxAddInput, TxAddOutput]],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val
if (closing.alternativeCommitments.nonEmpty) {
// There are unconfirmed, alternative funding transactions, so we wait for one to confirm before
// watching transactions spending it.
blockchain ! WatchFundingConfirmed(self, data.commitments.commitInput.outPoint.txid, nodeParams.channelConf.minDepthBlocks)
closing.alternativeCommitments.foreach(c => blockchain ! WatchFundingConfirmed(self, c.commitments.commitInput.outPoint.txid, nodeParams.channelConf.minDepthBlocks))
blockchain ! WatchFundingConfirmed(self, data.commitments.fundingTxId, nodeParams.channelConf.minDepthBlocks)
closing.alternativeCommitments.foreach(c => blockchain ! WatchFundingConfirmed(self, c.commitments.fundingTxId, nodeParams.channelConf.minDepthBlocks))
}
closing.mutualClosePublished.foreach(mcp => doPublish(mcp, isInitiator))
closing.localCommitPublished.foreach(lcp => doPublish(lcp, closing.commitments))
Expand All @@ -278,7 +278,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val
if (closing.commitments.channelFeatures.hasFeature(Features.DualFunding)) {
closing.fundingTx.flatMap(_.signedTx_opt).foreach(tx => wallet.publishTransaction(tx))
} else {
blockchain ! GetTxWithMeta(self, closing.commitments.commitInput.outPoint.txid)
blockchain ! GetTxWithMeta(self, closing.commitments.fundingTxId)
}
}
}
Expand Down Expand Up @@ -312,7 +312,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val
case funding: DATA_WAIT_FOR_FUNDING_CONFIRMED =>
watchFundingTx(funding.commitments)
// we make sure that the funding tx has been published
blockchain ! GetTxWithMeta(self, funding.commitments.commitInput.outPoint.txid)
blockchain ! GetTxWithMeta(self, funding.commitments.fundingTxId)
if (funding.waitingSince.toLong > 1_500_000_000) {
// we were using timestamps instead of block heights when the channel was created: we reset it *and* we use block heights
goto(OFFLINE) using funding.copy(waitingSince = nodeParams.currentBlockHeight) storing()
Expand All @@ -324,8 +324,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val
// we make sure that the funding tx with the highest feerate has been published
publishFundingTx(funding.fundingParams, funding.fundingTx)
// we watch confirmation of all funding candidates, and once one of them confirms we will watch spending txs
blockchain ! WatchFundingConfirmed(self, funding.commitments.commitInput.outPoint.txid, nodeParams.channelConf.minDepthBlocks)
funding.previousFundingTxs.map(_.commitments).foreach(c => blockchain ! WatchFundingConfirmed(self, c.commitInput.outPoint.txid, nodeParams.channelConf.minDepthBlocks))
blockchain ! WatchFundingConfirmed(self, funding.commitments.fundingTxId, nodeParams.channelConf.minDepthBlocks)
funding.previousFundingTxs.map(_.commitments).foreach(c => blockchain ! WatchFundingConfirmed(self, c.fundingTxId, nodeParams.channelConf.minDepthBlocks))
goto(OFFLINE) using funding

case _ =>
Expand Down Expand Up @@ -1082,7 +1082,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val
case Left(cause) => handleCommandError(cause, c)
}

case Event(getTxResponse: GetTxWithMetaResponse, d: DATA_CLOSING) if getTxResponse.txid == d.commitments.commitInput.outPoint.txid =>
case Event(getTxResponse: GetTxWithMetaResponse, d: DATA_CLOSING) if getTxResponse.txid == d.commitments.fundingTxId =>
// NB: waitingSinceBlock contains the block at which closing was initiated, not the block at which funding was initiated.
// That means we're lenient with our peer and give its funding tx more time to confirm, to avoid having to store two distinct
// waitingSinceBlock (e.g. closingWaitingSinceBlock and fundingWaitingSinceBlock).
Expand All @@ -1093,7 +1093,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val
case Event(BITCOIN_FUNDING_TIMEOUT, d: DATA_CLOSING) => handleFundingTimeout(d)

case Event(w: WatchFundingConfirmedTriggered, d: DATA_CLOSING) =>
d.alternativeCommitments.find(_.commitments.commitInput.outPoint.txid == w.tx.txid) match {
d.alternativeCommitments.find(_.commitments.fundingTxId == w.tx.txid) match {
case Some(DualFundingTx(_, commitments1)) =>
// This is a corner case where:
// - we are using dual funding
Expand All @@ -1112,14 +1112,14 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val
context.system.eventStream.publish(TransactionConfirmed(d.channelId, remoteNodeId, w.tx))
val otherFundingTxs =
d.fundingTx.toSeq.collect { case DualFundedUnconfirmedFundingTx(fundingTx) => fundingTx } ++
d.alternativeCommitments.filterNot(_.commitments.commitInput.outPoint.txid == w.tx.txid).map(_.fundingTx)
d.alternativeCommitments.filterNot(_.commitments.fundingTxId == w.tx.txid).map(_.fundingTx)
rollbackDualFundingTxs(otherFundingTxs)
val commitTx = commitments1.fullySignedLocalCommitTx(keyManager).tx
val localCommitPublished = Closing.LocalClose.claimCommitTxOutputs(keyManager, commitments1, commitTx, nodeParams.currentBlockHeight, nodeParams.onChainFeeConf)
val d1 = DATA_CLOSING(commitments1, None, d.waitingSince, alternativeCommitments = Nil, mutualCloseProposed = Nil, localCommitPublished = Some(localCommitPublished))
stay() using d1 storing() calling doPublish(localCommitPublished, commitments1)
case None =>
if (d.commitments.commitInput.outPoint.txid == w.tx.txid) {
if (d.commitments.fundingTxId == w.tx.txid) {
// The best funding tx candidate has been confirmed, we can forget alternative commitments.
stay() using d.copy(alternativeCommitments = Nil) storing()
} else {
Expand Down Expand Up @@ -1327,7 +1327,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val

case Event(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL) => handleUpdateRelayFeeDisconnected(c, d)

case Event(getTxResponse: GetTxWithMetaResponse, d: DATA_WAIT_FOR_FUNDING_CONFIRMED) if getTxResponse.txid == d.commitments.commitInput.outPoint.txid => handleGetFundingTx(getTxResponse, d.waitingSince, d.fundingTx)
case Event(getTxResponse: GetTxWithMetaResponse, d: DATA_WAIT_FOR_FUNDING_CONFIRMED) if getTxResponse.txid == d.commitments.fundingTxId => handleGetFundingTx(getTxResponse, d.waitingSince, d.fundingTx)

case Event(BITCOIN_FUNDING_PUBLISH_FAILED, d: DATA_WAIT_FOR_FUNDING_CONFIRMED) => handleFundingPublishFailed(d)

Expand Down Expand Up @@ -1371,7 +1371,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val
defaultMinDepth.toLong
}
// we put back the watch (operation is idempotent) because the event may have been fired while we were in OFFLINE
blockchain ! WatchFundingConfirmed(self, d.commitments.commitInput.outPoint.txid, minDepth)
blockchain ! WatchFundingConfirmed(self, d.commitments.fundingTxId, minDepth)
goto(WAIT_FOR_FUNDING_CONFIRMED)

case Event(_: ChannelReestablish, d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED) =>
Expand All @@ -1383,7 +1383,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val
log.warning("min_depth should be defined since we're waiting for the funding tx to confirm, using default minDepth={}", defaultMinDepth)
defaultMinDepth.toLong
}
(d.commitments +: d.previousFundingTxs.map(_.commitments)).foreach(commitments => blockchain ! WatchFundingConfirmed(self, commitments.commitInput.outPoint.txid, minDepth))
(d.commitments +: d.previousFundingTxs.map(_.commitments)).foreach(commitments => blockchain ! WatchFundingConfirmed(self, commitments.fundingTxId, minDepth))
goto(WAIT_FOR_DUAL_FUNDING_CONFIRMED) sending d.fundingTx.localSigs

case Event(_: ChannelReestablish, d: DATA_WAIT_FOR_CHANNEL_READY) =>
Expand Down Expand Up @@ -1449,7 +1449,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val
case _ =>
// even if we were just disconnected/reconnected, we need to put back the watch because the event may have been
// fired while we were in OFFLINE (if not, the operation is idempotent anyway)
blockchain ! WatchFundingDeeplyBuried(self, d.commitments.commitInput.outPoint.txid, ANNOUNCEMENTS_MINCONF)
blockchain ! WatchFundingDeeplyBuried(self, d.commitments.fundingTxId, ANNOUNCEMENTS_MINCONF)
}

if (d.commitments.announceChannel) {
Expand Down Expand Up @@ -1529,7 +1529,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val

case Event(c: CurrentFeerates, d: PersistentChannelData) => handleCurrentFeerateDisconnected(c, d)

case Event(getTxResponse: GetTxWithMetaResponse, d: DATA_WAIT_FOR_FUNDING_CONFIRMED) if getTxResponse.txid == d.commitments.commitInput.outPoint.txid => handleGetFundingTx(getTxResponse, d.waitingSince, d.fundingTx)
case Event(getTxResponse: GetTxWithMetaResponse, d: DATA_WAIT_FOR_FUNDING_CONFIRMED) if getTxResponse.txid == d.commitments.fundingTxId => handleGetFundingTx(getTxResponse, d.waitingSince, d.fundingTx)

case Event(BITCOIN_FUNDING_PUBLISH_FAILED, d: DATA_WAIT_FOR_FUNDING_CONFIRMED) => handleFundingPublishFailed(d)

Expand Down
Loading