Skip to content

Commit 929f85d

Browse files
committed
Update scid when splice funding tx confirms
A zero conf splice will send splice_locked when funding tx is published, but not update it's scid until the fund tx confirms. Channel balances and max htlc amount will not update until local sends to and receives from it's remote peer splice_locked.
1 parent d1109fa commit 929f85d

File tree

7 files changed

+91
-119
lines changed

7 files changed

+91
-119
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala

Lines changed: 5 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -104,20 +104,6 @@ object ZmqWatcher {
104104
def hints: Set[TxId]
105105
}
106106

107-
/**
108-
* Watch for the first transaction spending the given outpoint. We assume that txid is already confirmed or in the
109-
* mempool (i.e. the outpoint exists).
110-
*
111-
* NB: an event will be triggered only once when we see a transaction that spends the given outpoint. If you want to
112-
* react to the transaction spending the outpoint, you should use [[WatchSpent]] instead.
113-
*/
114-
sealed trait WatchSpentBasic[T <: WatchSpentBasicTriggered] extends Watch[T] {
115-
/** TxId of the outpoint to watch. */
116-
def txId: TxId
117-
/** Index of the outpoint to watch. */
118-
def outputIndex: Int
119-
}
120-
121107
/** This event is sent when a [[WatchConfirmed]] condition is met. */
122108
sealed trait WatchConfirmedTriggered extends WatchTriggered {
123109
/** Block in which the transaction was confirmed. */
@@ -134,11 +120,10 @@ object ZmqWatcher {
134120
def spendingTx: Transaction
135121
}
136122

137-
/** This event is sent when a [[WatchSpentBasic]] condition is met. */
138-
sealed trait WatchSpentBasicTriggered extends WatchTriggered
139-
140-
case class WatchExternalChannelSpent(replyTo: ActorRef[WatchExternalChannelSpentTriggered], txId: TxId, outputIndex: Int, shortChannelId: RealShortChannelId) extends WatchSpentBasic[WatchExternalChannelSpentTriggered]
141-
case class WatchExternalChannelSpentTriggered(shortChannelId: RealShortChannelId) extends WatchSpentBasicTriggered
123+
case class WatchExternalChannelSpent(replyTo: ActorRef[WatchExternalChannelSpentTriggered], txId: TxId, outputIndex: Int, shortChannelId: RealShortChannelId) extends WatchSpent[WatchExternalChannelSpentTriggered] {
124+
override def hints: Set[TxId] = Set.empty
125+
}
126+
case class WatchExternalChannelSpentTriggered(shortChannelId: RealShortChannelId, spendingTx: Transaction) extends WatchSpentTriggered
142127

143128
case class WatchFundingSpent(replyTo: ActorRef[WatchFundingSpentTriggered], txId: TxId, outputIndex: Int, hints: Set[TxId]) extends WatchSpent[WatchFundingSpentTriggered]
144129
case class WatchFundingSpentTriggered(spendingTx: Transaction) extends WatchSpentTriggered
@@ -194,7 +179,6 @@ object ZmqWatcher {
194179
private def utxo(w: GenericWatch): Option[OutPoint] = {
195180
w match {
196181
case w: WatchSpent[_] => Some(OutPoint(w.txId, w.outputIndex))
197-
case w: WatchSpentBasic[_] => Some(OutPoint(w.txId, w.outputIndex))
198182
case _ => None
199183
}
200184
}
@@ -242,7 +226,7 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
242226
.flatMap(watchedUtxos.get)
243227
.flatten
244228
.foreach {
245-
case w: WatchExternalChannelSpent => context.self ! TriggerEvent(w.replyTo, w, WatchExternalChannelSpentTriggered(w.shortChannelId))
229+
case w: WatchExternalChannelSpent => context.self ! TriggerEvent(w.replyTo, w, WatchExternalChannelSpentTriggered(w.shortChannelId, tx))
246230
case w: WatchFundingSpent => context.self ! TriggerEvent(w.replyTo, w, WatchFundingSpentTriggered(tx))
247231
case w: WatchOutputSpent => context.self ! TriggerEvent(w.replyTo, w, WatchOutputSpentTriggered(tx))
248232
case _: WatchPublished => // nothing to do
@@ -336,9 +320,6 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
336320
val result = w match {
337321
case _ if watches.contains(w) =>
338322
Ignore // we ignore duplicates
339-
case w: WatchSpentBasic[_] =>
340-
checkSpentBasic(w)
341-
Keep
342323
case w: WatchSpent[_] =>
343324
checkSpent(w)
344325
Keep
@@ -379,17 +360,6 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
379360
}
380361
}
381362

382-
private def checkSpentBasic(w: WatchSpentBasic[_ <: WatchSpentBasicTriggered]): Future[Unit] = {
383-
// NB: we assume parent tx was published, we just need to make sure this particular output has not been spent
384-
client.isTransactionOutputSpendable(w.txId, w.outputIndex, includeMempool = true).collect {
385-
case false =>
386-
log.info(s"output=${w.txId}:${w.outputIndex} has already been spent")
387-
w match {
388-
case w: WatchExternalChannelSpent => context.self ! TriggerEvent(w.replyTo, w, WatchExternalChannelSpentTriggered(w.shortChannelId))
389-
}
390-
}
391-
}
392-
393363
private def checkSpent(w: WatchSpent[_ <: WatchSpentTriggered]): Future[Unit] = {
394364
// first let's see if the parent tx was published or not
395365
client.getTxConfirmations(w.txId).collect {

eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import fr.acinq.eclair.Logs.LogCategory
2727
import fr.acinq.eclair._
2828
import fr.acinq.eclair.blockchain.CurrentBlockHeight
2929
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher
30-
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{ValidateResult, WatchExternalChannelSpent, WatchExternalChannelSpentTriggered}
30+
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{ValidateResult, WatchExternalChannelSpent, WatchExternalChannelSpentTriggered, WatchTxConfirmed, WatchTxConfirmedTriggered}
3131
import fr.acinq.eclair.channel._
3232
import fr.acinq.eclair.crypto.TransportHandler
3333
import fr.acinq.eclair.db.NetworkDb
@@ -76,10 +76,7 @@ class Router(val nodeParams: NodeParams, watcher: typed.ActorRef[ZmqWatcher.Comm
7676
{
7777
log.info("loading network announcements from db...")
7878
val (pruned, channels) = db.listChannels().partition { case (_, pc) => pc.isStale(nodeParams.currentBlockHeight) }
79-
val nodeIds = (pruned.values ++ channels.values).flatMap(pc => pc.ann.nodeId1 :: pc.ann.nodeId2 :: Nil).toSet
80-
val (isolatedNodes, nodes) = db.listNodes().partition(n => !nodeIds.contains(n.nodeId))
81-
log.info("removed {} isolated nodes from db", isolatedNodes.size)
82-
isolatedNodes.foreach(n => db.removeNode(n.nodeId))
79+
val nodes = db.listNodes()
8380
Metrics.Nodes.withoutTags().update(nodes.size)
8481
Metrics.Channels.withoutTags().update(channels.size)
8582
log.info("loaded from db: channels={} nodes={}", channels.size, nodes.size)
@@ -265,22 +262,17 @@ class Router(val nodeParams: NodeParams, watcher: typed.ActorRef[ZmqWatcher.Comm
265262
case Event(r: ValidateResult, d) =>
266263
stay() using Validation.handleChannelValidationResponse(d, nodeParams, watcher, r)
267264

268-
case Event(WatchExternalChannelSpentTriggered(shortChannelId), d) if d.channels.contains(shortChannelId) || d.prunedChannels.contains(shortChannelId) =>
269-
log.info("funding tx of channelId={} has been spent - delay removing it from the graph for 12 blocks", shortChannelId)
270-
// remove the channel from the db so it will not be added to the graph if a restart occurs before 12 blocks elapse
271-
db.removeChannel(shortChannelId)
272-
stay() using d.copy(spentChannels = d.spentChannels + (shortChannelId -> nodeParams.currentBlockHeight))
273-
274-
case Event(c: CurrentBlockHeight, d) =>
275-
val spentChannels1 = d.spentChannels.filter {
276-
// spent channels may be confirmed as a splice; wait 12 blocks before removing them from the graph
277-
case (_, blockHeight) if blockHeight >= c.blockHeight + 12 => true
278-
case (shortChannelId, _) => self ! HandleChannelSpent(shortChannelId); false
279-
}
280-
stay() using d.copy(spentChannels = spentChannels1)
265+
case Event(WatchExternalChannelSpentTriggered(shortChannelId, spendingTx), d) if d.channels.contains(shortChannelId) || d.prunedChannels.contains(shortChannelId) =>
266+
val txId = d.channels.getOrElse(shortChannelId, d.prunedChannels(shortChannelId)).fundingTxId
267+
log.info("funding tx txId={} of channelId={} has been spent - delay removing it from the graph until {} blocks after the spend confirms", txId, shortChannelId, nodeParams.channelConf.minDepthBlocks)
268+
watcher ! WatchTxConfirmed(self, spendingTx.txid, nodeParams.channelConf.minDepthBlocks)
269+
stay() using d.copy(spentChannels = d.spentChannels + (spendingTx.txid -> shortChannelId))
281270

282-
case Event(HandleChannelSpent(shortChannelId), d: Data) if d.channels.contains(shortChannelId) || d.prunedChannels.contains(shortChannelId) =>
283-
stay() using Validation.handleChannelSpent(d, nodeParams.db.network, shortChannelId)
271+
case Event(WatchTxConfirmedTriggered(_, _, spendingTx), d) =>
272+
d.spentChannels.get(spendingTx.txid) match {
273+
case Some(shortChannelId) => stay() using Validation.handleChannelSpent(d, nodeParams.db.network, shortChannelId)
274+
case None => stay()
275+
}
284276

285277
case Event(n: NodeAnnouncement, d: Data) =>
286278
stay() using Validation.handleNodeAnnouncement(d, nodeParams.db.network, Set(LocalGossip), n)
@@ -778,7 +770,7 @@ object Router {
778770
excludedChannels: Map[ChannelDesc, ExcludedChannelStatus], // those channels are temporarily excluded from route calculation, because their node returned a TemporaryChannelFailure
779771
graphWithBalances: GraphWithBalanceEstimates,
780772
sync: Map[PublicKey, Syncing], // keep tracks of channel range queries sent to each peer. If there is an entry in the map, it means that there is an ongoing query for which we have not yet received an 'end' message
781-
spentChannels: Map[RealShortChannelId, BlockHeight], // channels with funding txs spent less than 12 blocks ago
773+
spentChannels: Map[TxId, RealShortChannelId], // channels with spent funding txs that are not deeply buried yet
782774
) {
783775
def resolve(scid: ShortChannelId): Option[KnownChannel] = {
784776
// let's assume this is a real scid
@@ -819,7 +811,4 @@ object Router {
819811
/** We have tried to relay this amount from this channel and it failed. */
820812
case class ChannelCouldNotRelay(amount: MilliSatoshi, hop: ChannelHop)
821813

822-
/** Funding Tx of the channel id has been spent and not updated with a splice within 12 blocks. */
823-
private case class HandleChannelSpent(shortChannelId: RealShortChannelId)
824-
825814
}

eclair-core/src/main/scala/fr/acinq/eclair/router/Validation.scala

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import fr.acinq.eclair.db.NetworkDb
3131
import fr.acinq.eclair.router.Graph.GraphStructure.GraphEdge
3232
import fr.acinq.eclair.router.Monitoring.Metrics
3333
import fr.acinq.eclair.router.Router._
34+
import fr.acinq.eclair.router.Validation.{addPublicChannel, splicePublicChannel}
3435
import fr.acinq.eclair.transactions.Scripts
3536
import fr.acinq.eclair.wire.protocol._
3637
import fr.acinq.eclair.{BlockHeight, Logs, MilliSatoshiLong, NodeParams, RealShortChannelId, ShortChannelId, TxCoordinates}
@@ -111,17 +112,11 @@ object Validation {
111112
None
112113
} else {
113114
log.debug("validation successful for shortChannelId={}", c.shortChannelId)
114-
val sharedInputTxId_opt = tx.txIn.find(_.signatureScript == fundingOutputScript).map(_.outPoint.txid)
115115
remoteOrigins.foreach(o => sendDecision(o.peerConnection, GossipDecision.Accepted(c)))
116116
val capacity = tx.txOut(outputIndex).amount
117-
// if a channel spends the shared output of a recently spent channel, then it is a splice
118-
sharedInputTxId_opt match {
117+
d0.spentChannels.get(tx.txid) match {
118+
case Some(parentScid) => Some(splicePublicChannel(d0, nodeParams, watcher, c, tx.txid, capacity, d0.channels(parentScid)))
119119
case None => Some(addPublicChannel(d0, nodeParams, watcher, c, tx.txid, capacity, None))
120-
case Some(sharedInputTxId) =>
121-
d0.spentChannels.find(spent => d0.channels.get(spent._1).exists(_.fundingTxId == sharedInputTxId)) match {
122-
case Some((parentScid, _)) => Some(splicePublicChannel(d0, nodeParams, watcher, c, tx.txid, capacity, d0.channels(parentScid)))
123-
case None => log.error("channel shortChannelId={} is a splice, but not matching channel found!", c.shortChannelId); None
124-
}
125120
}
126121
}
127122
case ValidateResult(c, Right((tx, fundingTxStatus: UtxoStatus.Spent))) =>
@@ -165,16 +160,16 @@ object Validation {
165160
}
166161
}
167162

168-
private def splicePublicChannel(d: Data, nodeParams: NodeParams, watcher: typed.ActorRef[ZmqWatcher.Command], ann: ChannelAnnouncement, fundingTxId: TxId, capacity: Satoshi, parentChannel: PublicChannel)(implicit ctx: ActorContext, log: DiagnosticLoggingAdapter): Data = {
163+
private def splicePublicChannel(d: Data, nodeParams: NodeParams, watcher: typed.ActorRef[ZmqWatcher.Command], ann: ChannelAnnouncement, spliceTxId: TxId, capacity: Satoshi, parentChannel: PublicChannel)(implicit ctx: ActorContext, log: DiagnosticLoggingAdapter): Data = {
169164
implicit val sender: ActorRef = ctx.self // necessary to preserve origin when sending messages to other actors
170165
val fundingOutputIndex = outputIndex(ann.shortChannelId)
171-
watcher ! WatchExternalChannelSpent(ctx.self, fundingTxId, fundingOutputIndex, ann.shortChannelId)
166+
watcher ! WatchExternalChannelSpent(ctx.self, spliceTxId, fundingOutputIndex, ann.shortChannelId)
172167
ctx.system.eventStream.publish(ChannelsDiscovered(SingleChannelDiscovered(ann, capacity, None, None) :: Nil))
173-
nodeParams.db.network.addChannel(ann, fundingTxId, capacity)
168+
nodeParams.db.network.addChannel(ann, spliceTxId, capacity)
174169
nodeParams.db.network.removeChannel(parentChannel.shortChannelId)
175170
val pubChan = PublicChannel(
176171
ann = ann,
177-
fundingTxId = fundingTxId,
172+
fundingTxId = spliceTxId,
178173
capacity = capacity,
179174
update_1_opt = parentChannel.update_1_opt,
180175
update_2_opt = parentChannel.update_2_opt,
@@ -192,7 +187,8 @@ object Validation {
192187
// we rebroadcast the splice channel to our peers
193188
channels = d.rebroadcast.channels + (pubChan.ann -> d.awaiting.getOrElse(pubChan.ann, if (pubChan.nodeId1 == nodeParams.nodeId || pubChan.nodeId2 == nodeParams.nodeId) Seq(LocalGossip) else Nil).toSet),
194189
),
195-
graphWithBalances = graph1
190+
graphWithBalances = graph1,
191+
spentChannels = d.spentChannels.filter(_._1 != spliceTxId)
196192
)
197193
}
198194

@@ -257,7 +253,7 @@ object Validation {
257253
def handleChannelSpent(d: Data, db: NetworkDb, shortChannelId: RealShortChannelId)(implicit ctx: ActorContext, log: LoggingAdapter): Data = {
258254
implicit val sender: ActorRef = ctx.self // necessary to preserve origin when sending messages to other actors
259255
val lostChannel = d.channels.get(shortChannelId).orElse(d.prunedChannels.get(shortChannelId)).get.ann
260-
log.info("funding tx of channelId={} has been spent", shortChannelId)
256+
log.info("funding tx for channelId={} was spent", shortChannelId)
261257
// we need to remove nodes that aren't tied to any channels anymore
262258
val channels1 = d.channels - shortChannelId
263259
val prunedChannels1 = d.prunedChannels - shortChannelId
@@ -276,7 +272,8 @@ object Validation {
276272
db.removeNode(nodeId)
277273
ctx.system.eventStream.publish(NodeLost(nodeId))
278274
}
279-
d.copy(nodes = d.nodes -- lostNodes, channels = channels1, prunedChannels = prunedChannels1, graphWithBalances = graphWithBalances1)
275+
val spentChannels1 = d.spentChannels.filter(_._2 != shortChannelId)
276+
d.copy(nodes = d.nodes -- lostNodes, channels = channels1, prunedChannels = prunedChannels1, graphWithBalances = graphWithBalances1, spentChannels = spentChannels1)
280277
}
281278

282279
def handleNodeAnnouncement(d: Data, db: NetworkDb, origins: Set[GossipOrigin], n: NodeAnnouncement, wasStashed: Boolean = false)(implicit ctx: ActorContext, log: LoggingAdapter): Data = {
@@ -585,7 +582,7 @@ object Validation {
585582
val scid2PrivateChannels1 = d.scid2PrivateChannels - lcd.shortIds.localAlias.toLong -- lcd.shortIds.real.toOption.map(_.toLong)
586583
// a local channel has permanently gone down
587584
if (lcd.shortIds.real.toOption.exists(d.channels.contains)) {
588-
// the channel was public, we will receive (or have already received) a WatchEventSpentBasic event, that will trigger a clean up of the channel
585+
// the channel was public, we will receive (or have already received) a WatchSpent event, that will trigger a clean up of the channel
589586
// so let's not do anything here
590587
d.copy(scid2PrivateChannels = scid2PrivateChannels1)
591588
} else if (d.privateChannels.contains(lcd.channelId)) {

0 commit comments

Comments
 (0)