Only store txs spending our commit outputs (#3188)
What changed, and why it matters
This change tightens which Bitcoin transactions Eclair remembers after a Lightning channel closes. Previously, Eclair might have recorded remote 'anchor' transactions that spend the commitment transaction, even though those transactions don't belong to the node. Now it only records transactions that spend outputs the node actually cares about. The main risk is that storing the wrong transactions could have led to incorrect channel-state tracking, possibly affecting fee bumping or recovery logic, but the commit itself does not describe an active exploit or loss of funds.
Treat as a low-to-moderate hardening fix. Review whether previously stored remote anchor transactions in the irrevocablySpent map could have caused fee-bumping or channel-recovery issues in production, and consider whether a migration or cleanup of existing state is needed. No emergency action is indicated by the diff alone.
Security signals we found
Defensive hardening of transaction tracking logic
Removal of broad 'spends commit tx' filter in favor of explicit output-set membership
Prevention of storing remote/irrelevant anchor transactions in channel state
Potential prior state-corruption risk from tracking unintended transactions
Evidence from the diff
The patch modifies Helpers.scala to replace a broad ‘spends the commit tx, except anchor’ filter with explicit membership checks against localOutput_opt, htlcOutputs, htlcDelayedOutputs, and (for revoked commits) remoteOutput_opt. This prevents remote anchor transactions from being added to the irrevocablySpent map. The change is defensive: it reduces unintended side effects from tracking irrelevant transactions, but the diff alone does not demonstrate a concrete vulnerability or attack path.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scalaLocal commit published transaction trackingRemote commit published transaction trackingRevoked commit published transaction trackingInspect captured patch +12 / −18
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
index bad6c97..230b9be 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
@@ -1643,14 +1643,11 @@ object Helpers {
// even if our txs only have one input, maybe our counterparty uses a different scheme so we need to iterate
// over all of them to check if they are relevant
val relevantOutpoints = tx.txIn.map(_.outPoint).filter(outPoint => {
- // is this the commit tx itself? (we could do this outside of the loop...)
val isCommitTx = localCommitPublished.commitTx.txid == tx.txid
- // does the tx spend an output of the local commitment tx (other than the anchor output)?
- val spendsTheCommitTx = localCommitPublished.commitTx.txid == outPoint.txid && !localCommitPublished.anchorOutput_opt.contains(outPoint)
- // is the tx one of our 3rd stage delayed txs? (a 3rd stage tx is a tx spending the output of an htlc tx, which
- // is itself spending the output of the commitment tx)
- val is3rdStageDelayedTx = localCommitPublished.htlcDelayedOutputs.contains(outPoint)
- isCommitTx || spendsTheCommitTx || is3rdStageDelayedTx
+ val isMainTx = localCommitPublished.localOutput_opt.contains(outPoint)
+ val isHtlcTx = localCommitPublished.htlcOutputs.contains(outPoint)
+ val isHtlcDelayedTx = localCommitPublished.htlcDelayedOutputs.contains(outPoint)
+ isCommitTx || isMainTx || isHtlcTx || isHtlcDelayedTx
})
// then we add the relevant outpoints to the map keeping track of which txid spends which outpoint
localCommitPublished.copy(irrevocablySpent = localCommitPublished.irrevocablySpent ++ relevantOutpoints.map(o => o -> tx).toMap)
@@ -1670,11 +1667,10 @@ object Helpers {
// even if our txs only have one input, maybe our counterparty uses a different scheme so we need to iterate
// over all of them to check if they are relevant
val relevantOutpoints = tx.txIn.map(_.outPoint).filter(outPoint => {
- // is this the commit tx itself? (we could do this outside of the loop...)
val isCommitTx = remoteCommitPublished.commitTx.txid == tx.txid
- // does the tx spend an output of the remote commitment tx (other than the anchor output)?
- val spendsTheCommitTx = remoteCommitPublished.commitTx.txid == outPoint.txid && !remoteCommitPublished.anchorOutput_opt.contains(outPoint)
- isCommitTx || spendsTheCommitTx
+ val isMainTx = remoteCommitPublished.localOutput_opt.contains(outPoint)
+ val isHtlcTx = remoteCommitPublished.htlcOutputs.contains(outPoint)
+ isCommitTx || isMainTx || isHtlcTx
})
// then we add the relevant outpoints to the map keeping track of which txid spends which outpoint
remoteCommitPublished.copy(irrevocablySpent = remoteCommitPublished.irrevocablySpent ++ relevantOutpoints.map(o => o -> tx).toMap)
@@ -1694,14 +1690,12 @@ object Helpers {
// even if our txs only have one input, maybe our counterparty uses a different scheme so we need to iterate
// over all of them to check if they are relevant
val relevantOutpoints = tx.txIn.map(_.outPoint).filter(outPoint => {
- // is this the commit tx itself? (we could do this outside of the loop...)
val isCommitTx = revokedCommitPublished.commitTx.txid == tx.txid
- // does the tx spend an output of the remote commitment tx (other than the anchor output)?
- val spendsTheCommitTx = revokedCommitPublished.commitTx.txid == outPoint.txid && !revokedCommitPublished.anchorOutput_opt.contains(outPoint)
- // is the tx one of our 3rd stage delayed txs? (a 3rd stage tx is a tx spending the output of an htlc tx, which
- // is itself spending the output of the commitment tx)
- val is3rdStageDelayedTx = revokedCommitPublished.htlcDelayedOutputs.contains(outPoint)
- isCommitTx || spendsTheCommitTx || is3rdStageDelayedTx
+ val isMainTx = revokedCommitPublished.localOutput_opt.contains(outPoint)
+ val isMainPenaltyTx = revokedCommitPublished.remoteOutput_opt.contains(outPoint)
+ val isHtlcPenaltyTx = revokedCommitPublished.htlcOutputs.contains(outPoint)
+ val isHtlcDelayedPenaltyTx = revokedCommitPublished.htlcDelayedOutputs.contains(outPoint)
+ isCommitTx || isMainTx || isMainPenaltyTx || isHtlcPenaltyTx || isHtlcDelayedPenaltyTx
})
// then we add the relevant outpoints to the map keeping track of which txid spends which outpoint
revokedCommitPublished.copy(irrevocablySpent = revokedCommitPublished.irrevocablySpent ++ relevantOutpoints.map(o => o -> tx).toMap)
Why this scored 45/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.