Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RPC to bump local commit fees #2743

Merged
merged 1 commit into from
Sep 14, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion contrib/eclair-cli.bash-completion
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ _eclair-cli()
*)
# works fine, but is too slow at the moment.
# allopts=$($eclaircli help 2>&1 | awk '$1 ~ /^"/ { sub(/,/, ""); print $1}' | sed 's/[":]//g')
allopts="getinfo connect open cpfpbumpfees close forceclose updaterelayfee peers channels channel closedchannels allnodes allchannels allupdates findroute findroutetonode findroutebetweennodes parseinvoice payinvoice sendtonode getsentinfo createinvoice getinvoice listinvoices listpendinginvoices listreceivedpayments getreceivedinfo audit networkfees channelstats"
allopts="getinfo connect open cpfpbumpfees close forceclose bumpforceclose updaterelayfee peers channels channel closedchannels allnodes allchannels allupdates findroute findroutetonode findroutebetweennodes parseinvoice payinvoice sendtonode getsentinfo createinvoice getinvoice listinvoices listpendinginvoices listreceivedpayments getreceivedinfo audit networkfees channelstats"

if ! [[ " $allopts " =~ " $prev " ]]; then # prevent double arguments
if [[ -z "$cur" || "$cur" =~ ^[a-z] ]]; then
Expand Down
3 changes: 2 additions & 1 deletion docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ scenarii. Note that even in a force close scenario, when an output is only spend
the normal closing priority is used.

Default setting is `medium` for both funding and closing. Node operators may configure their values like so:

```eclair.conf
eclair.on-chain-fees.confirmation-priority {
funding = fast
Expand All @@ -24,7 +25,7 @@ This configuration section replaces the previous `eclair.on-chain-fees.target-bl

### API changes

<insert changes>
- `bumpforceclose` can be used to make a force-close confirm faster, by spending the anchor output (#2743)

### 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 @@ -43,6 +43,7 @@ and COMMAND is one of the available commands:
- spliceout
- close
- forceclose
- bumpforceclose
- channel
- channels
- closedchannels
Expand Down
3 changes: 2 additions & 1 deletion eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ eclair {
}

quiescence-timeout = 1 minutes // maximum time we will stay quiescent (or wait to reach quiescence) before disconnecting
}
}

balance-check-interval = 1 hour

Expand Down Expand Up @@ -254,6 +254,7 @@ eclair {
safe-utxos-threshold = 10

// if false, the commitment transaction will not be fee-bumped when we have no htlcs to claim (used in force-close scenario)
// it can still be manually fee-bumped using the bumpforceclose RPC
// *do not change this unless you know what you are doing*
spend-anchor-without-htlcs = true

Expand Down
13 changes: 12 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import fr.acinq.eclair.balance.{BalanceActor, ChannelsListener}
import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.WalletTx
import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw}
import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerByte, FeeratePerKw}
import fr.acinq.eclair.channel._
import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.db.AuditDb.{NetworkFee, Stats}
Expand Down Expand Up @@ -98,6 +98,8 @@ trait Eclair {

def forceClose(channels: List[ApiTypes.ChannelIdentifier])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_FORCECLOSE]]]]

def bumpForceCloseFee(channels: List[ApiTypes.ChannelIdentifier], confirmationTarget: ConfirmationTarget)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_BUMP_FORCE_CLOSE_FEE]]]]

def updateRelayFee(nodes: List[PublicKey], feeBase: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]]

def channelsInfo(toRemoteNode_opt: Option[PublicKey])(implicit timeout: Timeout): Future[Iterable[RES_GET_CHANNEL_INFO]]
Expand Down Expand Up @@ -251,6 +253,10 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
sendToChannels(channels, CMD_FORCECLOSE(ActorRef.noSender))
}

override def bumpForceCloseFee(channels: List[ApiTypes.ChannelIdentifier], confirmationTarget: ConfirmationTarget)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_BUMP_FORCE_CLOSE_FEE]]]] = {
sendToChannelsTyped(channels, cmdBuilder = CMD_BUMP_FORCE_CLOSE_FEE(_, confirmationTarget))
}

override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] = {
for (nodeId <- nodes) {
appKit.nodeParams.db.peers.addOrUpdateRelayFees(nodeId, RelayFees(feeBaseMsat, feeProportionalMillionths))
Expand Down Expand Up @@ -560,6 +566,11 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
Future.foldLeft(commands)(Map.empty[ApiTypes.ChannelIdentifier, Either[Throwable, R]])(_ + _)
}

private def sendToChannelsTyped[C <: Command, R <: CommandResponse[C]](channels: List[ApiTypes.ChannelIdentifier], cmdBuilder: akka.actor.typed.ActorRef[Any] => C)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, R]]] = {
val commands = channels.map(c => sendToChannelTyped[C, R](c, cmdBuilder).map(r => Right(r)).recover(t => Left(t)).map(r => c -> r))
Future.foldLeft(commands)(Map.empty[ApiTypes.ChannelIdentifier, Either[Throwable, R]])(_ + _)
}

