Skip to content

Commit d7b9bea

Browse files
committed
Delay considering a channel closed when seeing an on-chain spend
Issue #2437 When an external channel is spent, add it to the `spentChannels` list instead of immediately removing it from the graph. RBF attempts can produce multiple spending txs in the mempool for the same channel. The `spendChannels` list maps the txid of the spending tx to the scid of the spent channel. When a channel announcement is validated with a funding tx on the `spentChannels` list, consider the new channel a splice of the corresponding spent channel. A splice updates the graph edges to preserve balance estimate information in the graph. If a spending tx from the `spentChannels` list is deeply buried before appearing in a valid channel announcement, remove the corresponding spent channel edge from the graph. Also remove any corresponding spending tx entries on the `spentChannels` list.
1 parent a0b5834 commit d7b9bea

File tree

10 files changed

+285
-75
lines changed

10 files changed

+285
-75
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/BalanceEstimate.scala

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import fr.acinq.bitcoin.scalacompat.{Satoshi, SatoshiLong}
2222
import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge}
2323
import fr.acinq.eclair.router.Router.{ChannelDesc, ChannelHop, Route}
2424
import fr.acinq.eclair.wire.protocol.NodeAnnouncement
25-
import fr.acinq.eclair.{MilliSatoshi, MilliSatoshiLong, ShortChannelId, TimestampSecond, TimestampSecondLong, ToMilliSatoshiConversion}
25+
import fr.acinq.eclair.{MilliSatoshi, MilliSatoshiLong, RealShortChannelId, ShortChannelId, TimestampSecond, TimestampSecondLong, ToMilliSatoshiConversion}
2626

2727
import scala.concurrent.duration.{DurationInt, FiniteDuration}
2828

@@ -195,6 +195,18 @@ case class BalanceEstimate private(low: MilliSatoshi,
195195
)
196196
}
197197

