Deduplicate closing balance during mutual close (#3182)
What changed, and why it matters
This commit fixes an accounting display bug in Eclair's balance reporting. When a mutual close transaction has been published or recently confirmed, the same funds could be counted both as 'closing' balance and as regular off-chain balance for a short time. The patch makes the off-chain balance exclude the channel in that situation, so the total shown to the user is not temporarily doubled. It does not move, lose, or expose any funds; it only changes how balances are reported.
No security action required. Treat as a normal bug fix / accounting improvement. Reviewers may verify the new test passes and that the condition correctly identifies mutual-close-published + recently-spent funding inputs.
Security signals we found
No cryptographic, network, or consensus security change
Accounting/reporting correctness fix only
No privilege boundary crossed
No input validation or serialization change
No funds at risk identified in diff or commit message
Evidence from the diff
In CheckBalance.scala, the off-chain balance computation now returns the existing accumulator unchanged when a channel is in DATA_CLOSING with a published mutual close transaction and the funding input has been recently spent (mempool or recent confirmation). Previously, without this guard, the channel balance was added to the ‘closing’ bucket while the same UTXO could also still be reflected elsewhere, producing a transient double-count. A unit test verifies that the closing balance is zero in this scenario for non-simple-close channels.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/balance/CheckBalance.scalaeclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scalaInspect captured patch +14 / −0
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/balance/CheckBalance.scala b/eclair-core/src/main/scala/fr/acinq/eclair/balance/CheckBalance.scala
index 24ea89d..b67b14e 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/balance/CheckBalance.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/balance/CheckBalance.scala
@@ -200,6 +200,10 @@ object CheckBalance {
this.copy(closing = this.closing.copy(toLocal = this.closing.toLocal + localBalance))
case None => this
}
+ // If we have a fully signed mutual close transaction and a closing transaction is in our mempool or recently
+ // confirmed, the channel will most likely end up being mutual-closed (since the feerate is higher than any
+ // force-close transaction). We thus ignore this channel in our off-chain balance to avoid counting it twice.
+ case None if d.mutualClosePublished.nonEmpty && recentlySpentInputs.contains(d.commitments.latest.fundingInput) => this
// We don't know yet which type of closing will confirm on-chain, so we use our default off-chain balance.
case None => this.copy(closing = this.closing.addChannelBalance(d.commitments))
}
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala
index e232f42..d208ad2 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala
@@ -98,6 +98,16 @@ class CheckBalanceSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
assert(CheckBalance.computeOffChainBalance(Seq(alice.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE]), recentlySpentInputs = Set(closingTxInput)).negotiating == expected)
}
+ test("channel closing with published closing tx (without option_simple_close)") { f =>
+ import f._
+
+ mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain)
+ assert(alice.stateData.asInstanceOf[DATA_CLOSING].mutualClosePublished.nonEmpty)
+ val closingTxInput = alice.stateData.asInstanceOf[DATA_CLOSING].commitments.latest.fundingInput
+ val expected = MainAndHtlcBalance(toLocal = 0 sat, htlcs = 0 sat)
+ assert(CheckBalance.computeOffChainBalance(Seq(alice.stateData.asInstanceOf[DATA_CLOSING]), recentlySpentInputs = Set(closingTxInput)).closing == expected)
+ }
+
test("channel closed with remote commit tx", Tag(ChannelStateTestsTags.StaticRemoteKey), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f =>
import f._
Why this scored 15/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.