/** Send a request to multiple channels using node ids */
private def sendToNodes[C <: Command, R <: CommandResponse[C]](nodeids: List[PublicKey], request: C)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, R]]] = {
for {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,11 @@ sealed trait ConfirmationPriority {
case ConfirmationPriority.Medium => feerates.medium
case ConfirmationPriority.Fast => feerates.fast
}
override def toString: String = super.toString.toLowerCase
}
object ConfirmationPriority {
case object Slow extends ConfirmationPriority
case object Medium extends ConfirmationPriority
case object Fast extends ConfirmationPriority
case object Slow extends ConfirmationPriority { override def toString = "slow" }
case object Medium extends ConfirmationPriority { override def toString = "medium" }
case object Fast extends ConfirmationPriority { override def toString = "fast" }
}
sealed trait ConfirmationTarget
object ConfirmationTarget {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ package fr.acinq.eclair.channel
import akka.actor.{ActorRef, PossiblyHarmful, typed}
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.bitcoin.scalacompat.{ByteVector32, DeterministicWallet, OutPoint, Satoshi, SatoshiLong, Transaction, TxOut}
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerKw}
import fr.acinq.eclair.channel.LocalFundingStatus.DualFundedUnconfirmedFundingTx
import fr.acinq.eclair.channel.fund.InteractiveTxBuilder._
import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxSigningSession}
Expand Down Expand Up @@ -205,6 +205,7 @@ 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 with ForbiddenCommandDuringSplice with ForbiddenCommandDuringQuiescence
final case class CMD_FORCECLOSE(replyTo: ActorRef) extends CloseCommand
final case class CMD_BUMP_FORCE_CLOSE_FEE(replyTo: akka.actor.typed.ActorRef[CommandResponse[CMD_BUMP_FORCE_CLOSE_FEE]], confirmationTarget: ConfirmationTarget) extends Command

final case class CMD_BUMP_FUNDING_FEE(replyTo: akka.actor.typed.ActorRef[CommandResponse[CMD_BUMP_FUNDING_FEE]], targetFeerate: FeeratePerKw, lockTime: Long) extends Command
case class SpliceIn(additionalLocalFunding: Satoshi, pushAmount: MilliSatoshi = 0 msat)
Expand Down
43 changes: 24 additions & 19 deletions eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey, sha256}
import fr.acinq.bitcoin.scalacompat.Script._
import fr.acinq.bitcoin.scalacompat._
import fr.acinq.eclair._
import fr.acinq.eclair.blockchain.fee.{ConfirmationPriority, ConfirmationTarget, FeeratePerKw, FeeratesPerKw, OnChainFeeConf}
import fr.acinq.eclair.blockchain.fee._
import fr.acinq.eclair.channel.fsm.Channel
import fr.acinq.eclair.channel.fsm.Channel.REFRESH_CHANNEL_UPDATE_INTERVAL
import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager
Expand Down Expand Up @@ -732,7 +732,6 @@ object Helpers {
val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, commitment.localCommit.index.toInt)
val localRevocationPubkey = Generators.revocationPubKey(commitment.remoteParams.revocationBasepoint, localPerCommitmentPoint)
val localDelayedPubkey = Generators.derivePubKey(keyManager.delayedPaymentPoint(channelKeyPath).publicKey, localPerCommitmentPoint)
val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex).publicKey
val feeratePerKwDelayed = onChainFeeConf.getClosingFeerate(feerates)

