What changed, and why it matters
This commit fixes a resource cleanup issue in the Eclair Lightning node. After a channel is upgraded via a 'splice' transaction, the old funding transaction output is permanently spent. Previously, Eclair kept watching that old output indefinitely if the channel stayed open and the peer stayed disconnected, wasting memory and processing power in the blockchain watcher. The change explicitly tells the watcher to stop watching those old, already-spent outputs. It also adds logging so operators can spot similar leftover watches. There is no direct evidence in the commit that this was exploitable to steal funds or attack other nodes; it appears to be a performance and robustness improvement.
Treat as a routine hardening/performance fix. Operators running nodes with frequent splices should upgrade to avoid gradual watcher bloat. No emergency response is indicated by the diff. Review whether other watch types (e.g., WatchOutputSpent, WatchPublished) have similar stale-watch edge cases.
Security signals we found
Resource exhaustion / gradual memory growth from stale watcher state
Defensive cleanup of spent funding outputs after splice confirmation
New observability (per-block watch counts) to detect future cleanup gaps
No direct diff evidence of fund loss, remote exploit, or consensus bug
Evidence from the diff
The patch introduces an UnwatchFundingSpent command in ZmqWatcher and sends it from CommonFundingHandlers whenever a splice funding transaction confirms. The handler removes matching WatchFundingSpent entries and their associated watched-utxo state. A new log line counts watches by category on each new block. Tests are updated to assert that the unwatch message is sent and that no further trigger is received after unwatching. The change is defensive: it prevents the Watcher actor from accumulating stale watches for outputs that have already been irrevocably spent by a confirmed splice.
Changed components
ZmqWatcher actor (eclair-core blockchain watcher)CommonFundingHandlers (channel funding/splice state machine)Lightning channel splice confirmation pathRelated unit tests in ZmqWatcherSpec and NormalSplicesStateSpecInspect captured patch +101 / −9
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala
index 5643df7..3d9d766 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala
@@ -132,6 +132,7 @@ object ZmqWatcher {
case class WatchFundingSpent(replyTo: ActorRef[WatchFundingSpentTriggered], txId: TxId, outputIndex: Int, hints: Set[TxId]) extends WatchSpent[WatchFundingSpentTriggered]
case class WatchFundingSpentTriggered(spendingTx: Transaction) extends WatchSpentTriggered
+ case class UnwatchFundingSpent(txId: TxId, outputIndex: Int) extends Command
case class WatchOutputSpent(replyTo: ActorRef[WatchOutputSpentTriggered], txId: TxId, outputIndex: Int, amount: Satoshi, hints: Set[TxId]) extends WatchSpent[WatchOutputSpentTriggered]
case class WatchOutputSpentTriggered(amount: Satoshi, spendingTx: Transaction) extends WatchSpentTriggered
@@ -321,6 +322,12 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
blockHeight.set(currentHeight.toLong)
context.system.eventStream ! EventStream.Publish(CurrentBlockHeight(currentHeight))
// TODO: should we try to mitigate the herd effect and not check all watches immediately?
+ val watchExternalChannelCount = watches.keySet.count(_.isInstanceOf[WatchExternalChannelSpent])
+ val watchFundingSpentCount = watches.keySet.count(_.isInstanceOf[WatchFundingSpent])
+ val watchOutputSpentCount = watches.keySet.count(_.isInstanceOf[WatchOutputSpent])
+ val watchPublishedCount = watches.keySet.count(_.isInstanceOf[WatchPublished])
+ val watchConfirmedCount = watches.keySet.count(_.isInstanceOf[WatchConfirmed[_]])
+ log.info("{} watched utxos: external-channels={}, funding-spent={}, output-spent={}, tx-published={}, tx-confirmed={}", watchedUtxos.size, watchExternalChannelCount, watchFundingSpentCount, watchOutputSpentCount, watchPublishedCount, watchConfirmedCount)
KamonExt.timeFuture(Metrics.NewBlockCheckConfirmedDuration.withoutTags()) {
Future.sequence(watches.collect {
case (w: WatchPublished, _) => checkPublished(w)
@@ -401,6 +408,11 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
val watchedUtxos1 = deprecatedWatches.foldLeft(watchedUtxos) { case (m, w) => removeWatchedUtxos(m, w) }
watching(watches -- deprecatedWatches, watchedUtxos1, analyzedBlocks)
+ case UnwatchFundingSpent(txId, outputIndex) =>
+ val deprecatedWatches = watches.keySet.collect { case w: WatchFundingSpent if w.txId == txId && w.outputIndex == outputIndex => w }
+ val watchedUtxos1 = deprecatedWatches.foldLeft(watchedUtxos) { case (m, w) => removeWatchedUtxos(m, w) }
+ watching(watches -- deprecatedWatches, watchedUtxos1, analyzedBlocks)
+
case ValidateRequest(replyTo, ann) =>
client.validate(ann).map(replyTo ! _)
Behaviors.same
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala
index 0ce656c..2490e86 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala
@@ -102,6 +102,8 @@ trait CommonFundingHandlers extends CommonHandlers {
// Children splice transactions may already spend that confirmed funding transaction.
val spliceSpendingTxs = commitments1.all.collect { case c if c.fundingTxIndex == commitment.fundingTxIndex + 1 => c.fundingTxId }
watchFundingSpent(commitment, additionalKnownSpendingTxs = spliceSpendingTxs.toSet, None)
+ // We can unwatch the previous funding transaction(s), which have been spent by this splice transaction.
+ d.commitments.all.collect { case c if c.fundingTxIndex < commitment.fundingTxIndex => blockchain ! UnwatchFundingSpent(c.fundingTxId, c.fundingInput.index.toInt) }
// In the dual-funding/splicing case we can forget all other transactions (RBF attempts), they have been
// double-spent by the tx that just confirmed.
val conflictingTxs = d.commitments.active // note how we use the unpruned original commitments
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala
index b5833de..54ce8d6 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala
@@ -370,6 +370,32 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
})
}
+ test("unwatch funding spent") {
+ withWatcher(f => {
+ import f._
+
+ val (priv, address) = createExternalAddress()
+ val tx = sendToAddress(address, Btc(1), probe)
+ val outputIndex = tx.txOut.indexWhere(_.publicKeyScript == Script.write(Script.pay2wpkh(priv.publicKey)))
+ val (tx1, _) = createUnspentTxChain(tx, priv)
+
+ watcher ! WatchFundingSpent(probe.ref, tx.txid, outputIndex, Set.empty)
+ probe.expectNoMessage(100 millis)
+
+ bitcoinClient.publishTransaction(tx1)
+ probe.expectMsg(WatchFundingSpentTriggered(tx1))
+ probe.expectNoMessage(100 millis)
+
+ watcher ! UnwatchFundingSpent(tx.txid, outputIndex)
+ probe.expectNoMessage(100 millis)
+ // Let's confirm tx and tx1: seeing tx1 in a block should trigger both WatchSpentTriggered events again, but we unwatched the transaction.
+ bitcoinClient.getBlockHeight().pipeTo(probe.ref)
+ probe.expectMsgType[BlockHeight]
+ generateBlocks(1)
+ probe.expectNoMessage(100 millis)
+ })
+ }
+
test("watch for unknown spent transactions") {
withWatcher(f => {
import f._
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala
index 19f9ff9..0ed9ef6 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala
@@ -838,6 +838,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
test("recv CMD_BUMP_FUNDING_FEE (splice-in + splice-out)") { f =>
import f._
+ val fundingInput = alice.commitments.latest.fundingInput
val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(300_000 sat, defaultSpliceOutScriptPubKey)))
val spliceCommitment = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.find(_.fundingTxId == spliceTx.txid).get
assert(alice2blockchain.expectMsgType[WatchFundingConfirmed].txId == spliceTx.txid)
@@ -875,6 +876,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
assert(data.commitments.active.map(_.fundingTxId) == Seq(rbfTx2.txid))
assert(alice2blockchain.expectMsgType[WatchFundingSpent].txId == rbfTx2.txid)
alice2blockchain.expectMsgAllOf(
+ UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt),
UnwatchTxConfirmed(spliceTx.txid),
UnwatchTxConfirmed(rbfTx1.txid),
)
@@ -1234,6 +1236,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
test("splice local/remote locking", Tag(ChannelStateTestsTags.NoMaxHtlcValueInFlight)) { f =>
import f._
+ val fundingInput = alice.commitments.latest.fundingInput
val fundingTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)))
checkWatchConfirmed(f, fundingTx1)
val commitAlice1 = alice.signCommitTx()
@@ -1242,10 +1245,13 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
// Bob sees the first splice confirm, but Alice doesn't.
bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
bob2blockchain.expectWatchFundingSpent(fundingTx1.txid, Some(Set(commitAlice1.txid, commitBob1.txid)))
+ bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx1.txid)
bob2alice.forward(alice)
// Alice creates another splice spending the first splice.
+ val fundingInput1 = alice.commitments.latest.fundingInput
+ assert(fundingInput1.txid == fundingTx1.txid)
val fundingTx2 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)))
checkWatchConfirmed(f, fundingTx2)
val commitAlice2 = alice.signCommitTx()
@@ -1257,8 +1263,8 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
alice2bob.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx1.txid)
alice2bob.forward(bob)
-
alice2blockchain.expectWatchFundingSpent(fundingTx1.txid, Some(Set(fundingTx2.txid, commitAlice1.txid, commitBob1.txid)))
+ alice2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.map(_.fundingTxIndex) == Seq(2, 1))
assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.map(_.fundingTxIndex) == Seq.empty)
@@ -1270,7 +1276,9 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx2.txid)
bob2alice.forward(alice)
alice2blockchain.expectWatchFundingSpent(fundingTx2.txid, Some(Set(commitAlice2.txid, commitBob2.txid)))
+ alice2blockchain.expectMsg(UnwatchFundingSpent(fundingInput1.txid, fundingInput1.index.toInt))
bob2blockchain.expectWatchFundingSpent(fundingTx2.txid, Some(Set(commitAlice2.txid, commitBob2.txid)))
+ bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput1.txid, fundingInput1.index.toInt))
assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.map(_.fundingTxIndex) == Seq(2))
assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.map(_.fundingTxIndex) == Seq.empty)
}
@@ -1320,16 +1328,20 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
test("splice local/remote locking (intermingled)", Tag(ChannelStateTestsTags.NoMaxHtlcValueInFlight)) { f =>
import f._
+ val fundingInput = alice.commitments.latest.fundingInput
val fundingTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)))
checkWatchConfirmed(f, fundingTx1)
// Bob sees the first splice confirm, but Alice doesn't.
bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx1.txid)
+ bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx1.txid)
bob2alice.forward(alice)
// Alice creates another splice spending the first splice.
+ val fundingInput1 = alice.commitments.latest.fundingInput
+ assert(fundingInput1.txid == fundingTx1.txid)
val fundingTx2 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)))
checkWatchConfirmed(f, fundingTx2)
val commitAlice2 = alice.signCommitTx()
@@ -1340,6 +1352,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
alice2bob.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx2.txid)
alice2bob.forward(bob)
alice2blockchain.expectWatchFundingSpent(fundingTx2.txid, Some(Set(commitAlice2.txid, commitBob2.txid)))
+ alice2blockchain.expectMsg(UnwatchFundingSpent(fundingInput1.txid, fundingInput1.index.toInt))
bob2alice.expectNoMessage(100 millis)
assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.map(_.fundingTxIndex) == Seq(2, 1))
@@ -1348,6 +1361,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx2.txid)
bob2alice.forward(alice)
bob2blockchain.expectWatchFundingSpent(fundingTx2.txid, Some(Set(commitAlice2.txid, commitBob2.txid)))
+ bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput1.txid, fundingInput1.index.toInt))
awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.map(_.fundingTxIndex) == Seq(2))
awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.map(_.fundingTxIndex) == Seq(2))
assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.map(_.fundingTxIndex) == Seq.empty)
@@ -1385,11 +1399,13 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
systemB.eventStream.subscribe(bobEvents.ref, classOf[LocalChannelUpdate])
systemB.eventStream.subscribe(bobEvents.ref, classOf[LocalChannelDown])
+ val fundingInput = alice.commitments.latest.fundingInput
val fundingTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)))
checkWatchConfirmed(f, fundingTx1)
bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx1.txid)
+ bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx1.txid)
bob2alice.forward(alice)
bobEvents.expectMsg(ForgetHtlcInfos(initialState.channelId, initialState.commitments.localCommitIndex))
@@ -1465,18 +1481,21 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
assert(bobListener.expectMsgType[ShortChannelIdAssigned].announcement_opt.contains(ann))
// Alice and Bob create a first splice transaction.
+ val fundingInput = alice.commitments.latest.fundingInput
val spliceTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)))
alice2blockchain.expectWatchFundingConfirmed(spliceTx1.txid)
bob2blockchain.expectWatchFundingConfirmed(spliceTx1.txid)
// Alice sees the splice transaction confirm.
alice ! WatchFundingConfirmedTriggered(BlockHeight(1105), 37, spliceTx1)
alice2blockchain.expectWatchFundingSpent(spliceTx1.txid)
+ alice2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
assert(alice2bob.expectMsgType[SpliceLocked].fundingTxId == spliceTx1.txid)
alice2bob.forward(bob)
bob2alice.expectNoMessage(100 millis)
// Bob sees the splice transaction confirm and receives Alice's announcement_signatures.
bob ! WatchFundingConfirmedTriggered(BlockHeight(1105), 37, spliceTx1)
bob2blockchain.expectWatchFundingSpent(spliceTx1.txid)
+ bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
assert(bob2alice.expectMsgType[SpliceLocked].fundingTxId == spliceTx1.txid)
bob2alice.forward(alice)
alice2bob.expectMsgType[AnnouncementSignatures]
@@ -1495,17 +1514,21 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
awaitAssert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.isEmpty)
// Alice and Bob create a second splice transaction.
+ val fundingInput1 = alice.commitments.latest.fundingInput
+ assert(fundingInput1.txid == spliceTx1.txid)
val spliceTx2 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(100_000 sat)))
alice2blockchain.expectWatchFundingConfirmed(spliceTx2.txid)
bob2blockchain.expectWatchFundingConfirmed(spliceTx2.txid)
// Alice sees the splice transaction confirm.
alice ! WatchFundingConfirmedTriggered(BlockHeight(1729), 27, spliceTx2)
alice2blockchain.expectWatchFundingSpent(spliceTx2.txid)
+ alice2blockchain.expectMsg(UnwatchFundingSpent(fundingInput1.txid, fundingInput1.index.toInt))
assert(alice2bob.expectMsgType[SpliceLocked].fundingTxId == spliceTx2.txid)
alice2bob.forward(bob)
// Bob sees the splice transaction confirm.
bob ! WatchFundingConfirmedTriggered(BlockHeight(1729), 27, spliceTx2)
bob2blockchain.expectWatchFundingSpent(spliceTx2.txid)
+ bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput1.txid, fundingInput1.index.toInt))
assert(bob2alice.expectMsgType[SpliceLocked].fundingTxId == spliceTx2.txid)
bob2alice.forward(alice)
alice2bob.expectMsgType[AnnouncementSignatures]
@@ -1554,17 +1577,20 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
assert(bob.stateData.asInstanceOf[DATA_NORMAL].lastAnnouncement_opt.isEmpty)
// Alice and Bob create a splice transaction.
+ val fundingInput = alice.commitments.latest.fundingInput
val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(250_000 sat)))
alice2blockchain.expectWatchFundingConfirmed(spliceTx.txid)
bob2blockchain.expectWatchFundingConfirmed(spliceTx.txid)
// Alice sees the splice transaction confirm.
alice ! WatchFundingConfirmedTriggered(BlockHeight(1105), 37, spliceTx)
alice2blockchain.expectWatchFundingSpent(spliceTx.txid)
+ alice2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
assert(alice2bob.expectMsgType[SpliceLocked].fundingTxId == spliceTx.txid)
alice2bob.forward(bob)
// Bob sees the splice transaction confirm and receives Alice's announcement_signatures.
bob ! WatchFundingConfirmedTriggered(BlockHeight(1105), 37, spliceTx)
bob2blockchain.expectWatchFundingSpent(spliceTx.txid)
+ bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
assert(bob2alice.expectMsgType[SpliceLocked].fundingTxId == spliceTx.txid)
bob2alice.forward(alice)
alice2bob.expectMsgType[AnnouncementSignatures]
@@ -2682,12 +2708,14 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
def resendSpliceLockedOnReconnection(f: FixtureParam): Unit = {
import f._
+ val fundingInput = alice.commitments.latest.fundingInput
val fundingTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat)))
checkWatchConfirmed(f, fundingTx1)
// The first splice confirms on Bob's side.
bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx1.txid)
+ bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx1.txid)
bob2alice.forward(alice)
@@ -2713,7 +2741,8 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
assert(alice2bob.expectMsgType[SpliceLocked].fundingTxId == fundingTx1.txid)
alice2bob.forward(bob)
- alice2blockchain.expectMsgType[WatchFundingSpent]
+ alice2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx1.txid)
+ alice2blockchain.expectMsgTypeHaving[UnwatchFundingSpent](_.txId == fundingInput.txid)
disconnect(f)
reconnect(f)
@@ -2726,7 +2755,8 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx2)
assert(alice2bob.expectMsgType[SpliceLocked].fundingTxId == fundingTx2.txid)
alice2bob.forward(bob)
- alice2blockchain.expectMsgType[WatchFundingSpent]
+ alice2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx2.txid)
+ alice2blockchain.expectMsgTypeHaving[UnwatchFundingSpent](_.txId == fundingTx1.txid)
disconnect(f)
reconnect(f)
@@ -2736,8 +2766,9 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
// The second splice confirms on Bob's side.
bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx2)
- assert(bob2alice.expectMsgType[SpliceLocked].fundingTxId == fundingTx2.txid)
- bob2blockchain.expectMsgType[WatchFundingSpent]
+ bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx2.txid)
+ bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx2.txid)
+ bob2blockchain.expectMsgTypeHaving[UnwatchFundingSpent](_.txId == fundingTx1.txid)
// NB: we disconnect *before* transmitting the splice_locked to Alice.
disconnect(f)
@@ -3343,12 +3374,14 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
val htlcs = setupHtlcs(f)
+ val fundingInput = alice.commitments.latest.fundingInput
val fundingTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)))
checkWatchConfirmed(f, fundingTx1)
// The first splice confirms on Bob's side.
bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx1.txid)
+ bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx1.txid)
bob2alice.forward(alice)
@@ -3379,11 +3412,13 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
// The first splice transaction confirms.
alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
- alice2blockchain.expectMsgType[WatchFundingSpent]
+ alice2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx1.txid)
+ alice2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
// The second splice transaction confirms.
alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx2)
- alice2blockchain.expectMsgType[WatchFundingSpent]
+ alice2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx2.txid)
+ alice2blockchain.expectMsgTypeHaving[UnwatchFundingSpent](_.txId == fundingTx1.txid)
// Alice detects that the commit confirms, along with 2nd-stage and 3rd-stage transactions.
alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, commitTx2)
@@ -3432,12 +3467,14 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
val htlcs = setupHtlcs(f)
+ val fundingInput = alice.commitments.latest.fundingInput
val fundingTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey)))
checkWatchPublished(f, fundingTx1)
// The first splice confirms on Bob's side.
bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx1.txid)
+ bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx1.txid)
bob2alice.forward(alice)
@@ -3461,6 +3498,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
// The first splice transaction confirms.
alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
alice2blockchain.expectWatchFundingSpent(fundingTx1.txid)
+ alice2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
// Bob publishes his commit tx for the first splice transaction (which double-spends the second splice transaction).
val bobCommitments = bob.stateData.asInstanceOf[ChannelDataWithCommitments].commitments
@@ -3512,6 +3550,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
val htlcs = setupHtlcs(f)
// pay 10_000_000 msat to bob that will be paid back to alice after the splices
+ val fundingInput = alice.commitments.latest.fundingInput
val fundingTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 10_000_000 msat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey)))
checkWatchConfirmed(f, fundingTx1)
// remember bob's commitment for later
@@ -3520,6 +3559,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
// The first splice confirms on Bob's side.
bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx1.txid)
+ bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx1.txid)
bob2alice.forward(alice)
@@ -3543,6 +3583,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
// The first splice transaction confirms.
alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
alice2blockchain.expectWatchFundingSpent(fundingTx1.txid)
+ alice2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
// Bob publishes a revoked commitment for fundingTx1!
alice ! WatchFundingSpentTriggered(bobRevokedCommitTx)
// Alice watches bob's revoked commit tx, and force-closes with her latest commitment.
@@ -3590,6 +3631,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
val htlcs = setupHtlcs(f)
// pay 10_000_000 msat to bob that will be paid back to alice after the splices
+ val fundingInput = alice.commitments.latest.fundingInput
initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 10_000_000 msat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey)))
val fundingTx1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get
alice2blockchain.expectWatchPublished(fundingTx1.txid)
@@ -3641,6 +3683,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
// alice puts a watch-spent and prunes the initial funding
alice2blockchain.expectWatchFundingSpent(fundingTx1.txid)
+ alice2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1)
awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.size == 1)
// bob publishes his latest (inactive) commitment for fundingTx1
@@ -3687,6 +3730,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
val htlcs = setupHtlcs(f)
+ val fundingInput = alice.commitments.latest.fundingInput
initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey)))
val fundingTx1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get
alice2blockchain.expectWatchPublished(fundingTx1.txid)
@@ -3740,6 +3784,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
// alice puts a watch-spent and prunes the initial funding
alice2blockchain.expectWatchFundingSpent(fundingTx1.txid)
+ alice2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1)
awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.size == 1)
// bob publishes his latest commitment for fundingTx1, which is now revoked
@@ -3790,12 +3835,14 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
val htlcs = setupHtlcs(f)
// Our first splice upgrades the channel to taproot.
+ val fundingInput = alice.commitments.latest.fundingInput
val fundingTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix))
checkWatchConfirmed(f, fundingTx1)
// The first splice confirms on Bob's side.
bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx1.txid)
+ bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx1.txid)
bob2alice.forward(alice)
@@ -3831,11 +3878,13 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
// The first splice transaction confirms.
alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
- alice2blockchain.expectMsgType[WatchFundingSpent]
+ alice2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx1.txid)
+ alice2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
// The second splice transaction confirms.
alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx2)
- alice2blockchain.expectMsgType[WatchFundingSpent]
+ alice2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx2.txid)
+ alice2blockchain.expectMsgTypeHaving[UnwatchFundingSpent](_.txId == fundingTx1.txid)
// Alice detects that the commit confirms, along with 2nd-stage and 3rd-stage transactions.
alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, commitTx2)
@@ -4091,6 +4140,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
// The first splice confirms on Bob's side.
bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1)
bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx1.txid)
+ bob2blockchain.expectMsgTypeHaving[UnwatchFundingSpent](_.txId == fundingTxId0)
bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx1.txid)
bob2alice.forward(alice)
@@ -4280,6 +4330,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
// +-----------+ +-----------+ +-----------+
// | fundingTx |---->| spliceTx1 |---->| spliceTx2 |
// +-----------+ +-----------+ +-----------+
+ val fundingTxId = alice.commitments.latest.fundingTxId
val spliceTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(200_000 sat)))
checkWatchConfirmed(f, spliceTx1)
val spliceCommitment1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest
@@ -4288,6 +4339,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
// The first splice confirms on Bob's side (necessary to allow the second splice transaction).
bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, spliceTx1)
bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == spliceTx1.txid)
+ bob2blockchain.expectMsgTypeHaving[UnwatchFundingSpent](_.txId == fundingTxId)
bob2alice.expectMsgType[SpliceLocked]
bob2alice.forward(alice)
Why this scored 25/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.