Fix edge case in `availableBalanceForSend/Receive` (#3308)
What changed, and why it matters
This patch fixes a mismatch in how Eclair calculates how much Bitcoin can be sent or received through a Lightning channel. Previously, the balance estimate could say a payment was possible, but the actual safety check would later reject it because the other party couldn't afford the on-chain transaction fees. This could cause payment attempts to fail unexpectedly, especially in edge cases where one side is nearly out of funds. It is a consistency bug rather than a direct theft-of-funds vulnerability.
Apply the patch. It is a low-risk correctness fix. After deployment, monitor for any unexpected zero-balance reports on channels where the fee-paying peer is close to its reserve, and verify that fuzz tests no longer fail on this edge case.
Security signals we found
Logic inconsistency between balance reporting and commitment validation
Missing fee-reserve check in non-initiator fee path
Potential for failed HTLC adds despite positive reported balance
Fuzz-test failures triggered the fix
Evidence from the diff
The commit aligns availableBalanceForSend and availableBalanceForReceive with the existing canSendAdd/canReceiveAdd checks. When the local node is not paying commit-tx fees, the previous balance functions returned balanceNoFees without verifying that the remote (fee-paying) side could still cover commit fees plus channel reserve after adding a new HTLC. The patch adds worst-case fee checks for both directions: when localParams.paysCommitTxFees is false in availableBalanceForSend, and when true in availableBalanceForReceive. If the fee-paying side cannot afford the next HTLC’s fee, the available balance is reported as zero. This prevents the API from advertising a sendable/receivable amount that would be rejected by the actual commitment validation.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scalaavailableBalanceForSendavailableBalanceForReceivecanSendAdd / canReceiveAdd consistencyInspect captured patch +30 / −13
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala
index 5a630f5..2af018d 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala
@@ -327,30 +327,39 @@ case class Commitment(fundingTxIndex: Long,
def availableBalanceForSend(params: ChannelParams, changes: CommitmentChanges): MilliSatoshi = {
import params._
- // we need to base the next current commitment on the last sig we sent, even if we didn't yet receive their revocation
+ // 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)
val balanceNoFees = (reduced.toRemote - localChannelReserve(params)).max(0 msat)
if (localParams.paysCommitTxFees) {
// The initiator always pays the on-chain fees, so we must subtract that from the amount we can send.
val commitFees = commitTxTotalCostMsat(remoteCommitParams.dustLimit, reduced, commitmentFormat)
- // the initiator needs to keep a "funder fee buffer" (see explanation above)
+ // The initiator needs to keep a "funder fee buffer" (see explanation above).
val funderFeeBuffer = commitTxTotalCostMsat(remoteCommitParams.dustLimit, reduced.copy(commitTxFeerate = reduced.commitTxFeerate * 2), commitmentFormat) + htlcOutputFee(reduced.commitTxFeerate * 2, commitmentFormat)
val amountToReserve = commitFees.max(funderFeeBuffer)
if (balanceNoFees - amountToReserve < offeredHtlcTrimThreshold(remoteCommitParams.dustLimit, reduced, commitmentFormat)) {
- // htlc will be trimmed
+ // The htlc will be trimmed.
(balanceNoFees - amountToReserve).max(0 msat)
} else {
- // htlc will have an output in the commitment tx, so there will be additional fees.
+ // The htlc will have an output in the commitment tx, so there will be additional fees.
val commitFees1 = commitFees + htlcOutputFee(reduced.commitTxFeerate, commitmentFormat)
- // we take the additional fees for that htlc output into account in the fee buffer at a x2 feerate increase
+ // We take the additional fees for that htlc output into account in the fee buffer at a x2 feerate increase.
val funderFeeBuffer1 = funderFeeBuffer + htlcOutputFee(reduced.commitTxFeerate * 2, commitmentFormat)
val amountToReserve1 = commitFees1.max(funderFeeBuffer1)
(balanceNoFees - amountToReserve1).max(0 msat)
}
} else {
- // The non-initiator doesn't pay on-chain fees.
- balanceNoFees
+ // The non-initiator doesn't pay on-chain fees, but we must still ensure the initiator (remote)
+ // can afford the commit tx fees and channel reserve when a new HTLC is added: otherwise sendAdd
+ // will fail with RemoteCannotAffordFeesForNewHtlc. We assume the worst case (an untrimmed HTLC,
+ // which adds htlcOutputFee to the commit fees).
+ val commitFees = commitTxTotalCostMsat(remoteCommitParams.dustLimit, reduced, commitmentFormat)
+ val nextHtlcFee = htlcOutputFee(reduced.commitTxFeerate, commitmentFormat)
+ if (reduced.toLocal - remoteChannelReserve(params) - commitFees - nextHtlcFee < 0.msat) {
+ 0 msat
+ } else {
+ balanceNoFees
+ }
}
}
@@ -359,21 +368,29 @@ case class Commitment(fundingTxIndex: Long,
val reduced = CommitmentSpec.reduce(localCommit.spec, changes.localChanges.acked, changes.remoteChanges.proposed)
val balanceNoFees = (reduced.toRemote - remoteChannelReserve(params)).max(0 msat)
if (localParams.paysCommitTxFees) {
- // The non-initiator doesn't pay on-chain fees so we don't take those into account when receiving.
- balanceNoFees
+ // The non-initiator (sender) doesn't pay on-chain fees, but we (the initiator) must still afford
+ // the commit tx fees and channel reserve once a new HTLC is added: otherwise receiveAdd will
+ // fail with CannotAffordFees. We assume the worst case (an untrimmed HTLC).
+ val commitFees = commitTxTotalCostMsat(localCommitParams.dustLimit, reduced, commitmentFormat)
+ val nextHtlcFee = htlcOutputFee(reduced.commitTxFeerate, commitmentFormat)
+ if (reduced.toLocal - localChannelReserve(params) - commitFees - nextHtlcFee < 0.msat) {
+ 0 msat
+ } else {
+ balanceNoFees
+ }
} else {
// The initiator always pays the on-chain fees, so we must subtract that from the amount we can receive.
val commitFees = commitTxTotalCostMsat(localCommitParams.dustLimit, reduced, commitmentFormat)
- // we expected the initiator to keep a "funder fee buffer" (see explanation above)
+ // We expected the initiator to keep a "funder fee buffer" (see explanation above).
val funderFeeBuffer = commitTxTotalCostMsat(localCommitParams.dustLimit, reduced.copy(commitTxFeerate = reduced.commitTxFeerate * 2), commitmentFormat) + htlcOutputFee(reduced.commitTxFeerate * 2, commitmentFormat)
val amountToReserve = commitFees.max(funderFeeBuffer)
if (balanceNoFees - amountToReserve < receivedHtlcTrimThreshold(localCommitParams.dustLimit, reduced, commitmentFormat)) {
- // htlc will be trimmed
+ // The htlc will be trimmed.
(balanceNoFees - amountToReserve).max(0 msat)
} else {
- // htlc will have an output in the commitment tx, so there will be additional fees.
+ // The htlc will have an output in the commitment tx, so there will be additional fees.
val commitFees1 = commitFees + htlcOutputFee(reduced.commitTxFeerate, commitmentFormat)
- // we take the additional fees for that htlc output into account in the fee buffer at a x2 feerate increase
+ // We take the additional fees for that htlc output into account in the fee buffer at a x2 feerate increase.
val funderFeeBuffer1 = funderFeeBuffer + htlcOutputFee(reduced.commitTxFeerate * 2, commitmentFormat)
val amountToReserve1 = commitFees1.max(funderFeeBuffer1)
(balanceNoFees - amountToReserve1).max(0 msat)
Why this scored 36/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.