// first we will claim our main output as soon as the delay is over
Expand All @@ -745,29 +744,35 @@ object Helpers {

val htlcTxs: Map[OutPoint, Option[HtlcTx]] = claimHtlcOutputs(keyManager, commitment)

val lcp = LocalCommitPublished(
commitTx = tx,
claimMainDelayedOutputTx = mainDelayedTx,
htlcTxs = htlcTxs,
claimHtlcDelayedTxs = Nil, // we will claim these once the htlc txs are confirmed
claimAnchorTxs = Nil,
irrevocablySpent = Map.empty
)
val spendAnchors = htlcTxs.nonEmpty || onChainFeeConf.spendAnchorWithoutHtlcs
val claimAnchorTxs: List[ClaimAnchorOutputTx] = if (spendAnchors) {
if (spendAnchors) {
// If we don't have pending HTLCs, we don't have funds at risk, so we can aim for a slower confirmation.
val confirmCommitBefore = htlcTxs.values.flatten.map(htlcTx => htlcTx.confirmationTarget).minByOption(_.confirmBefore).getOrElse(ConfirmationTarget.Priority(onChainFeeConf.feeTargets.closing))
List(
withTxGenerationLog("local-anchor") {
Transactions.makeClaimLocalAnchorOutputTx(tx, localFundingPubKey, confirmCommitBefore)
},
withTxGenerationLog("remote-anchor") {
Transactions.makeClaimRemoteAnchorOutputTx(tx, commitment.remoteFundingPubKey)
}
).flatten
claimAnchors(keyManager, commitment, lcp, confirmCommitBefore)
} else {
Nil
lcp
}
}

LocalCommitPublished(
commitTx = tx,
claimMainDelayedOutputTx = mainDelayedTx,
htlcTxs = htlcTxs,
claimHtlcDelayedTxs = Nil, // we will claim these once the htlc txs are confirmed
claimAnchorTxs = claimAnchorTxs,
irrevocablySpent = Map.empty)
def claimAnchors(keyManager: ChannelKeyManager, commitment: FullCommitment, lcp: LocalCommitPublished, confirmationTarget: ConfirmationTarget)(implicit log: LoggingAdapter): LocalCommitPublished = {
val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex).publicKey
val claimAnchorTxs = List(
withTxGenerationLog("local-anchor") {
Transactions.makeClaimLocalAnchorOutputTx(lcp.commitTx, localFundingPubKey, confirmationTarget)
},
withTxGenerationLog("remote-anchor") {
Transactions.makeClaimRemoteAnchorOutputTx(lcp.commitTx, commitment.remoteFundingPubKey)
}
).flatten
lcp.copy(claimAnchorTxs = claimAnchorTxs)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import fr.acinq.eclair.channel._
import fr.acinq.eclair.channel.fund.InteractiveTxBuilder._
import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxFunder, InteractiveTxSigningSession}
import fr.acinq.eclair.channel.publish.TxPublisher
import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, SetChannelId}
import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx, SetChannelId}
import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager
import fr.acinq.eclair.db.DbEventHandler.ChannelEvent.EventType
import fr.acinq.eclair.db.PendingCommandsDb
Expand Down Expand Up @@ -1705,6 +1705,25 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with

case Event(c: CMD_CLOSE, d: DATA_CLOSING) => handleCommandError(ClosingAlreadyInProgress(d.channelId), c)

case Event(c: CMD_BUMP_FORCE_CLOSE_FEE, d: DATA_CLOSING) =>
d.localCommitPublished match {
case Some(lcp) => d.commitments.params.commitmentFormat match {
case _: Transactions.AnchorOutputsCommitmentFormat =>
val lcp1 = Closing.LocalClose.claimAnchors(keyManager, d.commitments.latest, lcp, c.confirmationTarget)
lcp1.claimAnchorTxs.collect { case tx: Transactions.ClaimLocalAnchorOutputTx => txPublisher ! PublishReplaceableTx(tx, d.commitments.latest) }
c.replyTo ! RES_SUCCESS(c, d.channelId)
stay() using d.copy(localCommitPublished = Some(lcp1))
case Transactions.DefaultCommitmentFormat =>
log.warning("cannot bump force-close fees, channel is not using anchor outputs")
c.replyTo ! RES_FAILURE(c, CommandUnavailableInThisState(d.channelId, "rbf-force-close", stateName))
stay()
}
case None =>
log.warning("cannot bump force-close fees, local commit hasn't been published")
c.replyTo ! RES_FAILURE(c, CommandUnavailableInThisState(d.channelId, "rbf-force-close", stateName))
stay()
}

case Event(e: Error, d: DATA_CLOSING) => handleRemoteError(e, d)

case Event(INPUT_DISCONNECTED | INPUT_RECONNECTED(_, _, _), _) => stay() // we don't really care at this point
Expand Down Expand Up @@ -2117,6 +2136,10 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
c.replyTo ! RES_FAILURE(c, CommandUnavailableInThisState(d.channelId, "rbf", stateName))
stay()

case Event(c: CMD_BUMP_FORCE_CLOSE_FEE, d) =>
c.replyTo ! RES_FAILURE(c, CommandUnavailableInThisState(d.channelId, "rbf-force-close", stateName))
stay()

