16
16
17
17
package fr .acinq .eclair .balance
18
18
19
- import fr .acinq .bitcoin .scalacompat .{Btc , ByteVector32 , OutPoint , Satoshi , SatoshiLong }
19
+ import fr .acinq .bitcoin .scalacompat .{BlockId , Btc , ByteVector32 , KotlinUtils , OutPoint , Satoshi , SatoshiLong }
20
20
import fr .acinq .eclair .blockchain .bitcoind .rpc .BitcoinCoreClient
21
21
import fr .acinq .eclair .blockchain .bitcoind .rpc .BitcoinCoreClient .Utxo
22
22
import fr .acinq .eclair .channel .Helpers .Closing
@@ -26,6 +26,7 @@ import fr.acinq.eclair.transactions.DirectedHtlc.incoming
26
26
import fr .acinq .eclair .wire .protocol .UpdateAddHtlc
27
27
28
28
import scala .concurrent .{ExecutionContext , Future }
29
+ import scala .jdk .CollectionConverters .CollectionHasAsScala
29
30
30
31
object CheckBalance {
31
32
@@ -60,16 +61,16 @@ object CheckBalance {
60
61
// We take the last commitment into account: it's the most likely to (eventually) confirm.
61
62
MainAndHtlcBalance (
62
63
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
64
65
)
65
66
}
66
67
67
68
/** 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.
71
72
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
73
74
case _ => 0 sat
74
75
}
75
76
val additionalHtlcs = lcp.htlcOutputs.map { outpoint =>
@@ -81,9 +82,9 @@ object CheckBalance {
81
82
val delayedHtlcOutpoint = OutPoint (spendingTx.txid, 0 )
82
83
val htlcSpentByUs = lcp.htlcDelayedOutputs.contains(delayedHtlcOutpoint)
83
84
// 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.
85
86
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
87
88
case None =>
88
89
// We assume that HTLCs will be fulfilled, so we only count incoming HTLCs in our off-chain balance.
89
90
if (lcp.incomingHtlcs.contains(outpoint)) htlcAmount else 0 sat
@@ -93,34 +94,34 @@ object CheckBalance {
93
94
}
94
95
95
96
/** 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.
99
100
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
101
102
case _ => 0 sat
102
103
}
103
104
// If HTLC transactions are confirmed, they will appear in our on-chain balance if we were the one to claim them.
104
105
// We only need to include incoming HTLCs that haven't been claimed yet (since we assume that they will be fulfilled).
105
106
// Note that it is their commitment, so incoming/outgoing are inverted.
106
107
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
108
109
case _ => 0 sat
109
110
}.sum
110
111
MainAndHtlcBalance (toLocal = toLocal + additionalToLocal, htlcs = htlcs + additionalHtlcs)
111
112
}
112
113
113
114
/** 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.
117
118
// We do the same thing for our main penalty transaction claiming their main output.
118
119
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
120
121
case _ => 0 sat
121
122
}
122
123
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
124
125
case _ => 0 sat
125
126
}
126
127
val additionalHtlcs = rvk.htlcOutputs.map(htlcOutpoint => {
@@ -138,14 +139,15 @@ object CheckBalance {
138
139
val htlcDelayedPending = ! rvk.irrevocablySpent.contains(delayedHtlcOutpoint)
139
140
// Note that if the HTLC output was spent by us, it should appear in our on-chain balance, so we don't
140
141
// count it here.
141
- if (htlcSpentByThem && htlcDelayedPending) htlcAmount else 0 sat
142
+ if (htlcSpentByThem && htlcDelayedPending && ! recentlySpentInputs.contains(delayedHtlcOutpoint) ) htlcAmount else 0 sat
142
143
case None =>
143
144
// This should never happen unless our data is corrupted.
144
145
0 sat
145
146
}
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
149
151
}
150
152
}).sum
151
153
MainAndHtlcBalance (toLocal = toLocal + additionalToLocal + additionalToRemote, htlcs = htlcs + additionalHtlcs)
@@ -164,14 +166,19 @@ object CheckBalance {
164
166
waitForPublishFutureCommitment : Btc = 0 .sat) {
165
167
val total : Btc = waitForFundingConfirmed + waitForChannelReady + normal.total + shutdown.total + negotiating.total + closing.total + waitForPublishFutureCommitment
166
168
167
- def addChannelBalance (channel : PersistentChannelData ): OffChainBalance = channel match {
169
+ def addChannelBalance (channel : PersistentChannelData , recentlySpentInputs : Set [ OutPoint ] ): OffChainBalance = channel match {
168
170
case d : DATA_WAIT_FOR_FUNDING_CONFIRMED => this .copy(waitForFundingConfirmed = this .waitForFundingConfirmed + mainBalance(d.commitments.latest.localCommit))
169
171
case d : DATA_WAIT_FOR_CHANNEL_READY => this .copy(waitForChannelReady = this .waitForChannelReady + mainBalance(d.commitments.latest.localCommit))
170
172
case _ : DATA_WAIT_FOR_DUAL_FUNDING_SIGNED => this // we ignore our balance from unsigned commitments
171
173
case d : DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED => this .copy(waitForFundingConfirmed = this .waitForFundingConfirmed + mainBalance(d.commitments.latest.localCommit))
172
174
case d : DATA_WAIT_FOR_DUAL_FUNDING_READY => this .copy(waitForChannelReady = this .waitForChannelReady + mainBalance(d.commitments.latest.localCommit))
173
175
case d : DATA_NORMAL => this .copy(normal = this .normal.addChannelBalance(d.commitments))
174
176
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.
175
182
case d : DATA_NEGOTIATING => this .copy(negotiating = this .negotiating.addChannelBalance(d.commitments))
176
183
case d : DATA_NEGOTIATING_SIMPLE => this .copy(negotiating = this .negotiating.addChannelBalance(d.commitments))
177
184
case d : DATA_CLOSING =>
@@ -180,10 +187,10 @@ object CheckBalance {
180
187
// We can ignore it as our channel balance should appear in our on-chain balance.
181
188
case Some (_ : MutualClose ) => this
182
189
// 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 ))
187
194
// In the recovery case, we can only claim our main output, HTLC outputs are lost.
188
195
// Once our main transaction confirms, the channel will transition to the CLOSED state and our channel funds
189
196
// will appear in our on-chain balance (minus on-chain fees).
@@ -210,11 +217,11 @@ object CheckBalance {
210
217
* take on-chain fees into account. Once closing transactions confirm, we ignore the corresponding channel amounts,
211
218
* the final amounts are included in our on-chain balance, which takes into account the on-chain fees paid.
212
219
*/
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 ) }
215
222
}
216
223
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 ] ) {
218
225
val totalDeeplyConfirmed : Btc = deeplyConfirmed.values.map(_.toSatoshi).sum
219
226
val totalRecentlyConfirmed : Btc = recentlyConfirmed.values.map(_.toSatoshi).sum
220
227
val totalUnconfirmed : Btc = unconfirmed.values.map(_.toSatoshi).sum
@@ -229,26 +236,61 @@ object CheckBalance {
229
236
* Note that this may create temporary glitches when doing 0-conf splices, which will appear in the off-chain balance
230
237
* immediately but will only be correctly accounted for in our on-chain balance after being deeply confirmed. Those
231
238
* 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.
235
239
*/
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 {
237
241
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)) {
239
245
case (total, utxo) if utxo.confirmations == 0 => total.copy(unconfirmed = total.unconfirmed + (utxo.outPoint -> utxo.amount))
240
246
case (total, utxo) if utxo.confirmations < minDepth => total.copy(recentlyConfirmed = total.recentlyConfirmed + (utxo.outPoint -> utxo.amount))
241
247
case (total, utxo) => total.copy(deeplyConfirmed = total.deeplyConfirmed + (utxo.outPoint -> utxo.amount))
242
248
}
243
249
} yield detailed
244
250
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
+
245
287
case class GlobalBalance (onChain : DetailedOnChainBalance , offChain : OffChainBalance , channels : Map [ByteVector32 , PersistentChannelData ]) {
246
288
val total : Btc = onChain.total + offChain.total
247
289
}
248
290
249
291
def computeGlobalBalance (channels : Map [ByteVector32 , PersistentChannelData ], bitcoinClient : BitcoinCoreClient , minDepth : Int )(implicit ec : ExecutionContext ): Future [GlobalBalance ] = for {
250
292
onChain <- CheckBalance .computeOnChainBalance(bitcoinClient, minDepth)
251
- offChain = CheckBalance .computeOffChainBalance(channels.values)
293
+ offChain = CheckBalance .computeOffChainBalance(channels.values, onChain.recentlySpentInputs )
252
294
} yield GlobalBalance (onChain, offChain, channels)
253
295
254
296
}
0 commit comments