What changed, and why it matters
This is a large security patch for the Eclair Lightning node that fixes multiple ways an attacker could steal funds, burn money to miners, or lock funds forever. The fixes include: preventing force-closes with un-publishable splice transactions; capping funding/closing miner fees; validating relay fees before opening channels on-the-fly; stopping overflow in fee math; rejecting fake Bitcoin Core change addresses; checking signed PSBTs haven't been altered; extracting payment secrets from newer taproot transactions; and ensuring distinct cryptographic nonces are used when signing commitments.
Upgrade to the patched release as soon as it is available. Operators should review the new max-funding-feerate setting and ensure it is appropriate for current mempool conditions. Nodes running earlier versions are exposed to multiple fund-loss, fund-lock, and fee-burning attacks described in the commit message.
Security signals we found
Force-close uses latest publishable commitment to avoid unconfirmable splice commit txs
Closing fee bounded by maxClosingFeerate when local node pays fees
New max-funding-feerate configuration caps funding/splice miner fees
Interactive-tx funding checks mining fee against 1.5x target feerate
Derivation paths restricted to known BIP84/BIP86 wallet branches
PSBT unsigned transaction txid verified before signing
nodeFee overflow fixed with BigInt saturation
Blinded path hidden fees validated per payment part
On-the-fly funding validates default relay parameters when no channel exists
Minimum to_self_delay enforced to prevent zero-delay taproot script failure
Distinct signing nonces used for initial taproot commitments
Taproot HTLC-success witness with annex now parsed for preimage extraction
canSendFee uses pending signed remote commitment instead of revoked one
Evidence from the diff
The commit bundles several independent defensive fixes: (1) force-close now uses the latest publishable commitment instead of one whose splice funding tx lacks peer signatures, preventing fund-locking/HTLC theft; (2) closing fees paid by the funder are bounded by maxClosingFeerate to avoid burning the whole balance to miners; (3) a new max-funding-feerate config caps funding/splice feerates; (4) interactive-tx funding validates mining fee against target feerate to stop a malicious bitcoind from skipping change; (5) derivation paths from bitcoind are restricted to tracked BIP84/BIP86 wallet paths; (6) PSBT unsigned txid is checked before signing to detect transaction tampering; (7) nodeFee multiplication uses BigInt to avoid negative fees from Long overflow; (8) blinded-path hidden fees are bounded per payment part; (9) on-the-fly funding validates default relay params when no channel_update exists; (10) min to_self_delay enforced; (11) distinct nonces used for initial taproot commitments; (12) preimage extraction handles taproot HTLC-success with annex; (13) canSendFee uses pending signed remote commitment.
Changed components
Eclair Lightning channel state machine (Channel.scala, ErrorHandlers.scala)Channel commitments and closing logic (Commitments.scala, ChannelData.scala, Helpers.scala)On-chain fee configuration (OnChainFeeConf.scala, NodeParams.scala, reference.conf)Bitcoin Core RPC client / PSBT signing (BitcoinCoreClient.scala)Interactive transaction funding (InteractiveTxFunder.scala)On-chain key manager (LocalOnChainKeyManager.scala)Payment relay for blinded paths (ChannelRelay.scala)Offer/payment receiving (OfferManager.scala)Transaction script utilities (Scripts.scala)Single-funded channel opening (ChannelOpenSingleFunded.scala)Inspect captured patch +698 / −98
### docs/release-notes/eclair-vnext.md
@@ -8,7 +8,19 @@
### Configuration changes
-<insert changes>
+#### Add `max-funding-feerate` configuration parameter
+
+The feerate used for funding and splice transactions comes from our fee estimator. We added a new configuration value
+to `eclair.conf` to cap that feerate, so that inaccurate fee estimates cannot make you pay arbitrarily high mining fees
+on every channel open or splice, similarly to what `max-closing-feerate` does for closing transactions:
+
+```conf
+// Maximum feerate that will be used for funding and splice transactions, in satoshis per byte.
+eclair.on-chain-fees.max-funding-feerate = 50
+```
+
+If your channel opens and splices don't confirm because the mempool is more congested than that, you can RBF them
+with the `rbfopen` and `rbfsplice` API commands, or increase this value and restart your node.
### API changes
### eclair-core/src/main/resources/reference.conf
@@ -313,6 +313,14 @@ eclair {
// This value is in satoshis per byte.
max-closing-feerate = 10
+ // Maximum feerate that will be used for funding and splice transactions.
+ // The feerate for those transactions comes from our fee estimator, which relies on external data (bitcoind or a
+ // third-party API): this upper bound ensures that inaccurate or malicious data cannot make you pay arbitrarily high
+ // mining fees. If this value is too low, channel opens and splices will not confirm when the mempool is congested:
+ // they can be RBF-ed later, or you can increase this value and restart your node.
+ // This value is in satoshis per byte.
+ max-funding-feerate = 50
+
feerate-tolerance {
ratio-low = 0.5 // will allow remote fee rates as low as half our local feerate for funding/splice transactions
ratio-high = 10.0 // will allow remote fee rates as high as 10 times our local feerate for commitment transactions and funding/splice transactions
### eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
@@ -470,11 +470,14 @@ object NodeParams extends Logging {
)
def getRelayFees(relayFeesConfig: Config): RelayFees = {
- val feeBase = MilliSatoshi(relayFeesConfig.getInt("fee-base-msat"))
- // fee base is in msat but is encoded on 32 bits and not 64 in the BOLTs, which is why it has
- // to be below 0x100000000 msat which is about 42 mbtc
- require(feeBase <= MilliSatoshi(0xFFFFFFFFL), "fee-base-msat must be below 42 mbtc")
- RelayFees(feeBase, relayFeesConfig.getInt("fee-proportional-millionths"))
+ // relay fees are encoded on 32 bits and not 64 in the BOLTs, which is why they must fit in a uint32: the base fee
+ // must thus be below 0x100000000 msat, which is about 42 mbtc. Note that we read them as longs, otherwise values
+ // between 2^31 and 2^32 couldn't be expressed at all and the checks below couldn't be reached.
+ val feeBase = MilliSatoshi(relayFeesConfig.getLong("fee-base-msat"))
+ require(MilliSatoshi(0) <= feeBase && feeBase <= MilliSatoshi(0xFFFFFFFFL), "fee-base-msat must be between 0 and 42 mbtc")
+ val feeProportionalMillionths = relayFeesConfig.getLong("fee-proportional-millionths")
+ require(0 <= feeProportionalMillionths && feeProportionalMillionths <= 0xFFFFFFFFL, "fee-proportional-millionths must be between 0 and 0xffffffff")
+ RelayFees(feeBase, feeProportionalMillionths)
}
def getPathFindingConf(config: Config, name: String): PathFindingConf = PathFindingConf(
@@ -634,6 +637,7 @@ object NodeParams extends Logging {
onChainFeeConf = OnChainFeeConf(
feeTargets = feeTargets,
maxClosingFeerate = FeeratePerByte(Satoshi(config.getLong("on-chain-fees.max-closing-feerate"))).perKw,
+ maxFundingFeerate = FeeratePerByte(Satoshi(config.getLong("on-chain-fees.max-funding-feerate"))).perKw,
safeUtxosThreshold = config.getInt("on-chain-fees.safe-utxos-threshold"),
spendAnchorWithoutHtlcs = config.getBoolean("on-chain-fees.spend-anchor-without-htlcs"),
anchorWithoutHtlcsMaxFee = Satoshi(config.getLong("on-chain-fees.anchor-without-htlcs-max-fee-satoshis")),
### eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala
@@ -465,12 +465,25 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val lockUtxos: Bool
//------------------------- SIGNING -------------------------//
+ /**
+ * Bitcoin Core must not modify the transaction that we asked it to update or sign: the unsigned transaction of a PSBT
+ * is immutable (see BIP174), only the per-input and per-output metadata may be filled in.
+ *
+ * We must check this before signing, because we only validate the inputs and outputs that belong to our wallet: if
+ * bitcoin core replaced one of the other outputs (for example the destination of an on-chain payment), we would sign
+ * that modified transaction with SIGHASH_ALL without noticing.
+ */
+ private def checkUnsignedTx(expected: Psbt, actual: Psbt): Unit = {
+ require(actual.global.tx.txid == expected.global.tx.txid, s"bitcoin core modified our unsigned transaction (expected=${expected.global.tx.txid} actual=${actual.global.tx.txid}): bitcoin core may be malicious")
+ }
+
def signPsbt(psbt: Psbt, ourInputs: Seq[Int], ourOutputs: Seq[Int])(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] = {
onChainKeyManager_opt match {
case Some(keyManager) =>
for {
updated <- utxoUpdatePsbt(psbt)
filled <- processPsbt(updated, sign = false) // just fill input and output HD paths
+ _ = checkUnsignedTx(psbt, filled.psbt)
signed <- keyManager.sign(filled.psbt, ourInputs, ourOutputs) match {
case Success(signedPsbt) => Future.successful(ProcessPsbtResponse(signedPsbt, signedPsbt.extract().isRight))
case Failure(error) => Future.failed(error)
@@ -480,6 +493,7 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val lockUtxos: Bool
for {
updated <- utxoUpdatePsbt(psbt)
signed <- processPsbt(updated, sign = true)
+ _ = checkUnsignedTx(psbt, signed.psbt)
} yield signed
}
}
@@ -665,6 +679,7 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val lockUtxos: Bool
fundedTx <- fundTransaction(tx, feeratePerKw, replaceable = true)
lockedOutputs = fundedTx.tx.txIn.map(_.outPoint)
theirOutputPos = fundedTx.tx.txOut.indexOf(theirOutput)
+ _ = require(theirOutputPos >= 0, s"bitcoin core didn't fund the transaction we requested (output sending $amount to $pubkeyScript is missing): bitcoin core may be malicious")
signedPsbt <- unlockIfFails(lockedOutputs)(signPsbt(new Psbt(fundedTx.tx), fundedTx.tx.txIn.indices, fundedTx.tx.txOut.indices.filterNot(_ == theirOutputPos)))
_ = require(signedPsbt.finalTx_opt.isRight, s"transaction was not fully signed (${signedPsbt.finalTx_opt.left.toOption.get}): bitcoin core may be malicious")
signedTx = signedPsbt.finalTx_opt.toOption.get
### eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConf.scala
@@ -74,6 +74,7 @@ case class FeerateTolerance(ratioLow: Double, ratioHigh: Double, anchorOutputMax
case class OnChainFeeConf(feeTargets: FeeTargets,
maxClosingFeerate: FeeratePerKw,
+ maxFundingFeerate: FeeratePerKw,
safeUtxosThreshold: Int,
spendAnchorWithoutHtlcs: Boolean,
anchorWithoutHtlcsMaxFee: Satoshi,
@@ -99,7 +100,12 @@ case class OnChainFeeConf(feeTargets: FeeTargets,
}
}
- def getFundingFeerate(feerates: FeeratesPerKw): FeeratePerKw = feeTargets.funding.getFeerate(feerates)
+ /**
+ * Get the feerate that should apply to funding and splice transactions. We cap it with a value configured by the node
+ * operator: our fee estimator relies on external data (bitcoind or a third-party API) that could be inaccurate or
+ * malicious, and would otherwise make us pay arbitrarily high mining fees.
+ */
+ def getFundingFeerate(feerates: FeeratesPerKw): FeeratePerKw = feeTargets.funding.getFeerate(feerates).min(maxFundingFeerate)
/**
* Get the feerate that should apply to a channel commitment transaction:
### eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala
@@ -730,6 +730,22 @@ final case class DATA_CLOSING(commitments: Commitments,
remoteFuturePerCommitmentPoint_opt: Option[PublicKey] = None) extends ChannelDataWithCommitments {
val spendingTxs: List[Transaction] = mutualClosePublished.map(_.tx) ::: localCommitPublished.map(_.commitTx).toList ::: remoteCommitPublished.map(_.commitTx).toList ::: nextRemoteCommitPublished.map(_.commitTx).toList ::: futureRemoteCommitPublished.map(_.commitTx).toList ::: revokedCommitPublished.map(_.commitTx)
require(spendingTxs.nonEmpty, "there must be at least one tx published in this state")
+
+ /**
+ * The commitment matching a commit tx that was published (by us or by our peer). This may not be the latest
+ * commitment: our peer may publish an older commitment, and we may not be able to publish our latest commitment
+ * ourselves (see [[Commitments.latestPublishable]]). Transactions spending that commit tx must be created with the
+ * matching commitment, since they use keys and signatures that are specific to it.
+ *
+ * Note that we only know the txid of the current commit txs: revoked or future commit txs cannot be matched and we
+ * fall back to the latest commitment.
+ */
+ def commitmentFor(commitTxId: TxId): FullCommitment = {
+ commitments.all
+ .find(c => c.localCommit.txId == commitTxId || c.remoteCommit.txId == commitTxId || c.nextRemoteCommit_opt.exists(_.txId == commitTxId))
+ .map(c => FullCommitment(commitments.channelParams, commitments.changes, c))
+ .getOrElse(commitments.latest)
+ }
}
final case class DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT(commitments: Commitments, remoteChannelReestablish: ChannelReestablish) extends ChannelDataWithCommitments
@@ -787,40 +803,44 @@ object DATA_CLOSED {
closingAmount = closingTx.toLocalOutput_opt.map(_.amount).getOrElse(0 sat)
)
- def apply(d: DATA_CLOSING, closingType: Helpers.Closing.ClosingType): DATA_CLOSED = DATA_CLOSED(
- channelId = d.channelId,
- remoteNodeId = d.remoteNodeId,
- fundingTxId = d.commitments.latest.fundingTxId,
- fundingOutputIndex = d.commitments.latest.fundingInput.index,
- fundingTxIndex = d.commitments.latest.fundingTxIndex,
- fundingKeyPath = d.commitments.channelParams.localParams.fundingKeyPath.toString(),
- channelFeatures = d.commitments.channelParams.channelFeatures.toString,
- isChannelOpener = d.commitments.latest.channelParams.localParams.isChannelOpener,
- commitmentFormat = d.commitments.latest.commitmentFormat.toString,
- announced = d.commitments.latest.channelParams.announceChannel,
- capacity = d.commitments.latest.capacity,
- closingTxId = closingType.closingTxId,
- closingType = closingType.toString,
- closingScript = d.finalScriptPubKey,
- localBalance = closingType match {
- case _: Closing.CurrentRemoteClose => d.commitments.latest.remoteCommit.spec.toRemote
- case _: Closing.NextRemoteClose => d.commitments.latest.nextRemoteCommit_opt.getOrElse(d.commitments.latest.remoteCommit).spec.toRemote
- case _ => d.commitments.latest.localCommit.spec.toLocal
- },
- remoteBalance = closingType match {
- case _: Closing.CurrentRemoteClose => d.commitments.latest.remoteCommit.spec.toLocal
- case _: Closing.NextRemoteClose => d.commitments.latest.nextRemoteCommit_opt.getOrElse(d.commitments.latest.remoteCommit).spec.toLocal
- case _ => d.commitments.latest.localCommit.spec.toRemote
- },
- closingAmount = closingType match {
- case Closing.MutualClose(closingTx) => closingTx.toLocalOutput_opt.map(_.amount).getOrElse(0 sat)
- case Closing.LocalClose(_, localCommitPublished) => Closing.closingBalance(d.finalScriptPubKey, localCommitPublished)
- case Closing.CurrentRemoteClose(_, remoteCommitPublished) => Closing.closingBalance(d.finalScriptPubKey, remoteCommitPublished)
- case Closing.NextRemoteClose(_, remoteCommitPublished) => Closing.closingBalance(d.finalScriptPubKey, remoteCommitPublished)
- case Closing.RecoveryClose(remoteCommitPublished) => Closing.closingBalance(d.finalScriptPubKey, remoteCommitPublished)
- case Closing.RevokedClose(revokedCommitPublished) => Closing.closingBalance(d.finalScriptPubKey, revokedCommitPublished)
- }
- )
+ def apply(d: DATA_CLOSING, closingType: Helpers.Closing.ClosingType): DATA_CLOSED = {
+ // The commit tx that closed the channel may not be for the latest commitment (see DATA_CLOSING.commitmentFor).
+ val commitment = d.commitmentFor(closingType.closingTxId)
+ DATA_CLOSED(
+ channelId = d.channelId,
+ remoteNodeId = d.remoteNodeId,
+ fundingTxId = commitment.fundingTxId,
+ fundingOutputIndex = commitment.fundingInput.index,
+ fundingTxIndex = commitment.fundingTxIndex,
+ fundingKeyPath = d.commitments.channelParams.localParams.fundingKeyPath.toString(),
+ channelFeatures = d.commitments.channelParams.channelFeatures.toString,
+ isChannelOpener = commitment.channelParams.localParams.isChannelOpener,
+ commitmentFormat = commitment.commitmentFormat.toString,
+ announced = commitment.channelParams.announceChannel,
+ capacity = commitment.capacity,
+ closingTxId = closingType.closingTxId,
+ closingType = closingType.toString,
+ closingScript = d.finalScriptPubKey,
+ localBalance = closingType match {
+ case _: Closing.CurrentRemoteClose => commitment.remoteCommit.spec.toRemote
+ case _: Closing.NextRemoteClose => commitment.nextRemoteCommit_opt.getOrElse(commitment.remoteCommit).spec.toRemote
+ case _ => commitment.localCommit.spec.toLocal
+ },
+ remoteBalance = closingType match {
+ case _: Closing.CurrentRemoteClose => commitment.remoteCommit.spec.toLocal
+ case _: Closing.NextRemoteClose => commitment.nextRemoteCommit_opt.getOrElse(commitment.remoteCommit).spec.toLocal
+ case _ => commitment.localCommit.spec.toRemote
+ },
+ closingAmount = closingType match {
+ case Closing.MutualClose(closingTx) => closingTx.toLocalOutput_opt.map(_.amount).getOrElse(0 sat)
+ case Closing.LocalClose(_, localCommitPublished) => Closing.closingBalance(d.finalScriptPubKey, localCommitPublished)
+ case Closing.CurrentRemoteClose(_, remoteCommitPublished) => Closing.closingBalance(d.finalScriptPubKey, remoteCommitPublished)
+ case Closing.NextRemoteClose(_, remoteCommitPublished) => Closing.closingBalance(d.finalScriptPubKey, remoteCommitPublished)
+ case Closing.RecoveryClose(remoteCommitPublished) => Closing.closingBalance(d.finalScriptPubKey, remoteCommitPublished)
+ case Closing.RevokedClose(revokedCommitPublished) => Closing.closingBalance(d.finalScriptPubKey, revokedCommitPublished)
+ }
+ )
+ }
}
/** Local params that apply for the channel's lifetime. */
### eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala
@@ -50,6 +50,7 @@ case class DustLimitTooSmall (override val channelId: Byte
case class DustLimitTooLarge (override val channelId: ByteVector32, dustLimit: Satoshi, max: Satoshi) extends ChannelException(channelId, s"dustLimit=$dustLimit is too large (max=$max)")
case class DustLimitAboveOurChannelReserve (override val channelId: ByteVector32, dustLimit: Satoshi, channelReserve: Satoshi) extends ChannelException(channelId, s"dustLimit=$dustLimit is above our channelReserve=$channelReserve")
case class ToSelfDelayTooHigh (override val channelId: ByteVector32, toSelfDelay: CltvExpiryDelta, max: CltvExpiryDelta) extends ChannelException(channelId, s"unreasonable to_self_delay=$toSelfDelay (max=$max)")
+case class ToSelfDelayTooLow (override val channelId: ByteVector32, toSelfDelay: CltvExpiryDelta, min: CltvExpiryDelta) extends ChannelException(channelId, s"unreasonable to_self_delay=$toSelfDelay (min=$min)")
case class ChannelReserveTooHigh (override val channelId: ByteVector32, channelReserve: Satoshi, reserveToFundingRatio: Double, maxReserveToFundingRatio: Double) extends ChannelException(channelId, s"channelReserve too high: reserve=$channelReserve fundingRatio=$reserveToFundingRatio maxFundingRatio=$maxReserveToFundingRatio")
case class ChannelReserveBelowOurDustLimit (override val channelId: ByteVector32, channelReserve: Satoshi, dustLimit: Satoshi) extends ChannelException(channelId, s"their channelReserve=$channelReserve is below our dustLimit=$dustLimit")
case class ChannelReserveNotMet (override val channelId: ByteVector32, toLocal: MilliSatoshi, toRemote: MilliSatoshi, reserve: Satoshi) extends ChannelException(channelId, s"channel reserve is not met toLocal=$toLocal toRemote=$toRemote reserve=$reserve")
### eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala
@@ -10,6 +10,7 @@ import fr.acinq.eclair.channel.ChannelSpendSignature.{IndividualSignature, Parti
import fr.acinq.eclair.channel.Helpers.Closing
import fr.acinq.eclair.channel.Monitoring.{Metrics, Tags}
import fr.acinq.eclair.channel.fsm.Channel.ChannelConf
+import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.PartiallySignedSharedTransaction
import fr.acinq.eclair.crypto.keymanager.{ChannelKeys, LocalCommitmentKeys, RemoteCommitmentKeys}
import fr.acinq.eclair.crypto.{NonceGenerator, ShaChain}
import fr.acinq.eclair.payment.OutgoingPaymentPacket
@@ -588,7 +589,9 @@ case class Commitment(fundingTxIndex: Long,
def canSendFee(targetFeerate: FeeratePerKw, params: ChannelParams, changes: CommitmentChanges, feeConf: OnChainFeeConf): Either[ChannelException, Unit] = {
// let's compute the current commitment *as seen by them* with this change taken into account
- val reduced = CommitmentSpec.reduce(remoteCommit.spec, changes.remoteChanges.acked, changes.localChanges.proposed)
+ // we need to base the next current commitment on the last sig we sent, even if we didn't yet receive their revocation
+ val remoteCommit1 = nextRemoteCommit_opt.getOrElse(remoteCommit)
+ val reduced = CommitmentSpec.reduce(remoteCommit1.spec, changes.remoteChanges.acked, changes.localChanges.proposed)
// a node cannot spend pending incoming htlcs, and need to keep funds above the reserve required by the counterparty, after paying the fee
// we look from remote's point of view, so if local is initiator remote doesn't pay the fees
val fees = commitTxTotalCost(remoteCommitParams.dustLimit, reduced, commitmentFormat)
@@ -887,6 +890,20 @@ case class Commitments(channelParams: ChannelParams,
copy(active = commitment +: active)
}
+ /**
+ * When force-closing, we cannot use a commitment for which our peer hasn't sent their tx_signatures: we cannot
+ * publish the corresponding funding transaction, so its commit tx cannot confirm unless our peer publishes that
+ * funding transaction. We instead use the latest commitment whose funding tx we can publish (or that is already
+ * confirmed). Note that this doesn't change the [[latest]] commitment: if our peer later publishes their funding tx,
+ * we will need to force-close again using the corresponding commitment.
+ */
+ def latestPublishable: FullCommitment = {
+ active.find(c => c.localFundingStatus match {
+ case LocalFundingStatus.DualFundedUnconfirmedFundingTx(_: PartiallySignedSharedTransaction, _, _, _) => false
+ case _ => true
+ }).map(c => FullCommitment(channelParams, changes, c)).getOrElse(latest)
+ }
+
// @formatter:off
def localIsQuiescent: Boolean = changes.localChanges.all.isEmpty
def remoteIsQuiescent: Boolean = changes.remoteChanges.all.isEmpty
### eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
@@ -114,6 +114,7 @@ object Helpers {
// BOLT #2: The receiving node MUST fail the channel if: to_self_delay is unreasonably large.
if (open.toSelfDelay > nodeParams.channelConf.maxToLocalDelay) return Left(ToSelfDelayTooHigh(open.temporaryChannelId, open.toSelfDelay, nodeParams.channelConf.maxToLocalDelay))
+ if (open.toSelfDelay < Channel.MIN_TO_SELF_DELAY) return Left(ToSelfDelayTooLow(open.temporaryChannelId, open.toSelfDelay, Channel.MIN_TO_SELF_DELAY))
if (open.dustLimitSatoshis > nodeParams.channelConf.maxRemoteDustLimit) return Left(DustLimitTooLarge(open.temporaryChannelId, open.dustLimitSatoshis, nodeParams.channelConf.maxRemoteDustLimit))
@@ -183,6 +184,7 @@ object Helpers {
// BOLT #2: The receiving node MUST fail the channel if: to_self_delay is unreasonably large.
if (open.toSelfDelay > nodeParams.channelConf.maxToLocalDelay) return Left(ToSelfDelayTooHigh(open.temporaryChannelId, open.toSelfDelay, nodeParams.channelConf.maxToLocalDelay))
+ if (open.toSelfDelay < Channel.MIN_TO_SELF_DELAY) return Left(ToSelfDelayTooLow(open.temporaryChannelId, open.toSelfDelay, Channel.MIN_TO_SELF_DELAY))
if (open.dustLimit < Channel.MIN_DUST_LIMIT) return Left(DustLimitTooSmall(open.temporaryChannelId, open.dustLimit, Channel.MIN_DUST_LIMIT))
if (open.dustLimit > nodeParams.channelConf.maxRemoteDustLimit) return Left(DustLimitTooLarge(open.temporaryChannelId, open.dustLimit, nodeParams.channelConf.maxRemoteDustLimit))
@@ -235,6 +237,7 @@ object Helpers {
// if minimum_depth is unreasonably large:
// MAY reject the channel.
if (accept.toSelfDelay > nodeParams.channelConf.maxToLocalDelay) return Left(ToSelfDelayTooHigh(accept.temporaryChannelId, accept.toSelfDelay, nodeParams.channelConf.maxToLocalDelay))
+ if (accept.toSelfDelay < Channel.MIN_TO_SELF_DELAY) return Left(ToSelfDelayTooLow(accept.temporaryChannelId, accept.toSelfDelay, Channel.MIN_TO_SELF_DELAY))
// if channel_reserve_satoshis is less than dust_limit_satoshis within the open_channel message:
// MUST reject the channel.
@@ -283,6 +286,7 @@ object Helpers {
// if minimum_depth is unreasonably large:
// MAY reject the channel.
if (accept.toSelfDelay > nodeParams.channelConf.maxToLocalDelay) return Left(ToSelfDelayTooHigh(accept.temporaryChannelId, accept.toSelfDelay, nodeParams.channelConf.maxToLocalDelay))
+ if (accept.toSelfDelay < Channel.MIN_TO_SELF_DELAY) return Left(ToSelfDelayTooLow(accept.temporaryChannelId, accept.toSelfDelay, Channel.MIN_TO_SELF_DELAY))
for {
script_opt <- extractShutdownScript(accept.temporaryChannelId, localFeatures, remoteFeatures, accept.upfrontShutdownScript_opt)
@@ -670,11 +674,11 @@ object Helpers {
def isClosingTypeAlreadyKnown(closing: DATA_CLOSING): Option[ClosingType] = {
closing match {
case _ if closing.localCommitPublished.exists(_.isConfirmed) =>
- Some(LocalClose(closing.commitments.latest.localCommit, closing.localCommitPublished.get))
+ Some(LocalClose(closing.commitmentFor(closing.localCommitPublished.get.commitTx.txid).localCommit, closing.localCommitPublished.get))
case _ if closing.remoteCommitPublished.exists(_.isConfirmed) =>
- Some(CurrentRemoteClose(closing.commitments.latest.remoteCommit, closing.remoteCommitPublished.get))
+ Some(CurrentRemoteClose(closing.commitmentFor(closing.remoteCommitPublished.get.commitTx.txid).remoteCommit, closing.remoteCommitPublished.get))
case _ if closing.nextRemoteCommitPublished.exists(_.isConfirmed) =>
- Some(NextRemoteClose(closing.commitments.latest.nextRemoteCommit_opt.get, closing.nextRemoteCommitPublished.get))
+ Some(NextRemoteClose(closing.commitmentFor(closing.nextRemoteCommitPublished.get.commitTx.txid).nextRemoteCommit_opt.get, closing.nextRemoteCommitPublished.get))
case _ if closing.futureRemoteCommitPublished.exists(_.isConfirmed) =>
Some(RecoveryClose(closing.futureRemoteCommitPublished.get))
case _ if closing.revokedCommitPublished.exists(_.isConfirmed) =>
@@ -696,11 +700,11 @@ object Helpers {
val closingTx = closing.mutualClosePublished.find(_.tx.txid == additionalConfirmedTx_opt.get.txid).get
Some(MutualClose(closingTx))
case closing: DATA_CLOSING if closing.localCommitPublished.exists(_.isDone) =>
- Some(LocalClose(closing.commitments.latest.localCommit, closing.localCommitPublished.get))
+ Some(LocalClose(closing.commitmentFor(closing.localCommitPublished.get.commitTx.txid).localCommit, closing.localCommitPublished.get))
case closing: DATA_CLOSING if closing.remoteCommitPublished.exists(_.isDone) =>
- Some(CurrentRemoteClose(closing.commitments.latest.remoteCommit, closing.remoteCommitPublished.get))
+ Some(CurrentRemoteClose(closing.commitmentFor(closing.remoteCommitPublished.get.commitTx.txid).remoteCommit, closing.remoteCommitPublished.get))
case closing: DATA_CLOSING if closing.nextRemoteCommitPublished.exists(_.isDone) =>
- Some(NextRemoteClose(closing.commitments.latest.nextRemoteCommit_opt.get, closing.nextRemoteCommitPublished.get))
+ Some(NextRemoteClose(closing.commitmentFor(closing.nextRemoteCommitPublished.get.commitTx.txid).nextRemoteCommit_opt.get, closing.nextRemoteCommitPublished.get))
case closing: DATA_CLOSING if closing.futureRemoteCommitPublished.exists(_.isDone) =>
Some(RecoveryClose(closing.futureRemoteCommitPublished.get))
case closing: DATA_CLOSING if closing.revokedCommitPublished.exists(_.isDone) =>
@@ -726,16 +730,31 @@ object Helpers {
}
}
- def firstClosingFee(channelKeys: ChannelKeys, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, feerates: ClosingFeerates)(implicit log: LoggingAdapter): ClosingFees = {
- // this is just to estimate the weight, it depends on size of the pubkey scripts
+ /** This is just an estimate of the closing tx weight, which depends on the size of the pubkey scripts. */
+ private def estimateClosingTxWeight(channelKeys: ChannelKeys, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector): Int = {
val dummyClosingTx = ClosingTx.createUnsignedTx(commitment.commitInput(channelKeys), localScriptPubkey, remoteScriptPubkey, commitment.localChannelParams.paysClosingFees, 0 sat, 0 sat, commitment.localCommit.spec)
val dummyPubkey = commitment.remoteFundingPubKey
val dummySig = IndividualSignature(Transactions.PlaceHolderSig)
- val closingWeight = dummyClosingTx.aggregateSigs(dummyPubkey, dummyPubkey, dummySig, dummySig).weight()
+ dummyClosingTx.aggregateSigs(dummyPubkey, dummyPubkey, dummySig, dummySig).weight()
+ }
+
+ def firstClosingFee(channelKeys: ChannelKeys, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, feerates: ClosingFeerates)(implicit log: LoggingAdapter): ClosingFees = {
+ val closingWeight = estimateClosingTxWeight(channelKeys, commitment, localScriptPubkey, remoteScriptPubkey)
log.info(s"using feerates=$feerates for initial closing tx")
feerates.computeFees(closingWeight)
}
+ /**
+ * When we pay the closing fees, our peer may propose a fee outside of our fee range: for compatibility with older
+ * implementations, we try to converge towards their fee instead of failing the channel. But we must never accept
+ * a fee that exceeds what our maximum closing feerate allows, otherwise a malicious peer could make us sign a
+ * closing transaction that burns our whole channel balance to miners.
+ */
+ def maxClosingFee(channelKeys: ChannelKeys, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, onChainFeeConf: OnChainFeeConf): Satoshi = {
+ val closingWeight = estimateClosingTxWeight(channelKeys, commitment, localScriptPubkey, remoteScriptPubkey)
+ Transactions.weight2fee(onChainFeeConf.maxClosingFeerate, closingWeight)
+ }
+
def firstClosingFee(channelKeys: ChannelKeys, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, feerates: FeeratesPerKw, onChainFeeConf: OnChainFeeConf)(implicit log: LoggingAdapter): ClosingFees = {
val requestedFeerate = onChainFeeConf.getClosingFeerate(feerates, maxClosingFeerateOverride_opt = None)
// NB: we choose a minimum fee that ensures the tx will easily propagate while allowing low fees since we can
### eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
@@ -168,6 +168,12 @@ object Channel {
// A dust limit of 354 sat ensures all segwit outputs will relay with default relay policies.
val MIN_DUST_LIMIT: Satoshi = 354 sat
+ // A zero to_self_delay would remove the delay that our peer relies on to punish us if we publish a revoked
+ // commitment. It would also make our main output and our HTLC outputs unspendable when using taproot channels: the
+ // delayed script leaf ends with OP_CHECKSEQUENCEVERIFY, which doesn't remove its operand from the stack, so a zero
+ // delay makes the script terminate with a false stack item.
+ val MIN_TO_SELF_DELAY: CltvExpiryDelta = CltvExpiryDelta(1)
+
// we won't exchange more than this many signatures when negotiating the closing fee
val MAX_NEGOTIATION_ITERATIONS = 20
@@ -400,22 +406,25 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
// - there is no need to attempt to publish transactions for other type of closes
// - there may be 3rd-stage transactions to publish
// - there is a single commitment, the others have all been invalidated
- val commitment = closing.commitments.latest
+ // Note that the commit tx that was published may not match the latest commitment (see DATA_CLOSING.commitmentFor).
val closingFeerate = nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates, closing.maxClosingFeerate_opt)
closingType_opt match {
case Some(c: Closing.MutualClose) =>
doPublish(c.tx, localPaysClosingFees)
case Some(c: Closing.LocalClose) =>
+ val commitment = closing.commitmentFor(c.localCommitPublished.commitTx.txid)
val (_, secondStageTransactions) = Closing.LocalClose.claimCommitTxOutputs(channelKeys, commitment, c.localCommitPublished.commitTx, closingFeerate, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs)
doPublish(c.localCommitPublished, secondStageTransactions, commitment)
val thirdStageTransactions = Closing.LocalClose.claimHtlcDelayedOutputs(c.localCommitPublished, channelKeys, commitment, closingFeerate, closing.finalScriptPubKey)
doPublish(c.localCommitPublished, thirdStageTransactions)
case Some(c: Closing.RemoteClose) =>
+ val commitment = closing.commitmentFor(c.remoteCommitPublished.commitTx.txid)
val (_, secondStageTransactions) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, c.remoteCommit, c.remoteCommitPublished.commitTx, closingFeerate, nodeParams.currentBitcoinCoreFeerates, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs)
doPublish(c.remoteCommitPublished, secondStageTransactions, commitment)
case Some(c: Closing.RecoveryClose) =>
// We cannot do anything in that case: we've already published our recovery transaction before restarting,
// and must wait for it to confirm.
+ val commitment = closing.commitmentFor(c.remoteCommitPublished.commitTx.txid)
doPublish(c.remoteCommitPublished, Closing.RemoteClose.SecondStageTransactions(None, None, Nil), commitment)
case Some(c: Closing.RevokedClose) =>
Closing.RevokedClose.getRemotePerCommitmentSecret(closing.commitments.channelParams, channelKeys, closing.commitments.remotePerCommitmentSecrets, c.revokedCommitPublished.commitTx).foreach {
@@ -436,14 +445,17 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
// - there cannot be 3rd-stage transactions yet, no need to re-compute them
closing.mutualClosePublished.foreach(mcp => doPublish(mcp, localPaysClosingFees))
closing.localCommitPublished.foreach(lcp => {
+ val commitment = closing.commitmentFor(lcp.commitTx.txid)
val (_, secondStageTransactions) = Closing.LocalClose.claimCommitTxOutputs(channelKeys, commitment, lcp.commitTx, closingFeerate, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs)
doPublish(lcp, secondStageTransactions, commitment)
})
closing.remoteCommitPublished.foreach(rcp => {
+ val commitment = closing.commitmentFor(rcp.commitTx.txid)
val (_, secondStageTransactions) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, commitment.remoteCommit, rcp.commitTx, closingFeerate, nodeParams.currentBitcoinCoreFeerates, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs)
doPublish(rcp, secondStageTransactions, commitment)
})
closing.nextRemoteCommitPublished.foreach(rcp => {
+ val commitment = closing.commitmentFor(rcp.commitTx.txid)
val remoteCommit = commitment.nextRemoteCommit_opt.get
val (_, secondStageTransactions) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, remoteCommit, rcp.commitTx, closingFeerate, nodeParams.currentBitcoinCoreFeerates, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs)
doPublish(rcp, secondStageTransactions, commitment)
@@ -459,7 +471,10 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
doPublish(rvk, secondStageTransactions)
}
})
- closing.futureRemoteCommitPublished.foreach(rcp => doPublish(rcp, Closing.RemoteClose.SecondStageTransactions(None, None, Nil), commitment))
+ closing.futureRemoteCommitPublished.foreach(rcp => {
+ val commitment = closing.commitmentFor(rcp.commitTx.txid)
+ doPublish(rcp, Closing.RemoteClose.SecondStageTransactions(None, None, Nil), commitment)
+ })
}
// no need to go OFFLINE, we can directly switch to CLOSING
goto(CLOSING) using closing
@@ -1870,13 +1885,10 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
MutualClose.checkClosingSignature(channelKeys, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, remoteClosingFee, remoteSig) match {
case Right((signedClosingTx, closingSignedRemoteFees)) =>
val lastLocalClosingSigned_opt = d.closingTxProposed.last.lastOption
+ val maxClosingFee = MutualClose.maxClosingFee(channelKeys, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.onChainFeeConf)
if (lastLocalClosingSigned_opt.exists(_.localClosingSigned.feeSatoshis == remoteClosingFee)) {
// they accepted the last fee we sent them, so we close without sending a closing_signed
handleMutualClose(signedClosingTx, Left(d.copy(bestUnpublishedClosingTx_opt = Some(signedClosingTx))))
- } else if (d.closingTxProposed.flatten.size >= MAX_NEGOTIATION_ITERATIONS) {
- // there were too many iterations, we stop negotiating and accept their fee
- log.warning("could not agree on closing fees after {} iterations, accepting their closing fees ({})", MAX_NEGOTIATION_ITERATIONS, remoteClosingFee)
- handleMutualClose(signedClosingTx, Left(d.copy(bestUnpublishedClosingTx_opt = Some(signedClosingTx)))) sending closingSignedRemoteFees
} else if (lastLocalClosingSigned_opt.flatMap(_.localClosingSigned.feeRange_opt).exists(r => r.min <= remoteClosingFee && remoteClosingFee <= r.max)) {
// they chose a fee inside our proposed fee range, so we close and send a closing_signed for that fee
val localFeeRange = lastLocalClosingSigned_opt.flatMap(_.localClosingSigned.feeRange_opt).get
@@ -1885,6 +1897,16 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
} else if (d.commitments.latest.localCommit.spec.toLocal == 0.msat) {
// we have nothing at stake so there is no need to negotiate, we accept their fee right away
handleMutualClose(signedClosingTx, Left(d.copy(bestUnpublishedClosingTx_opt = Some(signedClosingTx)))) sending closingSignedRemoteFees
+ } else if (d.commitments.localChannelParams.paysClosingFees && remoteClosingFee > maxClosingFee) {
+ // We're paying the closing fees and they're proposing a fee outside of our fee range that exceeds our maximum
+ // closing feerate: we refuse to sign it, otherwise a malicious peer could burn our channel balance to miners.
+ // If they never propose an acceptable fee, we can always force-close instead.
+ log.warning("their closing fee is above our maximum closing fee: {} > {}", remoteClosingFee, maxClosingFee)
+ stay() sending Warning(d.channelId, s"closing fee must not exceed $maxClosingFee")
+ } else if (d.closingTxProposed.flatten.size >= MAX_NEGOTIATION_ITERATIONS) {
+ // there were too many iterations, we stop negotiating and accept their fee
+ log.warning("could not agree on closing fees after {} iterations, accepting their closing fees ({})", MAX_NEGOTIATION_ITERATIONS, remoteClosingFee)
+ handleMutualClose(signedClosingTx, Left(d.copy(bestUnpublishedClosingTx_opt = Some(signedClosingTx)))) sending closingSignedRemoteFees
} else {
c.feeRange_opt match {
case Some(ClosingSignedTlv.FeeRange(minFee, maxFee)) if !d.commitments.localChannelParams.paysClosingFees =>
@@ -2058,26 +2080,28 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
case c: CMD_FAIL_MALFORMED_HTLC => d.commitments.sendFailMalformed(c)
}) match {
case Right((commitments1, _)) =>
- val commitment = commitments1.latest
val d1 = c match {
case c: CMD_FULFILL_HTLC =>
log.info("htlc #{} with payment_hash={} was fulfilled downstream, recalculating htlc-success transactions", c.id, c.r)
// We may be able to publish HTLC-success transactions for which we didn't have the preimage.
// We are already watching the corresponding outputs: no need to set additional watches.
d.localCommitPublished.foreach(lcp => {
+ val commitment = d.commitmentFor(lcp.commitTx.txid)
val commitKeys = commitment.localKeys(channelKeys)
Closing.LocalClose.claimHtlcsWithPreimage(channelKeys, commitKeys, commitment, c.r).foreach(htlcTx => {
txPublisher ! TxPublisher.PublishReplaceableTx(htlcTx, lcp.commitTx, commitment, Closing.confirmationTarget(htlcTx))
})
})
d.remoteCommitPublished.foreach(rcp => {
+ val commitment = d.commitmentFor(rcp.commitTx.txid)
val remoteCommit = commitment.remoteCommit
val commitKeys = commitment.remoteKeys(channelKeys, remoteCommit.remotePerCommitmentPoint)
Closing.RemoteClose.claimHtlcsWithPreimage(channelKeys, commitKeys, rcp, commitment, remoteCommit, c.r, d.finalScriptPubKey).foreach(htlcTx => {
txPublisher ! TxPublisher.PublishReplaceableTx(htlcTx, rcp.commitTx, commitment, Closing.confirmationTarget(htlcTx))
})
})
d.nextRemoteCommitPublished.foreach(nrcp => {
+ val commitment = d.commitmentFor(nrcp.commitTx.txid)
val remoteCommit = commitment.nextRemoteCommit_opt.get
val commitKeys = commitment.remoteKeys(channelKeys, remoteCommit.remotePerCommitmentPoint)
Closing.RemoteClose.claimHtlcsWithPreimage(channelKeys, commitKeys, nrcp, commitment, remoteCommit, c.r, d.finalScriptPubKey).foreach(htlcTx => {
@@ -2087,9 +2111,18 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
d.copy(commitments = commitments1)
case _: CMD_FAIL_HTLC | _: CMD_FAIL_MALFORMED_HTLC =>
log.info("htlc #{} was failed downstream, recalculating watched htlc outputs", c.id)
- val lcp1 = d.localCommitPublished.map(lcp => Closing.LocalClose.ignoreFailedIncomingHtlc(c.id, lcp, commitment))
- val rcp1 = d.remoteCommitPublished.map(rcp => Closing.RemoteClose.ignoreFailedIncomingHtlc(c.id, rcp, commitment, commitment.remoteCommit))
- val nrcp1 = d.nextRemoteCommitPublished.map(nrcp => Closing.RemoteClose.ignoreFailedIncomingHtlc(c.id, nrcp, commitment, commitment.nextRemoteCommit_opt.get))
+ val lcp1 = d.localCommitPublished.map(lcp => {
+ val commitment = d.commitmentFor(lcp.commitTx.txid)
+ Closing.LocalClose.ignoreFailedIncomingHtlc(c.id, lcp, commitment)
+ })
+ val rcp1 = d.remoteCommitPublished.map(rcp => {
+ val commitment = d.commitmentFor(rcp.commitTx.txid)
+ Closing.RemoteClose.ignoreFailedIncomingHtlc(c.id, rcp, commitment, commitment.remoteCommit)
+ })
+ val nrcp1 = d.nextRemoteCommitPublished.map(nrcp => {
+ val commitment = d.commitmentFor(nrcp.commitTx.txid)
+ Closing.RemoteClose.ignoreFailedIncomingHtlc(c.id, nrcp, commitment, commitment.nextRemoteCommit_opt.get)
+ })
d.copy(commitments = commitments1, localCommitPublished = lcp1, remoteCommitPublished = rcp1, nextRemoteCommitPublished = nrcp1)
}
handleCommandSuccess(c, d1) storing()
@@ -2129,6 +2162,13 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
)
val d1 = d.copy(commitments = commitments2)
spendLocalCurrent(d1, d.maxClosingFeerate_opt)
+ } else if (d.localCommitPublished.exists(lcp => d.commitments.resolveCommitment(lcp.commitTx).exists(_.fundingTxIndex < commitment.fundingTxIndex))) {
+ // We force-closed using a previous commitment, because our peer hadn't sent their tx_signatures for this
+ // splice transaction and we couldn't publish it. They have now published it and it confirmed, which
+ // double-spends the commit tx we published: we must force-close again using the commitment that spends
+ // this splice transaction.
+ log.warning("splice fundingTxIndex={} fundingTxId={} confirmed after we force-closed using a previous commitment", commitment.fundingTxIndex, commitment.fundingTxId)
+ spendLocalCurrent(d.copy(commitments = commitments1), d.maxClosingFeerate_opt)
} else {
// We're still on the same splice history, nothing to do
stay() using d.copy(commitments = commitments1) storing()
@@ -2251,7 +2291,9 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
// If this is an HTLC transaction, it may reveal preimages that we haven't received yet.
// If we successfully extract those preimages, we can forward them upstream.
log.debug("processing bitcoin output spent by txid={} tx={}", tx.txid, tx)
- val extracted = Closing.extractPreimages(d.commitments.latest, tx)
+ // If this transaction spends one of the commit txs we're tracking, we use the matching commitment.
+ val commitment = d.commitmentFor(tx.txIn.head.outPoint.txid)
+ val extracted = Closing.extractPreimages(commitment, tx)
extracted.foreach { case (htlc, preimage) =>
d.commitments.originChannels.get(htlc.id) match {
case Some(origin) =>
@@ -2273,7 +2315,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
localCommitPublished = d.localCommitPublished.map(localCommitPublished => {
// If the tx is one of our HTLC txs, we now publish a 3rd-stage transaction that claims its output.
val closingFeerate = nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates, d.maxClosingFeerate_opt)
- val (localCommitPublished1, htlcDelayedTxs) = Closing.LocalClose.claimHtlcDelayedOutput(localCommitPublished, channelKeys, d.commitments.latest, tx, closingFeerate, d.finalScriptPubKey)
+ val (localCommitPublished1, htlcDelayedTxs) = Closing.LocalClose.claimHtlcDelayedOutput(localCommitPublished, channelKeys, d.commitmentFor(localCommitPublished.commitTx.txid), tx, closingFeerate, d.finalScriptPubKey)
doPublish(localCommitPublished1, htlcDelayedTxs)
Closing.updateIrrevocablySpent(localCommitPublished1, tx)
}),
@@ -2294,7 +2336,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
)
// if the local commitment tx just got confirmed, let's send an event telling when we will get the main output refund
if (d1.localCommitPublished.exists(_.commitTx.txid == tx.txid)) {
- context.system.eventStream.publish(LocalCommitConfirmed(self, remoteNodeId, d.channelId, blockHeight + d.commitments.latest.localCommitParams.toSelfDelay.toInt))
+ context.system.eventStream.publish(LocalCommitConfirmed(self, remoteNodeId, d.channelId, blockHeight + d.commitmentFor(tx.txid).localCommitParams.toSelfDelay.toInt))
}
// if the local or remote commitment tx just got confirmed, we abandon anchor transactions that were created based
// on the other commitment: they will never confirm so we must free their wallet inputs.
@@ -2323,8 +2365,8 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
}
// we may need to fail some htlcs in case a commitment tx was published and they have reached the timeout threshold
val timedOutHtlcs = Closing.isClosingTypeAlreadyKnown(d1) match {
- case Some(c: Closing.LocalClose) => Closing.trimmedOrTimedOutHtlcs(channelKeys, d.commitments.latest, c.localCommit, tx)
- case Some(c: Closing.RemoteClose) => Closing.trimmedOrTimedOutHtlcs(channelKeys, d.commitments.latest, c.remoteCommit, tx)
+ case Some(c: Closing.LocalClose) => Closing.trimmedOrTimedOutHtlcs(channelKeys, d.commitmentFor(c.localCommitPublished.commitTx.txid), c.localCommit, tx)
+ case Some(c: Closing.RemoteClose) => Closing.trimmedOrTimedOutHtlcs(channelKeys, d.commitmentFor(c.remoteCommitPublished.commitTx.txid), c.remoteCommit, tx)
case Some(_: Closing.RevokedClose) => Set.empty[UpdateAddHtlc] // revoked commitments are handled using [[overriddenOutgoingHtlcs]] below
case Some(_: Closing.RecoveryClose) => Set.empty[UpdateAddHtlc] // we lose htlc outputs in dataloss protection scenarios (future remote commit)
case Some(_: Closing.MutualClose) => Set.empty[UpdateAddHtlc]
@@ -2381,22 +2423,27 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
val commitmentFormat = d.commitments.latest.commitmentFormat
commitmentFormat match {
case _: Transactions.AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat =>
- val commitment = d.commitments.latest
- val fundingKey = channelKeys.fundingKey(commitment.fundingTxIndex)
+ // The commit txs that were published may not match the latest commitment (see DATA_CLOSING.commitmentFor).
val localAnchor_opt = for {
lcp <- d.localCommitPublished
+ commitment = d.commitmentFor(lcp.commitTx.txid)
+ fundingKey = channelKeys.fundingKey(commitment.fundingTxIndex)
commitKeys = commitment.localKeys(channelKeys)
- anchorTx <- Closing.LocalClose.claimAnchor(fundingKey, commitKeys, lcp.commitTx, commitmentFormat)
+ anchorTx <- Closing.LocalClose.claimAnchor(fundingKey, commitKeys, lcp.commitTx, commitment.commitmentFormat)
} yield PublishReplaceableTx(anchorTx, lcp.commitTx, commitment, c.confirmationTarget)
val remoteAnchor_opt = for {
rcp <- d.remoteCommitPublished
+ commitment = d.commitmentFor(rcp.commitTx.txid)
+ fundingKey = channelKeys.fundingKey(commitment.fundingTxIndex)
commitKeys = commitment.remoteKeys(channelKeys, commitment.remoteCommit.remotePerCommitmentPoint)
- anchorTx <- Closing.RemoteClose.claimAnchor(fundingKey, commitKeys, rcp.commitTx, commitmentFormat)
+ anchorTx <- Closing.RemoteClose.claimAnchor(fundingKey, commitKeys, rcp.commitTx, commitment.commitmentFormat)
} yield PublishReplaceableTx(anchorTx, rcp.commitTx, commitment, c.confirmationTarget)
val nextRemoteAnchor_opt = for {
nrcp <- d.nextRemoteCommitPublished
+ commitment = d.commitmentFor(nrcp.commitTx.txid)
+ fundingKey = channelKeys.fundingKey(commitment.fundingTxIndex)
commitKeys = commitment.remoteKeys(channelKeys, commitment.nextRemoteCommit_opt.get.remotePerCommitmentPoint)
- anchorTx <- Closing.RemoteClose.claimAnchor(fundingKey, commitKeys, nrcp.commitTx, commitmentFormat)
+ anchorTx <- Closing.RemoteClose.claimAnchor(fundingKey, commitKeys, nrcp.commitTx, commitment.commitmentFormat)
} yield PublishReplaceableTx(anchorTx, nrcp.commitTx, commitment, c.confirmationTarget)
// We favor the remote commitment(s) because they're more interesting than the local commitment (no CSV delays).
if (remoteAnchor_opt.nonEmpty) {
### eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala
@@ -225,7 +225,9 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers {
val remoteCommit = RemoteCommit(0, remoteSpec, remoteCommitTx.tx.txid, d.remoteFirstPerCommitmentPoint)
val localSigOfRemoteTx = d.commitmentFormat match {
case _: SimpleTaprootChannelCommitmentFormat =>
- val localNonce = NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0)
+ // We must use a fresh nonce here: our verification nonce is reserved for signing our own commitment,
+ // and signing two different transactions with the same nonce would leak our funding key.
+ val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey, d.remoteFundingPubKey, fundingTx.txid)
remoteNextCommitNonces.get(NonceGenerator.dummyFundingTxId) match {
case Some(remoteNonce) =>
remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match {
@@ -303,7 +305,9 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers {
val channelId = toLongId(fundingTxId, fundingTxOutputIndex)
val localSigOfRemoteTx = d.commitmentFormat match {
case _: SimpleTaprootChannelCommitmentFormat =>
- val localNonce = NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0)
+ // We must use a fresh nonce here: our verification nonce is reserved for signing our own commitment,
+ // and signing two different transactions with the same nonce would leak our funding key.
+ val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey, d.remoteFundingPubKey, fundingTxId)
remoteNextCommitNonces.get(NonceGenerator.dummyFundingTxId) match {
case Some(remoteNonce) =>
remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match {
### eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala
@@ -228,7 +228,12 @@ trait ErrorHandlers extends CommonHandlers {
stay()
} else {
val finalScriptPubKey = getOrGenerateFinalScriptPubKey(d)
- val commitment = d.commitments.latest
+ // If our peer hasn't sent their tx_signatures for our latest splice transaction(s), we cannot publish them and
+ // the corresponding commit txs cannot confirm: we force-close using the latest commitment that can confirm.
+ val commitment = d.commitments.latestPublishable
+ if (commitment.fundingTxIndex < d.commitments.latest.fundingTxIndex) {
+ log.warning("our peer hasn't sent their tx_signatures for fundingTxIndex={}, using fundingTxIndex={} instead", d.commitments.latest.fundingTxIndex, commitment.fundingTxIndex)
+ }
log.error(s"force-closing with fundingIndex=${commitment.fundingTxIndex}")
context.system.eventStream.publish(NotifyNodeOperator(NotificationsLogger.Error, s"force-closing channel ${d.channelId} with fundingIndex=${commitment.fundingTxIndex}"))
val commitTx = commitment.fullySignedLocalCommitTx(channelKeys)
### eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala
@@ -266,13 +266,26 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response
Behaviors.receiveMessagePartial {
case inputDetails: InputDetails if inputDetails.unusableInputs.isEmpty =>
// This funding iteration did not add any unusable inputs, so we can directly return the results.
+ // bitcoind chose the wallet inputs and the change output: we verify that the resulting mining fee matches the
+ // target feerate, otherwise a malicious bitcoind could make us burn our wallet funds to miners. At this point
+ // the transaction only contains our inputs and outputs (and the shared input), so its fee is what we pay.
+ val amountIn = inputDetails.usableInputs.map(_.txOut.amount).sum
+ val amountOut = fundedTx.txOut.map(_.amount).sum
+ val maxWeight = fundedTx.weight() + inputDetails.usableInputs.map {
+ case _: Input.Shared => fundingParams.sharedInput_opt.map(_.weight).getOrElse(0)
+ case _ => Transactions.maxWalletInputWeight
+ }.sum
+ val maxFee = Transactions.weight2fee(fundingParams.targetFeerate * 1.5, maxWeight)
// The transaction should still contain the funding output.
if (fundedTx.txOut.count(_.publicKeyScript == fundingPubkeyScript) != 1) {
log.error("funded transaction is missing the funding output: {}", fundedTx)
sendResultAndStop(FundingFailed, fundedTx.txIn.map(_.outPoint).toSet ++ unusableInputs.map(_.outpoint))
} else if (fundingParams.localOutputs.exists(o => !fundedTx.txOut.contains(o))) {
log.error("funded transaction is missing one of our local outputs: {}", fundedTx)
sendResultAndStop(FundingFailed, fundedTx.txIn.map(_.outPoint).toSet ++ unusableInputs.map(_.outpoint))
+ } else if (amountIn - amountOut > maxFee) {
+ log.error("funded transaction pays excessive mining fees (fee={} max={} targetFeerate={}): bitcoin core may be malicious", amountIn - amountOut, maxFee, fundingParams.targetFeerate)
+ sendResultAndStop(FundingFailed, fundedTx.txIn.map(_.outPoint).toSet ++ unusableInputs.map(_.outpoint))
} else {
val nonChangeOutputs = fundingParams.localOutputs.map(o => Output.Local.NonChange(UInt64(0), o.amount, o.publicKeyScript))
val changeOutput_opt = changePosition.map(i => Output.Local.Change(UInt64(0), fundedTx.txOut(i).amount, fundedTx.txOut(i).publicKeyScript))
### eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalOnChainKeyManager.scala
@@ -95,6 +95,22 @@ class LocalOnChainKeyManager(override val walletName: String, seed: ByteVector,
case _ => AddressType.P2wpkh
}
+ /**
+ * Our wallet only uses BIP84 and BIP86 paths of the form purpose'/coin_type'/account'/{0,1}/index, which are the
+ * paths tracked by the descriptors we import in Bitcoin Core. Bitcoin Core knows our account xpubs, so a malicious
+ * node could otherwise provide a derivation path for which we can derive keys but that our watch-only wallet doesn't
+ * track (e.g. a deeper branch): we would be unable to find the funds sent to those keys.
+ */
+ private def isOurKeyPath(keyPath: KeyPath, addressType: AddressType): Boolean = keyPath.path match {
+ case Seq(purpose, coinType, account, change, index) =>
+ val expectedRoot = addressType match {
+ case AddressType.P2wpkh => KeyPath(rootPathBIP84).path
+ case AddressType.P2tr => KeyPath(rootPathBIP86).path
+ }
+ Seq(purpose, coinType) == expectedRoot && account >= hardened(0) && (change == 0L || change == 1L) && index < hardened(0)
+ case _ => false
+ }
+
override def masterPubKey(account: Long, addressType: AddressType): String = addressType match {
case AddressType.P2tr =>
val prefix = chainHash match {
@@ -118,6 +134,7 @@ class LocalOnChainKeyManager(override val walletName: String, seed: ByteVector,
override def derivePublicKey(keyPath: KeyPath): (Crypto.PublicKey, String) = {
import fr.acinq.bitcoin.scalacompat.KotlinUtils._
+ require(isOurKeyPath(keyPath, addressType(keyPath)), s"derivation path $keyPath is not part of our wallet: bitcoin core may be malicious")
val pub = DeterministicWallet.derivePrivateKey(master, keyPath).publicKey
val address = addressType(keyPath) match {
case AddressType.P2tr => fr.acinq.bitcoin.Bitcoin.computeBIP86Address(pub, chainHash)
@@ -204,6 +221,9 @@ class LocalOnChainKeyManager(override val walletName: String, seed: ByteVector,
// We first check segwit v0.
output.getDerivationPaths.asScala.headOption match {
+ case Some((_, keypath)) if !isOurKeyPath(keypath.keyPath, AddressType.P2wpkh) =>
+ logger.warn(s"derivation path ${keypath.keyPath} is not part of our wallet: bitcoin core may be malicious")
+ false
case Some((pub, keypath)) =>
val expectedKey = derivePublicKey(keypath.keyPath)._1
if (pub != KotlinUtils.scala2kmp(expectedKey)) {
@@ -218,6 +238,9 @@ class LocalOnChainKeyManager(override val walletName: String, seed: ByteVector,
case None =>
// Otherwise, this may be a taproot input.
output.getTaprootDerivationPaths.asScala.headOption match {
+ case Some((_, keypath)) if !isOurKeyPath(keypath.keyPath, AddressType.P2tr) =>
+ logger.warn(s"derivation path ${keypath.keyPath} is not part of our wallet: bitcoin core may be malicious")
+ false
case Some((pub, keypath)) =>
val expectedKey = derivePublicKey(keypath.keyPath)._1
if (pub != output.getTaprootInternalKey) {
@@ -268,6 +291,7 @@ class LocalOnChainKeyManager(override val walletName: String, seed: ByteVector,
// Check that we're signing a p2wpkh input and that the keypath is provided and correct.
require(input.getDerivationPaths.size() == 1, "bip32 derivation path is missing: bitcoin core may be malicious")
val (pub, keypath) = input.getDerivationPaths.asScala.toSeq.head
+ require(isOurKeyPath(keypath.keyPath, AddressType.P2wpkh), s"derivation path ${keypath.keyPath} is not part of our wallet: bitcoin core may be malicious")
val priv = master.priv.derivePrivateKey(keypath.keyPath).getPrivateKey
require(priv.publicKey() == pub, s"derived public key doesn't match (expected=$pub actual=${priv.publicKey()}): bitcoin core may be malicious")
val expectedScript = ByteVector(Script.write(Script.pay2wpkh(pub)))
@@ -305,6 +329,7 @@ class LocalOnChainKeyManager(override val walletName: String, seed: ByteVector,
// Check that we're signing a p2tr input and that the keypath is provided and correct.
require(input.getTaprootDerivationPaths.size() == 1, "bip32 derivation path is missing: bitcoin core may be malicious")
val (pub, keypath) = input.getTaprootDerivationPaths.asScala.toSeq.head
+ require(isOurKeyPath(keypath.keyPath, AddressType.P2tr), s"derivation path ${keypath.keyPath} is not part of our wallet: bitcoin core may be malicious")
val priv = master.priv.derivePrivateKey(keypath.keyPath).getPrivateKey
require(priv.publicKey().xOnly() == pub, s"derived public key doesn't match (expected=$pub actual=${priv.publicKey().xOnly()}): bitcoin core may be malicious")
val expectedScript = Script.write(Script.pay2tr(pub, KeyPathTweak))
### eclair-core/src/main/scala/fr/acinq/eclair/package.scala
@@ -112,7 +112,16 @@ package object eclair {
* @param paymentAmount payment amount in millisatoshi
* @return the fee that a node should be paid to forward an HTLC of 'paymentAmount' millisatoshis
*/
- def nodeFee(baseFee: MilliSatoshi, proportionalFee: Long, paymentAmount: MilliSatoshi): MilliSatoshi = baseFee + (paymentAmount * proportionalFee) / 1000000
+ def nodeFee(baseFee: MilliSatoshi, proportionalFee: Long, paymentAmount: MilliSatoshi): MilliSatoshi = {
+ // Our peers choose the proportional fee they advertise in their channel_update, where it is encoded on 32 bits.
+ // A large value combined with a large payment amount overflows a signed 64-bit multiplication, which would return
+ // a *negative* fee: that would make the corresponding hop look free during path-finding, and would let a peer
+ // relay an HTLC for more than what it paid us. We saturate instead: a fee that we cannot represent must never look
+ // cheap.
+ val fee = BigInt(baseFee.toLong) + BigInt(paymentAmount.toLong) * proportionalFee / 1000000
+ val boundedFee = fee.max(BigInt(Long.MinValue)).min(BigInt(Long.MaxValue))
+ MilliSatoshi(boundedFee.toLong)
+ }
def nodeFee(relayFees: RelayFees, paymentAmount: MilliSatoshi): MilliSatoshi = nodeFee(relayFees.feeBase, relayFees.feeProportionalMillionths, paymentAmount)
### eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferManager.scala
@@ -34,7 +34,7 @@ import fr.acinq.eclair.router.Router
import fr.acinq.eclair.wire.protocol.OfferTypes.{InvoiceRequest, InvoiceTlv, Offer}
import fr.acinq.eclair.wire.protocol.PaymentOnion.FinalPayload
import fr.acinq.eclair.wire.protocol._
-import fr.acinq.eclair.{CltvExpiryDelta, Logs, MilliSatoshi, NodeParams, TimestampMilli, TimestampSecond, nodeFee, randomBytes32}
+import fr.acinq.eclair.{CltvExpiryDelta, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, TimestampMilli, TimestampSecond, nodeFee, randomBytes32}
import scodec.bits.ByteVector
import scala.concurrent.duration.FiniteDuration
@@ -124,14 +124,14 @@ object OfferManager {
case _ => context.log.debug("offer {} is not registered or invoice request is invalid", messagePayload.invoiceRequest.offer.offerId)
}
Behaviors.same
- case ReceivePayment(replyTo, paymentHash, payload, amountReceived) =>
+ case ReceivePayment(replyTo, paymentHash, payload, _) =>
MinimalInvoiceData.decode(payload.pathId) match {
case Some(signed) =>
registeredOffers.get(signed.offerId) match {
case Some(RegisteredOffer(offer, _, _, handler)) =>
MinimalInvoiceData.verify(nodeParams.nodeId, signed) match {
case Some(metadata) if Crypto.sha256(metadata.preimage) == paymentHash =>
- val child = context.spawnAnonymous(PaymentActor(nodeParams, replyTo, offer, metadata, amountReceived, paymentTimeout))
+ val child = context.spawnAnonymous(PaymentActor(nodeParams, replyTo, offer, metadata, payload.amount, paymentTimeout))
handler ! HandlePayment(child, offer, metadata)
case Some(_) => replyTo ! MultiPartHandler.GetIncomingPaymentActor.RejectPayment(s"preimage does not match payment hash for offer ${signed.offerId.toHex}")
case None => replyTo ! MultiPartHandler.GetIncomingPaymentActor.RejectPayment(s"invalid signature for metadata for offer ${signed.offerId.toHex}")
@@ -272,20 +272,34 @@ object OfferManager {
*/
case class RejectPayment(reason: String) extends Command
+ /**
+ * @param partAmount amount of this payment part as set by the payer in the onion: when we hide blinded path fees,
+ * the HTLC amount we receive is smaller than that by the fees paid along the path.
+ */
def apply(nodeParams: NodeParams,
replyTo: ActorRef[MultiPartHandler.GetIncomingPaymentActor.Command],
offer: Offer,
metadata: MinimalInvoiceData,
- amount: MilliSatoshi,
+ partAmount: MilliSatoshi,
timeout: FiniteDuration): Behavior[Command] = {
Behaviors.setup { context =>
context.scheduleOnce(timeout, context.self, RejectPayment("plugin timeout"))
Behaviors.receiveMessage {
case AcceptPayment(additionalTlvs, customTlvs) =>
val minimalInvoice = MinimalBolt12Invoice(offer, nodeParams.chainHash, metadata.amount, metadata.quantity, Crypto.sha256(metadata.preimage), metadata.payerKey, metadata.createdAt, additionalTlvs, customTlvs)
val incomingPayment = IncomingBlindedPayment(minimalInvoice, metadata.preimage, PaymentType.Blinded, TimestampMilli.now(), IncomingPaymentStatus.Pending)
- // We may be deducing some of the blinded path fees from the received amount.
- val maxRecipientPathFees = nodeFee(metadata.recipientPathFees, Seq(amount, metadata.amount).max)
+ // We may be deducing some of the blinded path fees from the received amount. Those fees are paid on each
+ // payment part, so the bound must be computed on this part's amount, not on the whole invoice amount:
+ // otherwise the payer could split the payment to underpay us by the proportional fee on every part.
+ // When hiding fees, we add a small tolerance because LDK doesn't use the BOLT 4 formula when computing
+ // amt_to_forward inside a blinded path (it rounds the forwarded amount down instead of up, keeping up to
+ // 1 msat more per hop) and rounds down the fee it pays as a sender: parts relayed through LDK nodes can
+ // thus be short by a few msat compared to what the aggregated fees predict. This can be removed once LDK
+ // follows the spec formula.
+ val maxRecipientPathFees = metadata.recipientPathFees match {
+ case RelayFees(feeBase, feeProportionalMillionths) if feeBase == 0.msat && feeProportionalMillionths == 0 => 0.msat
+ case recipientPathFees => nodeFee(recipientPathFees, partAmount) + 1000.msat
+ }
replyTo ! MultiPartHandler.GetIncomingPaymentActor.ProcessPayment(incomingPayment, maxRecipientPathFees)
Behaviors.stopped
case RejectPayment(reason) =>
### eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala
@@ -434,12 +434,15 @@ class ChannelRelay private(nodeParams: NodeParams,
val prevUpdate_opt = if (allowPreviousUpdate) outgoingChannel.prevChannelUpdate else None
val htlcMinimumOk = update.htlcMinimumMsat <= r.amountToForward || prevUpdate_opt.exists(_.htlcMinimumMsat <= r.amountToForward)
val expiryDeltaOk = update.cltvExpiryDelta <= r.expiryDelta || prevUpdate_opt.exists(_.cltvExpiryDelta <= r.expiryDelta)
+ // We must never forward more than what we received, whatever our relay fees are. This invariant doesn't depend on
+ // the fee computation, which protects us if that computation is ever incorrect.
+ val amountOk = r.amountToForward <= r.add.amountMsat
val feesOk = nodeFee(update.relayFees, r.amountToForward) <= r.relayFeeMsat || prevUpdate_opt.exists(u => nodeFee(u.relayFees, r.amountToForward) <= r.relayFeeMsat)
if (!htlcMinimumOk) {
Some(makeCmdFailHtlc(r.add.id, AmountBelowMinimum(r.amountToForward, Some(update))))
} else if (!expiryDeltaOk) {
Some(makeCmdFailHtlc(r.add.id, IncorrectCltvExpiry(r.outgoingCltv, Some(update))))
- } else if (!feesOk) {
+ } else if (!amountOk || !feesOk) {
Some(makeCmdFailHtlc(r.add.id, FeeInsufficient(r.add.amountMsat, Some(update))))
} else {
None
@@ -456,10 +459,27 @@ class ChannelRelay private(nodeParams: NodeParams,
}
// If we have a channel with the next peer, but we skipped it because the sender is using invalid relay parameters,
// we don't want to perform on-the-fly funding: the sender should send a valid payment first.
- val relayParamsOk = channels.values.forall(c => validateRelayParams(c).isEmpty)
+ // If we don't have a channel yet, we validate against the parameters we will use for the channel we create.
+ val relayParamsOk = if (channels.nonEmpty) {
+ channels.values.forall(c => validateRelayParams(c).isEmpty)
+ } else {
+ validateDefaultRelayParams()
+ }
featureOk && liquidityIssue && relayParamsOk
}
+ /**
+ * When we don't have a channel with the next node yet, we cannot validate relay parameters against a channel_update.
+ * We validate them against the parameters that the channel we'd create would use instead.
+ */
+ private def validateDefaultRelayParams(): Boolean = {
+ val relayFees = nodeParams.relayParams.defaultFees(announceChannel = false)
+ val htlcMinimumOk = nodeParams.channelConf.htlcMinimum <= r.amountToForward
+ val expiryDeltaOk = nodeParams.channelConf.expiryDelta <= r.expiryDelta
+ val feesOk = nodeFee(relayFees, r.amountToForward) <= r.relayFeeMsat
+ htlcMinimumOk && expiryDeltaOk && feesOk
+ }
+
private def makeCmdFailHtlc(originHtlcId: Long, failure: FailureMessage, delay_opt: Option[FiniteDuration] = None): CMD_FAIL_HTLC = {
val attribution = FailureAttributionData(htlcReceivedAt = upstream.receivedAt, trampolineReceivedAt_opt = None)
CMD_FAIL_HTLC(originHtlcId, FailureReason.LocalFailure(failure), Some(attribution), delay_opt, commit = true)
### eclair-core/src/main/scala/fr/acinq/eclair/transactions/Scripts.scala
@@ -230,8 +230,9 @@ object Scripts {
/** Extract the payment preimage from a 2nd-stage HTLC Success transaction's witness script */
def extractPreimageFromHtlcSuccess: PartialFunction[ScriptWitness, ByteVector32] = {
- case ScriptWitness(Seq(ByteVector.empty, _, _, paymentPreimage, _)) if paymentPreimage.size == 32 => ByteVector32(paymentPreimage)
- case ScriptWitness(Seq(_, _, paymentPreimage, _, _)) if paymentPreimage.size == 32 => ByteVector32(paymentPreimage)
+ case ScriptWitness(Seq(ByteVector.empty, _, _, paymentPreimage, _)) if paymentPreimage.size == 32 => ByteVector32(paymentPreimage) // segwit v0
+ case ScriptWitness(Seq(_, _, paymentPreimage, _, _)) if paymentPreimage.size == 32 => ByteVector32(paymentPreimage) // taproot
+ case ScriptWitness(Seq(_, _, paymentPreimage, _, _, _)) if paymentPreimage.size == 32 => ByteVector32(paymentPreimage) // taproot with annex
}
/** Extract payment preimages from a (potentially batched) 2nd-stage HTLC transaction's witnesses. */
### eclair-core/src/test/scala/fr/acinq/eclair/PackageSpec.scala
@@ -126,4 +126,17 @@ class PackageSpec extends AnyFunSuite {
}
}
+ test("node fees must not wrap") {
+ val amountToForward = 4_294_967_299L.msat
+ val proportionalFee = Int.MaxValue.toLong
+
+ // The exact fee fits in a Long, but the intermediate multiplication does not.
+ val expectedFee = 9_223_372_039_002L.msat
+ assert(nodeFee(0.msat, proportionalFee, amountToForward) == expectedFee)
+
+ // A wrapped negative fee lets an incoming HTLC of 1 msat pay for a larger outgoing HTLC.
+ val attackerPaidFee = 1.msat - amountToForward
+ assert(expectedFee > attackerPaidFee)
+ }
+
}
### eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala
@@ -167,6 +167,7 @@ object TestConstants {
onChainFeeConf = OnChainFeeConf(
feeTargets = FeeTargets(funding = ConfirmationPriority.Medium, closing = ConfirmationPriority.Medium),
maxClosingFeerate = FeeratePerKw(15_000 sat),
+ maxFundingFeerate = FeeratePerKw(250_000 sat),
safeUtxosThreshold = 0,
spendAnchorWithoutHtlcs = true,
anchorWithoutHtlcsMaxFee = 100_000.sat,
@@ -391,6 +392,7 @@ object TestConstants {
onChainFeeConf = OnChainFeeConf(
feeTargets = FeeTargets(funding = ConfirmationPriority.Medium, closing = ConfirmationPriority.Medium),
maxClosingFeerate = FeeratePerKw(15_000 sat),
+ maxFundingFeerate = FeeratePerKw(250_000 sat),
safeUtxosThreshold = 0,
spendAnchorWithoutHtlcs = true,
anchorWithoutHtlcsMaxFee = 100_000.sat,
### eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala
@@ -207,7 +207,7 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnChainAddressCache {
} yield MakeFundingTxResponse(signedTx, 0, fundedTx.fee)
}
- override def commit(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = Future.successful(true)
+ override def commit(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = publishTransaction(tx).map(_ => true)
override def getTransaction(txId: TxId)(implicit ec: ExecutionContext): Future[Transaction] = synchronized {
inputs.find(_.txid == txId) match {
### eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConfSpec.scala
@@ -25,10 +25,11 @@ class OnChainFeeConfSpec extends AnyFunSuite {
private val defaultFeeTargets = FeeTargets(funding = ConfirmationPriority.Medium, closing = ConfirmationPriority.Medium)
private val defaultMaxClosingFeerate = FeeratePerKw(10_000 sat)
+ private val defaultMaxFundingFeerate = FeeratePerKw(100_000 sat)
private val defaultFeerateTolerance = FeerateTolerance(0.5, 2.0, FeeratePerKw(2500 sat), DustTolerance(15000 sat, closeOnUpdateFeeOverflow = false))
test("should update fee when diff ratio exceeded") {
- val feeConf = OnChainFeeConf(defaultFeeTargets, defaultMaxClosingFeerate, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, anchorWithoutHtlcsMaxFee = 10_000.sat, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty)
+ val feeConf = OnChainFeeConf(defaultFeeTargets, defaultMaxClosingFeerate, defaultMaxFundingFeerate, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, anchorWithoutHtlcsMaxFee = 10_000.sat, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty)
assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1000 sat), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat))
assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(900 sat), ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat))
assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1100 sat), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat))
@@ -37,7 +38,7 @@ class OnChainFeeConfSpec extends AnyFunSuite {
}
test("should update fee to set to 1 sat/byte") {
- val feeConf = OnChainFeeConf(defaultFeeTargets, defaultMaxClosingFeerate, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, anchorWithoutHtlcsMaxFee = 10_000.sat, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty)
+ val feeConf = OnChainFeeConf(defaultFeeTargets, defaultMaxClosingFeerate, defaultMaxFundingFeerate, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, anchorWithoutHtlcsMaxFee = 10_000.sat, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty)
// We always use 1 sat/byte for mobile wallet commitment formats, regardless of the current feerate.
val feerates = FeeratesPerKw.single(FeeratePerKw(FeeratePerByte(20 sat)))
assert(feeConf.getCommitmentFeerate(feerates, randomKey().publicKey, UnsafeLegacyAnchorOutputsCommitmentFormat) == FeeratePerKw(FeeratePerByte(1 sat)))
@@ -52,7 +53,7 @@ class OnChainFeeConfSpec extends AnyFunSuite {
val defaultMaxCommitFeerate = defaultFeerateTolerance.anchorOutputMaxCommitFeerate
val overrideNodeId = randomKey().publicKey
val overrideMaxCommitFeerate = defaultMaxCommitFeerate * 2
- val feeConf = OnChainFeeConf(defaultFeeTargets, defaultMaxClosingFeerate, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, anchorWithoutHtlcsMaxFee = 10_000.sat, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map(overrideNodeId -> defaultFeerateTolerance.copy(anchorOutputMaxCommitFeerate = overrideMaxCommitFeerate)))
+ val feeConf = OnChainFeeConf(defaultFeeTargets, defaultMaxClosingFeerate, defaultMaxFundingFeerate, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, anchorWithoutHtlcsMaxFee = 10_000.sat, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map(overrideNodeId -> defaultFeerateTolerance.copy(anchorOutputMaxCommitFeerate = overrideMaxCommitFeerate)))
val feerates1 = FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(fast = defaultMaxCommitFeerate / 2, minimum = FeeratePerKw(250 sat))
assert(feeConf.getCommitmentFeerate(feerates1, defaultNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == defaultMaxCommitFeerate / 2)
@@ -88,7 +89,7 @@ class OnChainFeeConfSpec extends AnyFunSuite {
test("get closing feerate") {
val maxClosingFeerate = FeeratePerKw(2500 sat)
val feeTargets = FeeTargets(funding = ConfirmationPriority.Medium, closing = ConfirmationPriority.Fast)
- val feeConf = OnChainFeeConf(feeTargets, maxClosingFeerate, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, anchorWithoutHtlcsMaxFee = 10_000.sat, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty)
+ val feeConf = OnChainFeeConf(feeTargets, maxClosingFeerate, defaultMaxFundingFeerate, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, anchorWithoutHtlcsMaxFee = 10_000.sat, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty)
val feerates1 = FeeratesPerKw.single(FeeratePerKw(1000 sat)).copy(fast = FeeratePerKw(1500 sat))
assert(feeConf.getClosingFeerate(feerates1, None) == FeeratePerKw(1500 sat))
val feerates2 = FeeratesPerKw.single(FeeratePerKw(1000 sat)).copy(fast = FeeratePerKw(500 sat))
@@ -99,6 +100,17 @@ class OnChainFeeConfSpec extends AnyFunSuite {
assert(feeConf.getClosingFeerate(feerates3, maxClosingFeerateOverride_opt = Some(FeeratePerKw(2400 sat))) == FeeratePerKw(2400 sat))
}
+ test("get funding feerate") {
+ val maxFundingFeerate = FeeratePerKw(5000 sat)
+ val feeTargets = FeeTargets(funding = ConfirmationPriority.Fast, closing = ConfirmationPriority.Medium)
+ val feeConf = OnChainFeeConf(feeTargets, defaultMaxClosingFeerate, maxFundingFeerate, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, anchorWithoutHtlcsMaxFee = 10_000.sat, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty)
+ val feerates1 = FeeratesPerKw.single(FeeratePerKw(1000 sat)).copy(fast = FeeratePerKw(2500 sat))
+ assert(feeConf.getFundingFeerate(feerates1) == FeeratePerKw(2500 sat))
+ // Our fee estimator returns an absurdly high feerate: we cap it with the configured maximum.
+ val feerates2 = FeeratesPerKw.single(FeeratePerKw(1000 sat)).copy(fast = FeeratePerKw(500_000 sat))
+ assert(feeConf.getFundingFeerate(feerates2) == maxFundingFeerate)
+ }
+
test("fee difference too high") {
val tolerance = FeerateTolerance(ratioLow = 0.5, ratioHigh = 4.0, anchorOutputMaxCommitFeerate = FeeratePerKw(2500 sat), DustTolerance(25000 sat, closeOnUpdateFeeOverflow = false))
val testCases = Seq(
### eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala
@@ -42,6 +42,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
private val feeConfNoMismatch = OnChainFeeConf(
feeTargets = FeeTargets(funding = ConfirmationPriority.Medium, closing = ConfirmationPriority.Medium),
maxClosingFeerate = FeeratePerKw(10_000 sat),
+ maxFundingFeerate = FeeratePerKw(100_000 sat),
safeUtxosThreshold = 0,
spendAnchorWithoutHtlcs = true,
anchorWithoutHtlcsMaxFee = 10_000.sat,
### eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala
@@ -47,7 +47,7 @@ import scodec.bits.{ByteVector, HexStringSyntax}
import java.util.UUID
import scala.concurrent.ExecutionContext.Implicits.global
-import scala.concurrent.Future
+import scala.concurrent.{ExecutionContext, Future}
import scala.concurrent.duration.DurationInt
import scala.reflect.ClassTag
@@ -2537,6 +2537,24 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
}
}
+ test("bitcoind pays excessive mining fees") {
+ val probe = TestProbe()
+ // A malicious bitcoind selects inputs worth much more than what the transaction needs and doesn't add a change
+ // output: the difference would be paid to miners.
+ val wallet = new SingleKeyOnChainWallet() {
+ override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, changePosition: Option[Int], externalInputsWeight: Map[OutPoint, Long], minInputConfirmations_opt: Option[Int], feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = {
+ super.fundTransaction(tx, feeRate, replaceable, changePosition, externalInputsWeight, minInputConfirmations_opt, feeBudget_opt)(ec).map(funded => {
+ val excessiveFee = funded.fee + funded.tx.txOut.last.amount
+ funded.copy(tx = funded.tx.copy(txOut = funded.tx.txOut.dropRight(1)), fee = excessiveFee, changePosition = None)
+ })(ec)
+ }
+ }
+ val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 75_000 sat, 0 sat, FeeratePerKw(5000 sat), 500 sat, 0)
+ val alice = params.spawnTxBuilderAlice(wallet)
+ alice ! Start(probe.ref)
+ assert(probe.expectMsgType[LocalFailure].cause == ChannelFundingError(params.channelId))
+ }
+
test("invalid funding contributions") {
val probe = TestProbe()
val wallet = new SingleKeyOnChainWallet()
### eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala
@@ -194,6 +194,18 @@ class WaitForAcceptChannelStateSpec extends TestKitBaseClass with FixtureAnyFunS
aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected]
}
+ test("recv AcceptChannel (to_self_delay too low)") { f =>
+ import f._
+ val accept = bob2alice.expectMsgType[AcceptChannel]
+ val delayTooLow = CltvExpiryDelta(0)
+ alice ! accept.copy(toSelfDelay = delayTooLow)
+ val error = alice2bob.expectMsgType[Error]
+ assert(error == Error(accept.temporaryChannelId, ToSelfDelayTooLow(accept.temporaryChannelId, delayTooLow, Channel.MIN_TO_SELF_DELAY).getMessage))
+ listener.expectMsgType[ChannelAborted]
+ awaitCond(alice.stateName == CLOSED)
+ aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected]
+ }
+
test("recv AcceptChannel (reserve too high)") { f =>
import f._
val accept = bob2alice.expectMsgType[AcceptChannel]
### eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptDualFundedChannelStateSpec.scala
@@ -26,7 +26,7 @@ import fr.acinq.eclair.channel.fsm.Channel.TickChannelOpenTimeout
import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags}
import fr.acinq.eclair.io.Peer.OpenChannelResponse
import fr.acinq.eclair.wire.protocol.{AcceptDualFundedChannel, ChannelTlv, Error, LiquidityAds, OpenDualFundedChannel}
-import fr.acinq.eclair.{MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes64}
+import fr.acinq.eclair.{CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes64}
import org.scalatest.funsuite.FixtureAnyFunSuiteLike
import org.scalatest.{Outcome, Tag}
@@ -236,6 +236,18 @@ class WaitForAcceptDualFundedChannelStateSpec extends TestKitBaseClass with Fixt
aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected]
}
+ test("recv AcceptDualFundedChannel (to_self_delay too low)", Tag(ChannelStateTestsTags.DualFunding)) { f =>
+ import f._
+ val accept = bob2alice.expectMsgType[AcceptDualFundedChannel]
+ val delayTooLow = CltvExpiryDelta(0)
+ alice ! accept.copy(toSelfDelay = delayTooLow)
+ val error = alice2bob.expectMsgType[Error]
+ assert(error == Error(accept.temporaryChannelId, ToSelfDelayTooLow(accept.temporaryChannelId, delayTooLow, Channel.MIN_TO_SELF_DELAY).getMessage))
+ listener.expectMsgType[ChannelAborted]
+ awaitCond(alice.stateName == CLOSED)
+ aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected]
+ }
+
test("recv AcceptDualFundedChannel (channel_id already used)", Tag(ChannelStateTestsTags.DualFunding)) { f =>
import f._
val accept = bob2alice.expectMsgType[AcceptDualFundedChannel]
### eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala
@@ -208,6 +208,17 @@ class WaitForOpenChannelStateSpec extends TestKitBaseClass with FixtureAnyFunSui
awaitCond(bob.stateName == CLOSED)
}
+ test("recv OpenChannel (to_self_delay too low)") { f =>
+ import f._
+ val open = alice2bob.expectMsgType[OpenChannel]
+ val delayTooLow = CltvExpiryDelta(0)
+ bob ! open.copy(toSelfDelay = delayTooLow)
+ val error = bob2alice.expectMsgType[Error]
+ assert(error == Error(open.temporaryChannelId, ToSelfDelayTooLow(open.temporaryChannelId, delayTooLow, Channel.MIN_TO_SELF_DELAY).getMessage))
+ listener.expectMsgType[ChannelAborted]
+ awaitCond(bob.stateName == CLOSED)
+ }
+
test("recv OpenChannel (reserve too high)") { f =>
import f._
val open = alice2bob.expectMsgType[OpenChannel]
### eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala
@@ -24,7 +24,7 @@ import fr.acinq.eclair.channel._
import fr.acinq.eclair.channel.fsm.Channel
import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags}
import fr.acinq.eclair.wire.protocol.{AcceptDualFundedChannel, ChannelTlv, Error, LiquidityAds, OpenDualFundedChannel}
-import fr.acinq.eclair.{MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32}
+import fr.acinq.eclair.{CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32}
import org.scalatest.funsuite.FixtureAnyFunSuiteLike
import org.scalatest.{Outcome, Tag}
@@ -226,6 +226,17 @@ class WaitForOpenDualFundedChannelStateSpec extends TestKitBaseClass with Fixtur
awaitCond(bob.stateName == CLOSED)
}
+ test("recv OpenDualFundedChannel (to_self_delay too low)", Tag(ChannelStateTestsTags.DualFunding)) { f =>
+ import f._
+ val open = alice2bob.expectMsgType[OpenDualFundedChannel]
+ val delayTooLow = CltvExpiryDelta(0)
+ bob ! open.copy(toSelfDelay = delayTooLow)
+ val error = bob2alice.expectMsgType[Error]
+ assert(error == Error(open.temporaryChannelId, ToSelfDelayTooLow(open.temporaryChannelId, delayTooLow, Channel.MIN_TO_SELF_DELAY).getMessage))
+ bobListener.expectMsgType[ChannelAborted]
+ awaitCond(bob.stateName == CLOSED)
+ }
+
test("recv OpenDualFundedChannel (dust limit too high)", Tag(ChannelStateTestsTags.DualFunding)) { f =>
import f._
val open = alice2bob.expectMsgType[OpenDualFundedChannel]
### eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala
@@ -3864,6 +3864,69 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
assert(bob.stateData.asInstanceOf[DATA_CLOSED].fundingTxId == fundingTx2.txid)
}
+ test("force-close with unsigned splice (tx_signatures not received)") { f =>
+ import f._
+
+ val htlcs = setupHtlcs(f)
+ val fundingInput = bob.commitments.latest.fundingInput
+ val fundingTxIndex = bob.commitments.latest.fundingTxIndex
+
+ // Alice splices in: Bob doesn't contribute any input, so he must send his tx_signatures first.
+ val sender = initiateSpliceWithoutSigs(f, spliceIn_opt = Some(SpliceIn(500_000 sat)))
+ alice2bob.expectMsgType[CommitSig]
+ alice2bob.forward(bob)
+ bob2alice.expectMsgType[CommitSig]
+ bob2alice.forward(alice)
+ val bobSigs = bob2alice.expectMsgType[TxSignatures] // Alice doesn't receive Bob's tx_signatures
+ bob2blockchain.expectWatchFundingConfirmed(bobSigs.txId)
+ awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].spliceStatus == SpliceStatus.NoSplice)
+ val spliceCommitment = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest
+ assert(spliceCommitment.fundingTxIndex == fundingTxIndex + 1)
+ assert(spliceCommitment.fundingTxId == bobSigs.txId)
+ assert(spliceCommitment.localFundingStatus.signedTx_opt.isEmpty)
+
+ // Bob cannot publish the splice transaction, so he must force-close using the previous commitment.
+ bob ! CMD_FORCECLOSE(ActorRef.noSender)
+ bob2alice.expectMsgType[Error]
+ val commitTx1 = bob2blockchain.expectFinalTxPublished("commit-tx").tx
+ assert(commitTx1.txIn.map(_.outPoint) == Seq(fundingInput))
+ bob2blockchain.expectReplaceableTxPublished[ClaimLocalAnchorTx]
+ bob2blockchain.expectFinalTxPublished("local-main-delayed")
+ val bobHtlcTimeout1 = htlcs.bobToAlice.map(_ => bob2blockchain.expectReplaceableTxPublished[HtlcTimeoutTx].sign())
+ bobHtlcTimeout1.foreach(htlcTx => Transaction.correctlySpends(htlcTx, Seq(commitTx1), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS))
+ bob2blockchain.expectWatchTxConfirmed(commitTx1.txid)
+ val lcp1 = bob.stateData.asInstanceOf[DATA_CLOSING].localCommitPublished.get
+ assert(lcp1.commitTx.txid == commitTx1.txid)
+ bob2blockchain.expectWatchOutputsSpent(lcp1.localOutput_opt.toSeq ++ lcp1.anchorOutput_opt.toSeq ++ lcp1.htlcOutputs.toSeq)
+ // The unsigned splice commitment is kept, in case Alice publishes the splice transaction.
+ assert(bob.stateData.asInstanceOf[DATA_CLOSING].commitments.latest.fundingTxId == spliceCommitment.fundingTxId)
+ assert(bob.stateData.asInstanceOf[DATA_CLOSING].commitments.active.map(_.fundingTxIndex) == Seq(fundingTxIndex + 1, fundingTxIndex))
+ assert(bob.stateData.asInstanceOf[DATA_CLOSING].commitmentFor(commitTx1.txid).fundingTxIndex == fundingTxIndex)
+
+ // Alice receives Bob's tx_signatures and publishes the splice transaction, which confirms.
+ bob2alice.forward(alice, bobSigs)
+ alice2bob.expectMsgType[TxSignatures]
+ sender.expectMsgType[RES_SPLICE]
+ val spliceTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get
+ assert(spliceTx.txid == spliceCommitment.fundingTxId)
+ bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, spliceTx)
+ bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == spliceTx.txid)
+ bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
+ // Bob's previous commit tx has been double-spent by the splice transaction: he force-closes using the splice commitment.
+ val commitTx2 = bob2blockchain.expectFinalTxPublished("commit-tx").tx
+ Transaction.correctlySpends(commitTx2, Seq(spliceTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
+ bob2blockchain.expectReplaceableTxPublished[ClaimLocalAnchorTx]
+ bob2blockchain.expectFinalTxPublished("local-main-delayed")
+ val bobHtlcTimeout2 = htlcs.bobToAlice.map(_ => bob2blockchain.expectReplaceableTxPublished[HtlcTimeoutTx].sign())
+ bobHtlcTimeout2.foreach(htlcTx => Transaction.correctlySpends(htlcTx, Seq(commitTx2), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS))
+ bob2blockchain.expectWatchTxConfirmed(commitTx2.txid)
+ val lcp2 = bob.stateData.asInstanceOf[DATA_CLOSING].localCommitPublished.get
+ assert(lcp2.commitTx.txid == commitTx2.txid)
+ bob2blockchain.expectWatchOutputsSpent(lcp2.localOutput_opt.toSeq ++ lcp2.anchorOutput_opt.toSeq ++ lcp2.htlcOutputs.toSeq)
+ assert(bob.stateData.asInstanceOf[DATA_CLOSING].commitments.latest.fundingTxId == spliceTx.txid)
+ assert(bob.stateData.asInstanceOf[DATA_CLOSING].commitmentFor(commitTx2.txid).fundingTxId == spliceTx.txid)
+ }
+
test("force-close with multiple splices (previous active remote)", Tag(ChannelStateTestsTags.OptionSimpleTaproot), Tag(ChannelStateTestsTags.ZeroConf)) { f =>
import f._
### eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala
@@ -2265,6 +2265,25 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
assert(initialState == bob.stateData)
}
+ test("recv CMD_UPDATE_FEE (sender can't afford it with pending signed htlc)") { f =>
+ import f._
+ val sender = TestProbe()
+ // Alice sends an htlc and signs it, but Bob hasn't revoked his previous commitment yet.
+ addHtlc(100_000_000 msat, alice, bob, alice2bob, bob2alice)
+ alice ! CMD_SIGN()
+ alice2bob.expectMsgType[CommitSig] // not forwarded to Bob yet
+ assert(alice.commitments.latest.nextRemoteCommit_opt.nonEmpty)
+ val initialState = alice.stateData.asInstanceOf[DATA_NORMAL]
+ // This feerate is just above the threshold once the pending htlc is taken into account:
+ // (800000 (alice balance) - 100000 (htlc) - 20000 (reserve) - 660 (anchors)) / 1296 (commit tx weight with 1 htlc) = 524182
+ // It would be affordable without that htlc: we must not send it, otherwise Bob would reject it and force-close.
+ val c = CMD_UPDATE_FEE(FeeratePerKw(524183 sat), replyTo_opt = Some(sender.ref))
+ alice ! c
+ sender.expectMsg(RES_FAILURE(c, CannotAffordFees(channelId(alice), missing = 1 sat, reserve = 20000 sat, fees = 680001 sat)))
+ alice2bob.expectNoMessage(100 millis)
+ assert(alice.stateData == initialState)
+ }
+
test("recv UpdateFee") { f =>
import f._
val initialState = bob.stateData.asInstanceOf[DATA_NORMAL]
### eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala
@@ -424,7 +424,7 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
test("recv ClosingSigned (other side ignores our fee range, max iterations reached)") { f =>
import f._
- alice.setBitcoinCoreFeerate(FeeratePerKw(1000 sat))
+ alice.setBitcoinCoreFeerates(buildFeerates(FeeratePerKw(1000 sat)))
aliceClose(f)
for (_ <- 1 to Channel.MAX_NEGOTIATION_ITERATIONS) {
val aliceClosing = alice2bob.expectMsgType[ClosingSigned]
@@ -438,6 +438,31 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == alice.stateData.asInstanceOf[DATA_CLOSING].mutualClosePublished.head.tx)
}
+ test("recv ClosingSigned (fee too high, funder)") { f =>
+ import f._
+ alice.setBitcoinCoreFeerates(buildFeerates(FeeratePerKw(1000 sat)))
+ aliceClose(f)
+ val aliceClosing1 = alice2bob.expectMsgType[ClosingSigned]
+ assert(aliceClosing1.feeSatoshis == 770.sat)
+ val aliceBalance = alice.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.latest.localCommit.spec.toLocal.truncateToSatoshi
+ // Bob ignores our fee range and proposes a fee that would burn Alice's whole balance to miners: Alice refuses to sign.
+ val (_, bobClosing1) = makeLegacyClosingSigned(f, aliceBalance + aliceBalance)
+ bob2alice.send(alice, bobClosing1)
+ alice2bob.expectMsgType[Warning]
+ alice2bob.expectNoMessage(100 millis)
+ assert(alice.stateName == NEGOTIATING)
+ assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].closingTxProposed.last.length == 1)
+ assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].bestUnpublishedClosingTx_opt.isEmpty)
+ // Bob proposes a fee that is above our fee range, but below our maximum closing feerate: Alice keeps negotiating.
+ val (_, bobClosing2) = makeLegacyClosingSigned(f, 2500 sat)
+ bob2alice.send(alice, bobClosing2)
+ val aliceClosing2 = alice2bob.expectMsgType[ClosingSigned]
+ assert(aliceClosing1.feeSatoshis < aliceClosing2.feeSatoshis)
+ assert(aliceClosing2.feeSatoshis < 2500.sat)
+ assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].closingTxProposed.last.length == 2)
+ assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].bestUnpublishedClosingTx_opt.nonEmpty)
+ }
+
test("recv ClosingSigned (fee too low, fundee)") { f =>
import f._
alice.setBitcoinCoreFeerates(buildFeerates(FeeratePerKw(250 sat)))
### eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala
@@ -515,6 +515,35 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
extractPreimageFromHtlcSuccess(f)
}
+ test("recv WatchOutputSpentTriggered (extract preimage from HTLC-success tx with annex, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f =>
+ import f._
+
+ // Alice sends an htlc to Bob.
+ val (preimage, htlc) = addHtlc(50_000_000 msat, alice, bob, alice2bob, bob2alice)
+ crossSign(alice, bob, alice2bob, bob2alice)
+ // Bob has the preimage, but he force-closes before Alice receives it.
+ bob ! CMD_FULFILL_HTLC(htlc.id, preimage, None, None)
+ bob2alice.expectMsgType[UpdateFulfillHtlc] // ignored
+ val (rcp, closingTxs) = localClose(bob, bob2blockchain, htlcSuccessCount = 1)
+ val htlcSuccessTx = closingTxs.htlcSuccessTxs.head
+ // Bob includes an annex in its witness (the signature will be incorrect but we don't check it here).
+ val annexWitness = htlcSuccessTx.txIn.head.witness.copy(stack = htlcSuccessTx.txIn.head.witness.stack :+ ByteVector.fromValidHex("50deadbeef"))
+ val htlcSuccessTxWithAnnex = htlcSuccessTx.updateWitness(0, annexWitness)
+
+ // Alice extracts the preimage and forwards it upstream.
+ alice ! WatchFundingSpentTriggered(rcp.commitTx)
+ alice ! WatchOutputSpentTriggered(htlc.amountMsat.truncateToSatoshi, htlcSuccessTxWithAnnex)
+ inside(alice2relayer.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.OnChainFulfill]]) { fulfill =>
+ assert(fulfill.htlc == htlc)
+ assert(fulfill.result.paymentPreimage == preimage)
+ assert(fulfill.origin == alice.stateData.asInstanceOf[DATA_CLOSING].commitments.originChannels(htlc.id))
+ }
+
+ // The HTLC-success transaction confirms: nothing to do, preimage has already been relayed.
+ alice ! WatchTxConfirmedTriggered(alice.nodeParams.currentBlockHeight, 6, htlcSuccessTxWithAnnex)
+ alice2relayer.expectNoMessage(100 millis)
+ }
+
private def extractPreimageFromRemovedHtlc(f: FixtureParam): Unit = {
import f._
### eclair-core/src/test/scala/fr/acinq/eclair/crypto/keymanager/LocalOnChainKeyManagerSpec.scala
@@ -120,6 +120,43 @@ class LocalOnChainKeyManagerSpec extends AnyFunSuite {
}
}
+ test("reject derivation paths that are not part of our wallet") {
+ val seed = randomBytes32()
+ val onChainKeyManager = new LocalOnChainKeyManager("eclair", seed, TimestampSecond.now(), Block.Testnet3GenesisBlock.hash)
+ val (_, accountPub) = DeterministicWallet.ExtendedPublicKey.decode(onChainKeyManager.masterPubKey(0, AddressType.P2wpkh))
+ val mainPub = DeterministicWallet.derivePublicKey(accountPub, 0)
+ val mainKey = DeterministicWallet.derivePublicKey(mainPub, 0).publicKey
+ // Bitcoin Core knows our account xpub: it can derive keys in branches that our watch-only wallet doesn't track.
+ val deeperKey = DeterministicWallet.derivePublicKey(DeterministicWallet.derivePublicKey(mainPub, 0), 0).publicKey
+ val otherBranchKey = DeterministicWallet.derivePublicKey(DeterministicWallet.derivePublicKey(accountPub, 2), 0).publicKey
+ val invalidPaths = Seq(
+ (deeperKey, "m/84'/1'/0'/0/0/0"),
+ (otherBranchKey, "m/84'/1'/0'/2/0"),
+ (mainKey, "m/84'/1'/0'/0"),
+ )
+
+ // We must not trust addresses using those paths.
+ onChainKeyManager.derivePublicKey(DeterministicWallet.KeyPath("m/84'/1'/0'/0/0"))
+ invalidPaths.foreach { case (_, path) =>
+ val error = intercept[IllegalArgumentException](onChainKeyManager.derivePublicKey(DeterministicWallet.KeyPath(path)))
+ assert(error.getMessage.contains("not part of our wallet"))
+ }
+
+ // We must not accept change outputs using those paths when signing.
+ val utxo = Transaction(version = 2, txIn = Nil, txOut = TxOut(Satoshi(1_000_000), Script.pay2wpkh(mainKey)) :: Nil, lockTime = 0)
+ val mainPath = new KeyPathWithMaster(0, new fr.acinq.bitcoin.KeyPath("m/84'/1'/0'/0/0"))
+ invalidPaths.foreach { case (changeKey, changePath) =>
+ val tx = Transaction(version = 2, txIn = TxIn(OutPoint(utxo, 0), Nil, fr.acinq.bitcoin.TxIn.SEQUENCE_FINAL) :: Nil, txOut = TxOut(Satoshi(900_000), Script.pay2wpkh(changeKey)) :: Nil, lockTime = 0)
+ val Right(psbt) = for {
+ p0 <- new Psbt(tx).updateWitnessInput(OutPoint(utxo, 0), utxo.txOut(0), null, Script.pay2pkh(mainKey), null, java.util.Map.of(mainKey, mainPath), null, null, java.util.Map.of())
+ p1 <- p0.updateNonWitnessInput(utxo, 0, null, null, java.util.Map.of())
+ p2 <- p1.updateWitnessOutput(0, null, null, java.util.Map.of(changeKey, new KeyPathWithMaster(0, new fr.acinq.bitcoin.KeyPath(changePath))), null, java.util.Map.of())
+ } yield p2
+ val Failure(error) = onChainKeyManager.sign(psbt, Seq(0), Seq(0))
+ assert(error.getMessage.contains("could not verify output 0"))
+ }
+ }
+
test("sign psbt (BIP86)") {
val seed = randomBytes32()
val onChainKeyManager = new LocalOnChainKeyManager("eclair", seed, TimestampSecond.now(), Block.Testnet3GenesisBlock.hash)
### eclair-core/src/test/scala/fr/acinq/eclair/payment/offer/OfferManagerSpec.scala
@@ -333,6 +333,41 @@ class OfferManagerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app
assert(incomingPayment.invoice.nodeId == nodeParams.nodeId)
assert(incomingPayment.invoice.paymentHash == invoice.paymentHash)
assert(maxRecipientPathFees >= paymentPayload.amount - amountReceived)
- assert(maxRecipientPathFees == nodeFee(1000 msat, 200, amount))
+ assert(maxRecipientPathFees == nodeFee(1000 msat, 200, amount) + 1000.msat)
+ }
+
+ test("pay offer with hidden fees (multi-part)") { f =>
+ import f._
+
+ val handler = TestProbe[HandlerCommand]()
+ val amount = 10_000_000 msat
+ val offer = Offer(Some(amount), Some("offer"), nodeParams.nodeId, Features.empty, nodeParams.chainHash)
+ offerManager ! RegisterOffer(offer, Some(nodeParams.privateKey), None, handler.ref)
+ // Request invoice.
+ val payerKey = randomKey()
+ requestInvoice(payerKey, offer, nodeParams.privateKey, amount, offerManager, postman.ref)
+ val invoice = receiveInvoice(f, amount, payerKey, nodeParams.nodeId, handler, hops = List(ChannelHop.dummy(nodeParams.nodeId, 1000 msat, 200, CltvExpiryDelta(144))), hideFees = true)
+ // The payer splits the payment in four parts: the fees we hide are paid on each part.
+ val partAmount = 2_500_000 msat
+ val blindedPath = invoice.blindedPaths.head.route
+ val encryptedDataTlvs = decryptBlindedPayload(nodeParams.privateKey, blindedPath.firstPathKey, blindedPath.encryptedPayloads)
+ val paymentTlvs = TlvStream[OnionPaymentPayloadTlv](
+ OnionPaymentPayloadTlv.AmountToForward(partAmount),
+ OnionPaymentPayloadTlv.TotalAmount(amount),
+ OnionPaymentPayloadTlv.OutgoingCltv(CltvExpiry(nodeParams.currentBlockHeight) + invoice.blindedPaths.head.paymentInfo.cltvExpiryDelta),
+ )
+ val paymentPayload = PaymentOnion.FinalPayload.Blinded(paymentTlvs, encryptedDataTlvs)
+ val amountReceived = amountAfterFee(1000 msat, 200, partAmount)
+ offerManager ! ReceivePayment(paymentHandler.ref, invoice.paymentHash, paymentPayload, amountReceived)
+
+ val handlePayment = handler.expectMessageType[HandlePayment]
+ assert(handlePayment.offer == offer)
+ handlePayment.replyTo ! PaymentActor.AcceptPayment()
+ val ProcessPayment(incomingPayment, maxRecipientPathFees) = paymentHandler.expectMessageType[ProcessPayment]
+ assert(incomingPayment.invoice.paymentHash == invoice.paymentHash)
+ // The bound covers the fees actually paid for this part, but not the fees of the whole payment.
+ assert(maxRecipientPathFees >= paymentPayload.amount - amountReceived)
+ assert(maxRecipientPathFees == nodeFee(1000 msat, 200, partAmount) + 1000.msat)
+ assert(maxRecipientPathFees < nodeFee(1000 msat, 200, amount))
}
}
### eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala
@@ -259,9 +259,12 @@ class ChannelRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("a
val (peerReadyManager, switchboard) = createWakeUpActors()
- val u = createLocalUpdate(channelId1, feeBaseMsat = 5000 msat, feeProportionalMillionths = 0)
- val payload = createBlindedPayload(Left(outgoingNodeId), u.channelUpdate, isIntroduction = false)
- val r = createValidIncomingPacket(payload, outgoingAmount + u.channelUpdate.feeBaseMsat, outgoingExpiry + u.channelUpdate.cltvExpiryDelta)
+ // We don't have a channel with the next node: the blinded path must use our node's default relay parameters.
+ val defaultFees = nodeParams.relayParams.privateChannelFees
+ val u = createLocalUpdate(channelId1, feeBaseMsat = defaultFees.feeBase, feeProportionalMillionths = defaultFees.feeProportionalMillionths)
+ val update = u.channelUpdate.copy(cltvExpiryDelta = nodeParams.channelConf.expiryDelta)
+ val payload = createBlindedPayload(Left(outgoingNodeId), update, isIntroduction = false)
+ val r = createValidIncomingPacket(payload, outgoingAmount + nodeFee(defaultFees, outgoingAmount), outgoingExpiry + update.cltvExpiryDelta)
channelRelayer ! Relay(r, TestConstants.Alice.nodeParams.nodeId, 0.1)
@@ -275,10 +278,67 @@ class ChannelRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("a
// We don't have any channel, so we attempt on-the-fly funding, but the peer is not available.
val fwdNodeId = register.expectMessageType[ForwardNodeId[Peer.ProposeOnTheFlyFunding]]
assert(fwdNodeId.nodeId == outgoingNodeId)
+ assert(fwdNodeId.message.expiry == outgoingExpiry)
fwdNodeId.replyTo ! Register.ForwardNodeIdFailure(fwdNodeId)
expectFwdFail(register, r.add.channelId, CMD_FAIL_HTLC(r.add.id, FailureReason.LocalFailure(UnknownNextPeer()), None, commit = true))
}
+ test("fail to relay blinded payment (on-the-fly funding with expiry delta too small)", Tag(wakeUpEnabled), Tag(onTheFlyFunding)) { f =>
+ import f._
+
+ val (peerReadyManager, switchboard) = createWakeUpActors()
+
+ // We don't have a channel with the next node, and the blinded path uses an expiry delta that is smaller than what
+ // our node requires: if we relayed that HTLC, the downstream HTLC would expire at the same time as the upstream HTLC.
+ val defaultFees = nodeParams.relayParams.privateChannelFees
+ val u = createLocalUpdate(channelId1, feeBaseMsat = defaultFees.feeBase, feeProportionalMillionths = defaultFees.feeProportionalMillionths)
+ val update = u.channelUpdate.copy(cltvExpiryDelta = CltvExpiryDelta(0))
+ val payload = createBlindedPayload(Left(outgoingNodeId), update, isIntroduction = false)
+ val r = createValidIncomingPacket(payload, outgoingAmount + nodeFee(defaultFees, outgoingAmount), outgoingExpiry)
+ assert(r.outgoingCltv == r.add.cltvExpiry)
+
+ channelRelayer ! Relay(r, TestConstants.Alice.nodeParams.nodeId, 0.1)
+
+ // We try to wake-up the next node.
+ peerReadyManager.expectMessageType[PeerReadyManager.Register].replyTo ! PeerReadyManager.Registered(outgoingNodeId, otherAttempts = 1)
+ val peerInfo = switchboard.expectMessageType[Switchboard.GetPeerInfo]
+ assert(peerInfo.remoteNodeId == outgoingNodeId)
+ peerInfo.replyTo ! Peer.PeerInfo(TestProbe[Any]().ref.toClassic, outgoingNodeId, Peer.CONNECTED, Some(nodeParams.features.initFeatures()), None, Set.empty)
+ cleanUpWakeUpActors(peerReadyManager, switchboard)
+
+ // We must not attempt on-the-fly funding: we fail the payment without funding a channel.
+ expectFwdFail(register, r.add.channelId, CMD_FAIL_HTLC(r.add.id, FailureReason.LocalFailure(UnknownNextPeer()), None, commit = true))
+ register.expectNoMessage(100 millis)
+ }
+
+ test("fail to relay blinded payment (on-the-fly funding with fee too low)", Tag(wakeUpEnabled), Tag(onTheFlyFunding)) { f =>
+ import f._
+
+ val (peerReadyManager, switchboard) = createWakeUpActors()
+
+ // We don't have a channel with the next node, and the blinded path uses a fee that is lower than our node's default
+ // relay fees, which is what the channel we would create would use.
+ val defaultFees = nodeParams.relayParams.privateChannelFees
+ val u = createLocalUpdate(channelId1, feeBaseMsat = 5000 msat, feeProportionalMillionths = 0)
+ val update = u.channelUpdate.copy(cltvExpiryDelta = nodeParams.channelConf.expiryDelta)
+ val payload = createBlindedPayload(Left(outgoingNodeId), update, isIntroduction = false)
+ val r = createValidIncomingPacket(payload, outgoingAmount + update.feeBaseMsat, outgoingExpiry + update.cltvExpiryDelta)
+ assert(r.relayFeeMsat < nodeFee(defaultFees, outgoingAmount))
+
+ channelRelayer ! Relay(r, TestConstants.Alice.nodeParams.nodeId, 0.1)
+
+ // We try to wake-up the next node.
+ peerReadyManager.expectMessageType[PeerReadyManager.Register].replyTo ! PeerReadyManager.Registered(outgoingNodeId, otherAttempts = 1)
+ val peerInfo = switchboard.expectMessageType[Switchboard.GetPeerInfo]
+ assert(peerInfo.remoteNodeId == outgoingNodeId)
+ peerInfo.replyTo ! Peer.PeerInfo(TestProbe[Any]().ref.toClassic, outgoingNodeId, Peer.CONNECTED, Some(nodeParams.features.initFeatures()), None, Set.empty)
+ cleanUpWakeUpActors(peerReadyManager, switchboard)
+
+ // We must not attempt on-the-fly funding: we fail the payment without funding a channel.
+ expectFwdFail(register, r.add.channelId, CMD_FAIL_HTLC(r.add.id, FailureReason.LocalFailure(UnknownNextPeer()), None, commit = true))
+ register.expectNoMessage(100 millis)
+ }
+
test("relay blinded payment (on-the-fly funding not attempted)", Tag(wakeUpEnabled), Tag(onTheFlyFunding)) { f =>
import f._
Why this scored 89/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.