198+
def updateEdge(desc: ChannelDesc, newShortChannelId: RealShortChannelId, newCapacity: Satoshi): BalanceEstimate = {
199+
val newCapacities = capacities - desc.shortChannelId + (newShortChannelId -> newCapacity)
200+
val capacityDelta = (newCapacity - capacities.getOrElse(desc.shortChannelId, newCapacity)).toMilliSatoshi
201+
copy(
202+
// a capacity decrease will decrease the low bound, but not below 0
203+
low = (low + capacityDelta.min(0 msat)).max(0 msat),
204+
// a capacity increase will increase the high bound, but not above the capacity of the largest channel
205+
high = (high + capacityDelta.max(0 msat)).min(newCapacities.values.maxOption.getOrElse(0 sat).toMilliSatoshi),
206+
capacities = newCapacities
207+
)
208+
}
209+
198210
/**
199211
* Estimate the probability that we can successfully send `amount` through the channel
200212
*
@@ -263,6 +275,14 @@ case class BalancesEstimates(balances: Map[(PublicKey, PublicKey), BalanceEstima
263275
defaultHalfLife
264276
)
265277

278+
def updateEdge(desc: ChannelDesc, newShortChannelId: RealShortChannelId, newCapacity: Satoshi): BalancesEstimates = BalancesEstimates(
279+
balances.updatedWith((desc.a, desc.b)) {
280+
case None => None
281+
case Some(balance) => Some(balance.updateEdge(desc, newShortChannelId, newCapacity))
282+
},
283+
defaultHalfLife
284+
)
285+
266286
def channelCouldSend(hop: ChannelHop, amount: MilliSatoshi)(implicit log: LoggingAdapter): BalancesEstimates = {
267287
get(hop.nodeId, hop.nextNodeId).foreach { balance =>
268288
val estimatedProbability = balance.canSend(amount, TimestampSecond.now())
@@ -305,6 +325,13 @@ case class GraphWithBalanceEstimates(graph: DirectedGraph, private val balances:
305325
descList.foldLeft(balances)((acc, edge) => acc.removeEdge(edge).removeEdge(edge.reversed)),
306326
)
307327

328+
def updateChannel(desc: ChannelDesc, newShortChannelId: RealShortChannelId, newCapacity: Satoshi): GraphWithBalanceEstimates = {
329+
GraphWithBalanceEstimates(
330+
graph.updateChannel(desc, newShortChannelId, newCapacity),
331+
balances.updateEdge(desc, newShortChannelId, newCapacity).updateEdge(desc.reversed, newShortChannelId, newCapacity)
332+
)
333+
}
334+
308335
def routeCouldRelay(route: Route)(implicit log: LoggingAdapter): GraphWithBalanceEstimates = {
309336
val (balances1, _) = route.hops.foldRight((balances, route.amount)) {
310337
case (hop, (balances, amount)) =>

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,26 @@ object Graph {
678678
descList.foldLeft(this)((acc, edge) => acc.removeChannel(edge))
679679
}
680680

681+
/**
682+
* Update the shortChannelId and capacity of edges corresponding to the given channel-desc,
683+
* both edges (corresponding to both directions) are updated.
684+
*
685+
* @param desc the channel description for the channel to update
686+
* @param newShortChannelId the new shortChannelId for this channel
687+
* @param newCapacity the new capacity of the channel
688+
* @return a new graph with updated vertexes
689+
*/
690+
def updateChannel(desc: ChannelDesc, newShortChannelId: RealShortChannelId, newCapacity: Satoshi): DirectedGraph = {
691+
val newDesc = desc.copy(shortChannelId = newShortChannelId)
692+
val updatedVertices =
693+
vertices
694+
.updatedWith(desc.b)(_.map(vertexB => vertexB.copy(incomingEdges = vertexB.incomingEdges - desc +
695+
(newDesc -> vertexB.incomingEdges(desc).copy(desc = newDesc, capacity = newCapacity)))))
696+
.updatedWith(desc.a)(_.map(vertexA => vertexA.copy(incomingEdges = vertexA.incomingEdges - desc.reversed +
697+
(newDesc.reversed -> vertexA.incomingEdges(desc.reversed).copy(desc = newDesc.reversed, capacity = newCapacity)))))
698+
DirectedGraph(updatedVertices)
699+
}
700+
681701
/**
682702
* @return For edges to be considered equal they must have the same in/out vertices AND same shortChannelId
683703
*/

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

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
2525
import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, Satoshi, TxId}
2626
import fr.acinq.eclair.Logs.LogCategory
2727
import fr.acinq.eclair._
28+
import fr.acinq.eclair.blockchain.CurrentBlockHeight
2829
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher
29-
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{ValidateResult, WatchExternalChannelSpent, WatchExternalChannelSpentTriggered}
30+
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{ValidateResult, WatchExternalChannelSpent, WatchExternalChannelSpentTriggered, WatchTxConfirmed, WatchTxConfirmedTriggered}
3031
import fr.acinq.eclair.channel._
32+
import fr.acinq.eclair.channel.fsm.Channel.ANNOUNCEMENTS_MINCONF
3133
import fr.acinq.eclair.crypto.TransportHandler
3234
import fr.acinq.eclair.db.NetworkDb
3335
import fr.acinq.eclair.io.Peer.PeerRoutingMessage
@@ -64,6 +66,7 @@ class Router(val nodeParams: NodeParams, watcher: typed.ActorRef[ZmqWatcher.Comm
6466
context.system.eventStream.subscribe(self, classOf[LocalChannelUpdate])
6567
context.system.eventStream.subscribe(self, classOf[LocalChannelDown])
6668
context.system.eventStream.subscribe(self, classOf[AvailableBalanceChanged])
69+
context.system.eventStream.subscribe(self, classOf[CurrentBlockHeight])
6770
context.system.eventStream.publish(SubscriptionsComplete(this.getClass))
6871

6972
startTimerWithFixedDelay(TickBroadcast.toString, TickBroadcast, nodeParams.routerConf.routerBroadcastInterval)
@@ -113,7 +116,8 @@ class Router(val nodeParams: NodeParams, watcher: typed.ActorRef[ZmqWatcher.Comm
113116
scid2PrivateChannels = Map.empty,
114117
excludedChannels = Map.empty,
115118
graphWithBalances = GraphWithBalanceEstimates(graph, nodeParams.routerConf.balanceEstimateHalfLife),
116-
sync = Map.empty)
119+
sync = Map.empty,
120+
spentChannels = Map.empty)
117121
startWith(NORMAL, data)
118122
}
119123

@@ -259,8 +263,17 @@ class Router(val nodeParams: NodeParams, watcher: typed.ActorRef[ZmqWatcher.Comm
259263
case Event(r: ValidateResult, d) =>
260264
stay() using Validation.handleChannelValidationResponse(d, nodeParams, watcher, r)
261265

262-
case Event(WatchExternalChannelSpentTriggered(shortChannelId), d) if d.channels.contains(shortChannelId) || d.prunedChannels.contains(shortChannelId) =>
263-
stay() using Validation.handleChannelSpent(d, nodeParams.db.network, shortChannelId)
266+
case Event(WatchExternalChannelSpentTriggered(shortChannelId, spendingTx), d) if d.channels.contains(shortChannelId) || d.prunedChannels.contains(shortChannelId) =>
267+
val txId = d.channels.getOrElse(shortChannelId, d.prunedChannels(shortChannelId)).fundingTxId
268+
log.info("funding tx txId={} of channelId={} has been spent - delay removing it from the graph until {} blocks after the spend confirms", txId, shortChannelId, ANNOUNCEMENTS_MINCONF * 2)
269+
watcher ! WatchTxConfirmed(self, spendingTx.txid, ANNOUNCEMENTS_MINCONF * 2)
270+
stay() using d.copy(spentChannels = d.spentChannels + (spendingTx.txid -> shortChannelId))
271+
272+
case Event(WatchTxConfirmedTriggered(_, _, spendingTx), d) =>
273+
d.spentChannels.get(spendingTx.txid) match {
274+
case Some(shortChannelId) => stay() using Validation.handleChannelSpent(d, nodeParams.db.network, shortChannelId)
275+
case None => stay()
276+
}
264277

265278
case Event(n: NodeAnnouncement, d: Data) =>
266279
stay() using Validation.handleNodeAnnouncement(d, nodeParams.db.network, Set(LocalGossip), n)
@@ -757,7 +770,8 @@ object Router {
757770
scid2PrivateChannels: Map[Long, ByteVector32], // real scid or alias to channel_id, only to be used for private channels
758771
excludedChannels: Map[ChannelDesc, ExcludedChannelStatus], // those channels are temporarily excluded from route calculation, because their node returned a TemporaryChannelFailure
759772
graphWithBalances: GraphWithBalanceEstimates,
760-
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
773+
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
774+
spentChannels: Map[TxId, RealShortChannelId], // transactions that spend funding txs that are not yet deeply buried
761775
) {
762776
def resolve(scid: ShortChannelId): Option[KnownChannel] = {
763777
// let's assume this is a real scid

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

Lines changed: 40 additions & 4 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}
@@ -113,7 +114,10 @@ object Validation {
113114
log.debug("validation successful for shortChannelId={}", c.shortChannelId)
114115
remoteOrigins.foreach(o => sendDecision(o.peerConnection, GossipDecision.Accepted(c)))
115116
val capacity = tx.txOut(outputIndex).amount
116-
Some(addPublicChannel(d0, nodeParams, watcher, c, tx.txid, capacity, None))
117+
d0.spentChannels.get(tx.txid) match {
118+
case Some(parentScid) => Some(splicePublicChannel(d0, nodeParams, watcher, c, tx.txid, capacity, d0.channels(parentScid)))
119+
case None => Some(addPublicChannel(d0, nodeParams, watcher, c, tx.txid, capacity, None))
120+
}
117121
}
118122
case ValidateResult(c, Right((tx, fundingTxStatus: UtxoStatus.Spent))) =>
119123
if (fundingTxStatus.spendingTxConfirmed) {
@@ -156,6 +160,38 @@ object Validation {
156160
}
157161
}
158162

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 = {
164+
implicit val sender: ActorRef = ctx.self // necessary to preserve origin when sending messages to other actors
165+
val fundingOutputIndex = outputIndex(ann.shortChannelId)
166+
watcher ! WatchExternalChannelSpent(ctx.self, spliceTxId, fundingOutputIndex, ann.shortChannelId)
167+
ctx.system.eventStream.publish(ChannelsDiscovered(SingleChannelDiscovered(ann, capacity, None, None) :: Nil))
168+
nodeParams.db.network.addChannel(ann, spliceTxId, capacity)
169+
nodeParams.db.network.removeChannel(parentChannel.shortChannelId)
170+
val pubChan = PublicChannel(
171+
ann = ann,
172+
fundingTxId = spliceTxId,
173+
capacity = capacity,
174+
update_1_opt = None,
175+
update_2_opt = None,
176+
meta_opt = parentChannel.meta_opt
177+
)
178+
log.debug("replacing parent channel scid={} with splice channel scid={}; splice channel={}", parentChannel.shortChannelId, ann.shortChannelId, pubChan)
179+
// we need to update the graph because the edge identifiers and capacity change from the parent scid to the new splice scid
180+
log.debug("updating the graph for shortChannelId={}", pubChan.shortChannelId)
181+
val graph1 = d.graphWithBalances.updateChannel(ChannelDesc(parentChannel.shortChannelId, parentChannel.nodeId1, parentChannel.nodeId2), ann.shortChannelId, capacity)
182+
d.copy(
183+
// we also add the splice scid -> channelId and remove the parent scid -> channelId mappings
184+
channels = d.channels + (pubChan.shortChannelId -> pubChan) - parentChannel.shortChannelId,
185+
// we also add the newly validated channels to the rebroadcast queue
186+
rebroadcast = d.rebroadcast.copy(
187+
// we rebroadcast the splice channel to our peers
188+
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),
189+
),
190+
graphWithBalances = graph1,
191+
spentChannels = d.spentChannels.filter(_._2 != parentChannel.shortChannelId)
192+
)
193+
}
194+
159195
private def addPublicChannel(d: Data, nodeParams: NodeParams, watcher: typed.ActorRef[ZmqWatcher.Command], ann: ChannelAnnouncement, fundingTxId: TxId, capacity: Satoshi, privChan_opt: Option[PrivateChannel])(implicit ctx: ActorContext, log: DiagnosticLoggingAdapter): Data = {
160196
implicit val sender: ActorRef = ctx.self // necessary to preserve origin when sending messages to other actors
161197
val fundingOutputIndex = outputIndex(ann.shortChannelId)
@@ -217,7 +253,7 @@ object Validation {
217253
def handleChannelSpent(d: Data, db: NetworkDb, shortChannelId: RealShortChannelId)(implicit ctx: ActorContext, log: LoggingAdapter): Data = {
218254
implicit val sender: ActorRef = ctx.self // necessary to preserve origin when sending messages to other actors
219255
val lostChannel = d.channels.get(shortChannelId).orElse(d.prunedChannels.get(shortChannelId)).get.ann
220-
log.info("funding tx of channelId={} has been spent", shortChannelId)
256+
log.info("funding tx for channelId={} was spent", shortChannelId)
221257
// we need to remove nodes that aren't tied to any channels anymore
222258
val channels1 = d.channels - shortChannelId
223259
val prunedChannels1 = d.prunedChannels - shortChannelId
@@ -236,7 +272,7 @@ object Validation {
236272
db.removeNode(nodeId)
237273
ctx.system.eventStream.publish(NodeLost(nodeId))
238274
}
239-
d.copy(nodes = d.nodes -- lostNodes, channels = channels1, prunedChannels = prunedChannels1, graphWithBalances = graphWithBalances1)
275+
d.copy(nodes = d.nodes -- lostNodes, channels = channels1, prunedChannels = prunedChannels1, graphWithBalances = graphWithBalances1, spentChannels = d.spentChannels.filter(_._2 != shortChannelId))
240276
}
241277

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

0 commit comments

Comments
 (0)