case Event(c: CMD_SPLICE, d) =>
c.replyTo ! RES_FAILURE(c, CommandUnavailableInThisState(d.channelId, "splice", stateName))
stay()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact
case (ConfirmationTarget.Absolute(currentConfirmBefore), ConfirmationTarget.Absolute(proposedConfirmBefore)) if proposedConfirmBefore < currentConfirmBefore =>
// The proposed block target is closer than what it was
updateConfirmationTarget()
case (_: ConfirmationTarget.Priority, ConfirmationTarget.Absolute(proposedConfirmBefore)) =>
case (_: ConfirmationTarget.Priority, ConfirmationTarget.Absolute(_)) =>
// Switch from relative priority mode to absolute blockheight mode
updateConfirmationTarget()
case _ =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, ByteVector64, Crypto,
import fr.acinq.eclair.ApiTypes.{ChannelIdentifier, ChannelNotFound}
import fr.acinq.eclair.TestConstants._
import fr.acinq.eclair.blockchain.DummyOnChainWallet
import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw}
import fr.acinq.eclair.blockchain.fee.{ConfirmationPriority, ConfirmationTarget, FeeratePerByte, FeeratePerKw}
import fr.acinq.eclair.channel._
import fr.acinq.eclair.db._
import fr.acinq.eclair.io.Peer
Expand Down Expand Up @@ -272,9 +272,15 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I
eclair.forceClose(Left(ByteVector32.Zeroes) :: Nil)
register.expectMsg(Register.Forward(null, ByteVector32.Zeroes, CMD_FORCECLOSE(ActorRef.noSender)))

eclair.bumpForceCloseFee(Left(ByteVector32.Zeroes) :: Nil, ConfirmationTarget.Priority(ConfirmationPriority.Medium))
register.expectMsgType[Register.Forward[CMD_BUMP_FORCE_CLOSE_FEE]]

eclair.forceClose(Right(ShortChannelId.fromCoordinates("568749x2597x0").success.value) :: Nil)
register.expectMsg(Register.ForwardShortId(null, ShortChannelId.fromCoordinates("568749x2597x0").success.value, CMD_FORCECLOSE(ActorRef.noSender)))

eclair.bumpForceCloseFee(Right(ShortChannelId.fromCoordinates("568749x2597x0").success.value) :: Nil, ConfirmationTarget.Priority(ConfirmationPriority.Fast))
register.expectMsgType[Register.ForwardShortId[CMD_BUMP_FORCE_CLOSE_FEE]]

eclair.forceClose(Left(ByteVector32.Zeroes) :: Right(ShortChannelId.fromCoordinates("568749x2597x0").success.value) :: Nil)
register.expectMsgAllOf(
Register.Forward(null, ByteVector32.Zeroes, CMD_FORCECLOSE(ActorRef.noSender)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@

package fr.acinq.eclair.channel.states.h

import akka.actor.typed.scaladsl.adapter.ClassicActorRefOps
import akka.actor.typed.scaladsl.adapter.{ClassicActorRefOps, actorRefAdapter}
import akka.testkit.{TestFSMRef, TestProbe}
import fr.acinq.bitcoin.ScriptFlags
import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey
import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, OutPoint, SatoshiLong, Script, Transaction, TxIn, TxOut}
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._
import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerKw, FeeratesPerKw}
import fr.acinq.eclair.blockchain.fee.{ConfirmationPriority, ConfirmationTarget, FeeratePerKw, FeeratesPerKw}
import fr.acinq.eclair.channel._
import fr.acinq.eclair.channel.fsm.Channel
import fr.acinq.eclair.channel.fsm.Channel.{BITCOIN_FUNDING_PUBLISH_FAILED, BITCOIN_FUNDING_TIMEOUT}
Expand Down Expand Up @@ -376,6 +376,26 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
assert(alice.stateData == initialState) // this was a no-op
}

test("recv CMD_BUMP_FORCE_CLOSE_FEE (local commit)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f =>
import f._

localClose(alice, alice2blockchain)
val initialState = alice.stateData.asInstanceOf[DATA_CLOSING]
assert(initialState.localCommitPublished.nonEmpty)
val localCommitPublished1 = initialState.localCommitPublished.get
assert(localCommitPublished1.claimAnchorTxs.nonEmpty)
val Some(localAnchor1) = localCommitPublished1.claimAnchorTxs.collectFirst { case tx: ClaimLocalAnchorOutputTx => tx }
assert(localAnchor1.confirmationTarget == ConfirmationTarget.Priority(ConfirmationPriority.Medium))

val replyTo = TestProbe()
alice ! CMD_BUMP_FORCE_CLOSE_FEE(replyTo.ref, ConfirmationTarget.Priority(ConfirmationPriority.Fast))
replyTo.expectMsgType[RES_SUCCESS[CMD_BUMP_FORCE_CLOSE_FEE]]
val localAnchor2 = alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.asInstanceOf[ClaimLocalAnchorOutputTx]
assert(localAnchor2.confirmationTarget == ConfirmationTarget.Priority(ConfirmationPriority.Fast))
val localCommitPublished2 = alice.stateData.asInstanceOf[DATA_CLOSING].localCommitPublished.get
assert(localCommitPublished2.claimAnchorTxs.contains(localAnchor2))
}

def testLocalCommitTxConfirmed(f: FixtureParam, channelFeatures: ChannelFeatures): Unit = {
import f._

Expand Down