Skip to content

Commit a307b70

Browse files
authored
Add unconfirmed transaction pruning when computing closing balance (#3119)
Our node balance computation was simplified in #3096, but it is now too simplistic during closing, where some of the balance may be duplicated between the off-chain part and unconfirmed or recently confirmed transactions. We deduplicate those by not counting in our off-chain balance of closing channels outputs that are spent by an unconfirmed or recently confirmed transaction that we've included in our on-chain balance. Fixes #3098
1 parent 09fc936 commit a307b70

File tree

5 files changed

+211
-57
lines changed

5 files changed

+211
-57
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/balance/BalanceActor.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,8 @@ private class BalanceActor(context: ActorContext[Command],
118118
.foreach(utxo => log.info("- utxo={} amount={}", utxo.outPoint, utxo.amount))
119119
// Off-chain metrics:
120120
log.info("off-chain diff={}", balance.offChain.total - previousBalance.offChain.total)
121-
val offChainBalancesBefore = previousBalance.channels.view.mapValues(channel => OffChainBalance().addChannelBalance(channel).total)
122-
val offChainBalancesAfter = balance.channels.view.mapValues(channel => OffChainBalance().addChannelBalance(channel).total)
121+
val offChainBalancesBefore = previousBalance.channels.view.mapValues(channel => OffChainBalance().addChannelBalance(channel, previousBalance.onChain.recentlySpentInputs).total)
122+
val offChainBalancesAfter = balance.channels.view.mapValues(channel => OffChainBalance().addChannelBalance(channel, balance.onChain.recentlySpentInputs).total)
123123
offChainBalancesAfter
124124
.map { case (channelId, balanceAfter) => (channelId, balanceAfter - offChainBalancesBefore.getOrElse(channelId, Btc(0))) }
125125
.filter { case (_, balanceDiff) => balanceDiff > 0.sat }

eclair-core/src/main/scala/fr/acinq/eclair/balance/CheckBalance.scala

Lines changed: 78 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
package fr.acinq.eclair.balance
1818

19-
import fr.acinq.bitcoin.scalacompat.{Btc, ByteVector32, OutPoint, Satoshi, SatoshiLong}
19+
import fr.acinq.bitcoin.scalacompat.{BlockId, Btc, ByteVector32, KotlinUtils, OutPoint, Satoshi, SatoshiLong}
2020
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient
2121
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.Utxo
2222
import fr.acinq.eclair.channel.Helpers.Closing
@@ -26,6 +26,7 @@ import fr.acinq.eclair.transactions.DirectedHtlc.incoming
2626
import fr.acinq.eclair.wire.protocol.UpdateAddHtlc
2727

2828
import scala.concurrent.{ExecutionContext, Future}
29+
import scala.jdk.CollectionConverters.CollectionHasAsScala
2930

3031
object CheckBalance {
3132

@@ -60,16 +61,16 @@ object CheckBalance {
6061
// We take the last commitment into account: it's the most likely to (eventually) confirm.
6162
MainAndHtlcBalance(
6263
toLocal = this.toLocal + mainBalance(commitments.latest.localCommit),
63-
htlcs = commitments.latest.localCommit.spec.htlcs.collect(incoming).sumAmount
64+
htlcs = this.htlcs + commitments.latest.localCommit.spec.htlcs.collect(incoming).sumAmount
6465
)
6566
}
6667

6768
/** Add our balance for a confirmed local close. */
68-
def addLocalClose(lcp: LocalCommitPublished): MainAndHtlcBalance = {
69-
// If our main transaction isn't deeply confirmed yet, we count it in our off-chain balance.
70-
// Once it confirms, it will be included in our on-chain balance, so we ignore it in our off-chain balance.
69+
def addLocalClose(lcp: LocalCommitPublished, recentlySpentInputs: Set[OutPoint]): MainAndHtlcBalance = {
70+
// If our main transaction isn't confirmed or in the mempool yet, we count it in our off-chain balance.
71+
// Once it confirms or appears in the mempool, it will be included in our on-chain balance, so we ignore it in our off-chain balance.
7172
val additionalToLocal = lcp.localOutput_opt match {
72-
case Some(outpoint) if !lcp.irrevocablySpent.contains(outpoint) => lcp.commitTx.txOut(outpoint.index.toInt).amount
73+
case Some(outpoint) if !lcp.irrevocablySpent.contains(outpoint) && !recentlySpentInputs.contains(outpoint) => lcp.commitTx.txOut(outpoint.index.toInt).amount
7374
case _ => 0 sat
7475
}
7576
val additionalHtlcs = lcp.htlcOutputs.map { outpoint =>
@@ -81,9 +82,9 @@ object CheckBalance {
8182
val delayedHtlcOutpoint = OutPoint(spendingTx.txid, 0)
8283
val htlcSpentByUs = lcp.htlcDelayedOutputs.contains(delayedHtlcOutpoint)
8384
// If our 3rd-stage transaction isn't confirmed yet, we should count it in our off-chain balance.
84-
// Once confirmed, we should ignore it since it will appear in our on-chain balance.
85+
// Once confirmed or seen in the mempool, we should ignore it since it will appear in our on-chain balance.
8586
val htlcDelayedPending = !lcp.irrevocablySpent.contains(delayedHtlcOutpoint)
86-
if (htlcSpentByUs && htlcDelayedPending) htlcAmount else 0 sat
87+
if (htlcSpentByUs && htlcDelayedPending && !recentlySpentInputs.contains(delayedHtlcOutpoint)) htlcAmount else 0 sat
8788
case None =>
8889
// We assume that HTLCs will be fulfilled, so we only count incoming HTLCs in our off-chain balance.
8990
if (lcp.incomingHtlcs.contains(outpoint)) htlcAmount else 0 sat
@@ -93,34 +94,34 @@ object CheckBalance {
9394
}
9495

9596
/** Add our balance for a confirmed remote close. */
96-
def addRemoteClose(rcp: RemoteCommitPublished): MainAndHtlcBalance = {
97-
// If our main transaction isn't deeply confirmed yet, we count it in our off-chain balance.
98-
// Once it confirms, it will be included in our on-chain balance, so we ignore it in our off-chain balance.
97+
def addRemoteClose(rcp: RemoteCommitPublished, recentlySpentInputs: Set[OutPoint]): MainAndHtlcBalance = {
98+
// If our main transaction isn't confirmed or in the mempool yet, we count it in our off-chain balance.
99+
// Once it confirms or appears in the mempool, it will be included in our on-chain balance, so we ignore it in our off-chain balance.
99100
val additionalToLocal = rcp.localOutput_opt match {
100-
case Some(outpoint) if !rcp.irrevocablySpent.contains(outpoint) => rcp.commitTx.txOut(outpoint.index.toInt).amount
101+
case Some(outpoint) if !rcp.irrevocablySpent.contains(outpoint) && !recentlySpentInputs.contains(outpoint) => rcp.commitTx.txOut(outpoint.index.toInt).amount
101102
case _ => 0 sat
102103
}
103104
// If HTLC transactions are confirmed, they will appear in our on-chain balance if we were the one to claim them.
104105
// We only need to include incoming HTLCs that haven't been claimed yet (since we assume that they will be fulfilled).
105106
// Note that it is their commitment, so incoming/outgoing are inverted.
106107
val additionalHtlcs = rcp.incomingHtlcs.keys.map {
107-
case outpoint if !rcp.irrevocablySpent.contains(outpoint) => rcp.commitTx.txOut(outpoint.index.toInt).amount
108+
case outpoint if !rcp.irrevocablySpent.contains(outpoint) && !recentlySpentInputs.contains(outpoint) => rcp.commitTx.txOut(outpoint.index.toInt).amount
108109
case _ => 0 sat
109110
}.sum
110111
MainAndHtlcBalance(toLocal = toLocal + additionalToLocal, htlcs = htlcs + additionalHtlcs)
111112
}
112113

113114
/** Add our balance for a confirmed revoked close. */
114-
def addRevokedClose(rvk: RevokedCommitPublished): MainAndHtlcBalance = {
115-
// If our main transaction isn't deeply confirmed yet, we count it in our off-chain balance.
116-
// Once it confirms, it will be included in our on-chain balance, so we ignore it in our off-chain balance.
115+
def addRevokedClose(rvk: RevokedCommitPublished, recentlySpentInputs: Set[OutPoint]): MainAndHtlcBalance = {
116+
// If our main transaction isn't confirmed or in the mempool yet, we count it in our off-chain balance.
117+
// Once it confirms or appears in the mempool, it will be included in our on-chain balance, so we ignore it in our off-chain balance.
117118
// We do the same thing for our main penalty transaction claiming their main output.
118119
val additionalToLocal = rvk.localOutput_opt match {
119-
case Some(outpoint) if !rvk.irrevocablySpent.contains(outpoint) => rvk.commitTx.txOut(outpoint.index.toInt).amount
120+
case Some(outpoint) if !rvk.irrevocablySpent.contains(outpoint) && !recentlySpentInputs.contains(outpoint) => rvk.commitTx.txOut(outpoint.index.toInt).amount
120121
case _ => 0 sat
121122
}
122123
val additionalToRemote = rvk.remoteOutput_opt match {
123-
case Some(outpoint) if !rvk.irrevocablySpent.contains(outpoint) => rvk.commitTx.txOut(outpoint.index.toInt).amount
124+
case Some(outpoint) if !rvk.irrevocablySpent.contains(outpoint) && !recentlySpentInputs.contains(outpoint) => rvk.commitTx.txOut(outpoint.index.toInt).amount
124125
case _ => 0 sat
125126
}
126127
val additionalHtlcs = rvk.htlcOutputs.map(htlcOutpoint => {
@@ -138,14 +139,15 @@ object CheckBalance {
138139
val htlcDelayedPending = !rvk.irrevocablySpent.contains(delayedHtlcOutpoint)
139140
// Note that if the HTLC output was spent by us, it should appear in our on-chain balance, so we don't
140141
// count it here.
141-
if (htlcSpentByThem && htlcDelayedPending) htlcAmount else 0 sat
142+
if (htlcSpentByThem && htlcDelayedPending && !recentlySpentInputs.contains(delayedHtlcOutpoint)) htlcAmount else 0 sat
142143
case None =>
143144
// This should never happen unless our data is corrupted.
144145
0 sat
145146
}
146-
case None =>
147-
// We assume that our penalty transaction will confirm before their HTLC transaction.
148-
htlcAmount
147+
// We ignore this HTLC if it's already included in our on-chain balance.
148+
case None if recentlySpentInputs.contains(htlcOutpoint) => 0 sat
149+
// We assume that our penalty transaction will confirm before their HTLC transaction.
150+
case None => htlcAmount
149151
}
150152
}).sum
151153
MainAndHtlcBalance(toLocal = toLocal + additionalToLocal + additionalToRemote, htlcs = htlcs + additionalHtlcs)
@@ -164,14 +166,19 @@ object CheckBalance {
164166
waitForPublishFutureCommitment: Btc = 0.sat) {
165167
val total: Btc = waitForFundingConfirmed + waitForChannelReady + normal.total + shutdown.total + negotiating.total + closing.total + waitForPublishFutureCommitment
166168

167-
def addChannelBalance(channel: PersistentChannelData): OffChainBalance = channel match {
169+
def addChannelBalance(channel: PersistentChannelData, recentlySpentInputs: Set[OutPoint]): OffChainBalance = channel match {
168170
case d: DATA_WAIT_FOR_FUNDING_CONFIRMED => this.copy(waitForFundingConfirmed = this.waitForFundingConfirmed + mainBalance(d.commitments.latest.localCommit))
169171
case d: DATA_WAIT_FOR_CHANNEL_READY => this.copy(waitForChannelReady = this.waitForChannelReady + mainBalance(d.commitments.latest.localCommit))
170172
case _: DATA_WAIT_FOR_DUAL_FUNDING_SIGNED => this // we ignore our balance from unsigned commitments
171173
case d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED => this.copy(waitForFundingConfirmed = this.waitForFundingConfirmed + mainBalance(d.commitments.latest.localCommit))
172174
case d: DATA_WAIT_FOR_DUAL_FUNDING_READY => this.copy(waitForChannelReady = this.waitForChannelReady + mainBalance(d.commitments.latest.localCommit))
173175
case d: DATA_NORMAL => this.copy(normal = this.normal.addChannelBalance(d.commitments))
174176
case d: DATA_SHUTDOWN => this.copy(shutdown = this.shutdown.addChannelBalance(d.commitments))
177+
// If one of our closing transactions is in the mempool or recently confirmed, and thus included in our on-chain
178+
// balance, we ignore this channel in our off-chain balance to avoid counting it twice.
179+
case d: DATA_NEGOTIATING if recentlySpentInputs.contains(d.commitments.latest.commitInput.outPoint) => this
180+
case d: DATA_NEGOTIATING_SIMPLE if recentlySpentInputs.contains(d.commitments.latest.commitInput.outPoint) => this
181+
// Otherwise, that means the closing transactions aren't in the mempool yet, so we include our off-chain balance.
175182
case d: DATA_NEGOTIATING => this.copy(negotiating = this.negotiating.addChannelBalance(d.commitments))
176183
case d: DATA_NEGOTIATING_SIMPLE => this.copy(negotiating = this.negotiating.addChannelBalance(d.commitments))
177184
case d: DATA_CLOSING =>
@@ -180,10 +187,10 @@ object CheckBalance {
180187
// We can ignore it as our channel balance should appear in our on-chain balance.
181188
case Some(_: MutualClose) => this
182189
// A commitment transaction is confirmed: we compute the channel balance that we expect to get back on-chain.
183-
case Some(c: LocalClose) => this.copy(closing = this.closing.addLocalClose(c.localCommitPublished))
184-
case Some(c: CurrentRemoteClose) => this.copy(closing = this.closing.addRemoteClose(c.remoteCommitPublished))
185-
case Some(c: NextRemoteClose) => this.copy(closing = this.closing.addRemoteClose(c.remoteCommitPublished))
186-
case Some(c: RevokedClose) => this.copy(closing = this.closing.addRevokedClose(c.revokedCommitPublished))
190+
case Some(c: LocalClose) => this.copy(closing = this.closing.addLocalClose(c.localCommitPublished, recentlySpentInputs))
191+
case Some(c: CurrentRemoteClose) => this.copy(closing = this.closing.addRemoteClose(c.remoteCommitPublished, recentlySpentInputs))
192+
case Some(c: NextRemoteClose) => this.copy(closing = this.closing.addRemoteClose(c.remoteCommitPublished, recentlySpentInputs))
193+
case Some(c: RevokedClose) => this.copy(closing = this.closing.addRevokedClose(c.revokedCommitPublished, recentlySpentInputs))
187194
// In the recovery case, we can only claim our main output, HTLC outputs are lost.
188195
// Once our main transaction confirms, the channel will transition to the CLOSED state and our channel funds
189196
// will appear in our on-chain balance (minus on-chain fees).
@@ -210,11 +217,11 @@ object CheckBalance {
210217
* take on-chain fees into account. Once closing transactions confirm, we ignore the corresponding channel amounts,
211218
* the final amounts are included in our on-chain balance, which takes into account the on-chain fees paid.
212219
*/
213-
def computeOffChainBalance(channels: Iterable[PersistentChannelData]): OffChainBalance = {
214-
channels.foldLeft(OffChainBalance()) { case (balance, channel) => balance.addChannelBalance(channel) }
220+
def computeOffChainBalance(channels: Iterable[PersistentChannelData], recentlySpentInputs: Set[OutPoint]): OffChainBalance = {
221+
channels.foldLeft(OffChainBalance()) { case (balance, channel) => balance.addChannelBalance(channel, recentlySpentInputs) }
215222
}
216223

217-
case class DetailedOnChainBalance(deeplyConfirmed: Map[OutPoint, Btc] = Map.empty, recentlyConfirmed: Map[OutPoint, Btc] = Map.empty, unconfirmed: Map[OutPoint, Btc] = Map.empty, utxos: Seq[Utxo]) {
224+
case class DetailedOnChainBalance(deeplyConfirmed: Map[OutPoint, Btc] = Map.empty, recentlyConfirmed: Map[OutPoint, Btc] = Map.empty, unconfirmed: Map[OutPoint, Btc] = Map.empty, utxos: Seq[Utxo], recentlySpentInputs: Set[OutPoint]) {
218225
val totalDeeplyConfirmed: Btc = deeplyConfirmed.values.map(_.toSatoshi).sum
219226
val totalRecentlyConfirmed: Btc = recentlyConfirmed.values.map(_.toSatoshi).sum
220227
val totalUnconfirmed: Btc = unconfirmed.values.map(_.toSatoshi).sum
@@ -229,26 +236,61 @@ object CheckBalance {
229236
* Note that this may create temporary glitches when doing 0-conf splices, which will appear in the off-chain balance
230237
* immediately but will only be correctly accounted for in our on-chain balance after being deeply confirmed. Those
231238
* cases can be detected by looking at the unconfirmed and recently confirmed on-chain balance.
232-
*
233-
* During force-close, closing transactions that haven't reached min-depth are counted in our off-chain balance and
234-
* should thus be ignored from our on-chain balance, where they will be tracked as unconfirmed or recently confirmed.
235239
*/
236-
private def computeOnChainBalance(bitcoinClient: BitcoinCoreClient, minDepth: Int)(implicit ec: ExecutionContext): Future[DetailedOnChainBalance] = for {
240+
def computeOnChainBalance(bitcoinClient: BitcoinCoreClient, minDepth: Int)(implicit ec: ExecutionContext): Future[DetailedOnChainBalance] = for {
237241
utxos <- bitcoinClient.listUnspent()
238-
detailed = utxos.foldLeft(DetailedOnChainBalance(utxos = utxos)) {
242+
unconfirmedRecentlySpentInputs <- getUnconfirmedRecentlySpentInputs(bitcoinClient, utxos)
243+
confirmedRecentlySpentInputs <- getConfirmedRecentlySpentInputs(bitcoinClient, minDepth)
244+
detailed = utxos.foldLeft(DetailedOnChainBalance(utxos = utxos, recentlySpentInputs = unconfirmedRecentlySpentInputs ++ confirmedRecentlySpentInputs)) {
239245
case (total, utxo) if utxo.confirmations == 0 => total.copy(unconfirmed = total.unconfirmed + (utxo.outPoint -> utxo.amount))
240246
case (total, utxo) if utxo.confirmations < minDepth => total.copy(recentlyConfirmed = total.recentlyConfirmed + (utxo.outPoint -> utxo.amount))
241247
case (total, utxo) => total.copy(deeplyConfirmed = total.deeplyConfirmed + (utxo.outPoint -> utxo.amount))
242248
}
243249
} yield detailed
244250

251+
/**
252+
* We list utxos that were spent by our unconfirmed transactions: they will be included in our on-chain balance, and
253+
* thus need to be ignored from our off-chain balance.
254+
*/
255+
private def getUnconfirmedRecentlySpentInputs(bitcoinClient: BitcoinCoreClient, utxos: Seq[Utxo])(implicit ec: ExecutionContext): Future[Set[OutPoint]] = {
256+
val unconfirmedTxs = utxos.filter(_.confirmations == 0).map(_.txid).toSet
257+
Future.sequence(unconfirmedTxs.map(txId => bitcoinClient.getTransaction(txId).map(Some(_)).recover { case _ => None })).map(_.flatten.flatMap(_.txIn.map(_.outPoint)))
258+
}
259+
260+
/**
261+
* We list utxos that were spent in recent blocks, up to min-depth: those utxos will be included in our on-chain
262+
* balance if they belong to us, and thus need to be ignored from our off-chain balance.
263+
*
264+
* Note that since we may spend our inputs before they reach min-depth (e.g. to fund unrelated channels), some of
265+
* those utxos don't appear in our on-chain balance, which is fine since we already spent them! In that case, they
266+
* must not be counted in our off-chain balance either, since we've used them already. This is why we cannot rely
267+
* only on listUnspent to deduplicate utxos between on-chain and off-chain balances.
268+
*/
269+
private def getConfirmedRecentlySpentInputs(bitcoinClient: BitcoinCoreClient, minDepth: Int)(implicit ec: ExecutionContext): Future[Set[OutPoint]] = for {
270+
currentBlockHeight <- bitcoinClient.getBlockHeight()
271+
currentBlockId <- bitcoinClient.getBlockId(currentBlockHeight.toInt)
272+
// We look one block past our min-depth in case there's a race with a new block.
273+
spentInputs <- scanPastBlocks(bitcoinClient, currentBlockId, Set.empty, remaining = minDepth + 1)
274+
} yield spentInputs
275+
276+
private def scanPastBlocks(bitcoinClient: BitcoinCoreClient, blockId: BlockId, spentInputs: Set[OutPoint], remaining: Int)(implicit ec: ExecutionContext): Future[Set[OutPoint]] = {
277+
bitcoinClient.getBlock(blockId).flatMap(block => {
278+
val spentInputs1 = spentInputs ++ block.tx.asScala.flatMap(_.txIn.asScala.map(_.outPoint)).map(KotlinUtils.kmp2scala).toSet
279+
if (remaining > 0) {
280+
scanPastBlocks(bitcoinClient, BlockId(KotlinUtils.kmp2scala(block.header.hashPreviousBlock)), spentInputs1, remaining - 1)
281+
} else {
282+
Future.successful(spentInputs1)
283+
}
284+
})
285+
}
286+
245287
case class GlobalBalance(onChain: DetailedOnChainBalance, offChain: OffChainBalance, channels: Map[ByteVector32, PersistentChannelData]) {
246288
val total: Btc = onChain.total + offChain.total
247289
}
248290

249291
def computeGlobalBalance(channels: Map[ByteVector32, PersistentChannelData], bitcoinClient: BitcoinCoreClient, minDepth: Int)(implicit ec: ExecutionContext): Future[GlobalBalance] = for {
250292
onChain <- CheckBalance.computeOnChainBalance(bitcoinClient, minDepth)
251-
offChain = CheckBalance.computeOffChainBalance(channels.values)
293+
offChain = CheckBalance.computeOffChainBalance(channels.values, onChain.recentlySpentInputs)
252294
} yield GlobalBalance(onChain, offChain, channels)
253295

254296
}

0 commit comments

Comments
 (0)