Don't store anchor transaction in channel data (#3187)
What changed, and why it matters
This commit removes the storage of Bitcoin 'anchor' transactions inside Lightning channel state and stops re-watching those anchor outputs after the commitment transaction is confirmed. The stated goal is to save database space and avoid redundant work. The change also tweaks how the code decides whether a transaction is 'relevant' to a closing channel, explicitly excluding anchor outputs from being treated as spending the commitment transaction. There is no direct evidence in the commit that this fixes an active security bug, but it does reduce the surface area for state-mismatch and watch-related edge cases during channel closes.
Treat as a low-risk maintenance/cleanup change. Reviewers should verify that skipping anchor-output watches after confirmation does not miss edge cases where the anchor is double-spent or RBF-bumped by the remote party, and that excluding anchor outpoints from 'spendsTheCommitTx' does not affect detection of revoked-commitment breaches or HTLC-timeout flows.
Security signals we found
Reduced state persistence: anchor tx no longer stored in channel data, lowering DB bloat and potential stale-state risks
Conditional watch logic: anchor output is only watched while the commitment tx is unconfirmed
Relevance filter change: anchor outputs explicitly excluded from 'spends the commitment tx' classification
No explicit security framing by vendor; commit message describes it as a space/efficiency improvement
Evidence from the diff
The patch modifies Eclair’s channel closing logic in three files. In Helpers.scala, the conditions that classify a transaction as spending the local/remote/revoked commitment transaction now exclude outpoints that match the optional anchor output. In ErrorHandlers.scala, the anchor output is added to the watch-spent queue only when the commitment transaction is not yet confirmed. In ClosingStateSpec.scala, tests are updated to no longer expect watches on anchor outputs after restart. The commit message frames this as a cleanup: anchor transactions are not needed in channel data and re-watching them after confirmation is unnecessary.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scalaeclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scalaeclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scalaInspect captured patch +9 / −11
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 dfe7fb5..bad6c97 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
@@ -1645,8 +1645,8 @@ object Helpers {
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?
- val spendsTheCommitTx = localCommitPublished.commitTx.txid == outPoint.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)
@@ -1672,8 +1672,8 @@ object Helpers {
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?
- val spendsTheCommitTx = remoteCommitPublished.commitTx.txid == outPoint.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
})
// then we add the relevant outpoints to the map keeping track of which txid spends which outpoint
@@ -1696,8 +1696,8 @@ object Helpers {
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?
- val spendsTheCommitTx = revokedCommitPublished.commitTx.txid == outPoint.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)
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala
index a2ac4b4..7b0869e 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala
@@ -256,7 +256,7 @@ trait ErrorHandlers extends CommonHandlers {
// we will watch for its confirmation. This ensures that we detect double-spends that could come from:
// - our own RBF attempts
// - remote transactions for outputs that both parties may spend (e.g. HTLCs)
- val watchSpentQueue = lcp.localOutput_opt ++ lcp.anchorOutput_opt ++ lcp.htlcOutputs.toSeq
+ val watchSpentQueue = lcp.localOutput_opt ++ (if (!lcp.isConfirmed) lcp.anchorOutput_opt else None) ++ lcp.htlcOutputs.toSeq
watchSpentIfNeeded(lcp.commitTx, watchSpentQueue, lcp.irrevocablySpent)
}
@@ -337,7 +337,7 @@ trait ErrorHandlers extends CommonHandlers {
// we will watch for its confirmation. This ensures that we detect double-spends that could come from:
// - our own RBF attempts
// - remote transactions for outputs that both parties may spend (e.g. HTLCs)
- val watchSpentQueue = rcp.localOutput_opt ++ rcp.anchorOutput_opt ++ rcp.htlcOutputs.toSeq
+ val watchSpentQueue = rcp.localOutput_opt ++ (if (!rcp.isConfirmed) rcp.anchorOutput_opt else None) ++ rcp.htlcOutputs.toSeq
watchSpentIfNeeded(rcp.commitTx, watchSpentQueue, rcp.irrevocablySpent)
}
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala
index 66ff368..2be9104 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala
@@ -993,7 +993,6 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
awaitCond(alice.stateName == CLOSING)
// Alice republishes the HTLC-success transaction, which then confirms.
assert(alice2blockchain.expectReplaceableTxPublished[HtlcSuccessTx].input == htlcSuccess.input)
- closingTxs.anchorTx_opt.foreach(anchorTx => alice2blockchain.expectWatchOutputSpent(anchorTx.txIn.head.outPoint))
alice2blockchain.expectWatchOutputSpent(htlcSuccess.input.outPoint)
alice ! WatchOutputSpentTriggered(htlcSuccess.amountIn, htlcSuccess.tx)
alice2blockchain.expectWatchTxConfirmed(htlcSuccess.tx.txid)
@@ -1010,7 +1009,6 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
alice ! INPUT_RESTORED(beforeRestart2)
alice2blockchain.expectMsgType[SetChannelId]
awaitCond(alice.stateName == CLOSING)
- closingTxs.anchorTx_opt.foreach(anchorTx => alice2blockchain.expectWatchOutputSpent(anchorTx.txIn.head.outPoint))
// Alice republishes the 3rd-stage HTLC transaction, which then confirms.
alice2blockchain.expectFinalTxPublished(htlcDelayedTx.tx.txid)
alice2blockchain.expectWatchOutputSpent(htlcDelayedTx.input)
@@ -1252,7 +1250,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
// Bob re-publishes closing transactions: he has 1 HTLC-success and 1 HTLC-timeout transactions left.
val republishedHtlcTxsBob = (1 to 2).map(_ => bob2blockchain.expectMsgType[PublishReplaceableTx])
- bob2blockchain.expectWatchOutputsSpent(remainingHtlcOutputs ++ closingTxsBob.anchorTx_opt.map(_.txIn.head.outPoint).toSeq)
+ bob2blockchain.expectWatchOutputsSpent(remainingHtlcOutputs)
assert(republishedHtlcTxsBob.map(_.input).toSet == Set(htlcTimeoutTxBob2.txIn.head.outPoint, closingTxsBob.htlcSuccessTxs.head.txIn.head.outPoint))
bob2blockchain.expectNoMessage(100 millis)
Why this scored 23/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.