Skip to content

Commit 524ab2e

Browse files
authored
Missing transactions when closing channels (#232)
1 parent 4203202 commit 524ab2e

File tree

13 files changed

+497
-113
lines changed

13 files changed

+497
-113
lines changed

eclair-kmp-test-fixtures/src/commonMain/kotlin/fr/acinq/eclair/tests/TestConstants.kt

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
package fr.acinq.eclair.tests
22

3-
import fr.acinq.bitcoin.Block
4-
import fr.acinq.bitcoin.ByteVector
5-
import fr.acinq.bitcoin.ByteVector32
6-
import fr.acinq.bitcoin.Script
3+
import fr.acinq.bitcoin.*
74
import fr.acinq.eclair.*
85
import fr.acinq.eclair.Eclair.randomKey
96
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
@@ -92,9 +89,10 @@ object TestConstants {
9289
enableTrampolinePayment = true
9390
)
9491

92+
val closingPubKeyInfo = keyManager.closingPubkeyScript(PublicKey.Generator)
9593
val channelParams: LocalParams = PeerChannels.makeChannelParams(
9694
nodeParams,
97-
ByteVector(Script.write(Script.pay2wpkh(randomKey().publicKey()))),
95+
defaultFinalScriptPubkey = ByteVector(closingPubKeyInfo.second),
9896
isFunder = true,
9997
fundingAmount
10098
).copy(channelReserve = 10_000.sat) // Bob will need to keep that much satoshis as direct payment
@@ -159,9 +157,10 @@ object TestConstants {
159157
enableTrampolinePayment = true
160158
)
161159

160+
val closingPubKeyInfo = keyManager.closingPubkeyScript(PublicKey.Generator)
162161
val channelParams: LocalParams = PeerChannels.makeChannelParams(
163162
nodeParams,
164-
ByteVector(Script.write(Script.pay2wpkh(randomKey().publicKey()))),
163+
defaultFinalScriptPubkey = ByteVector(closingPubKeyInfo.second),
165164
isFunder = false,
166165
fundingAmount
167166
).copy(channelReserve = 20_000.sat) // Alice will need to keep that much satoshis as direct payment

src/commonMain/kotlin/fr/acinq/eclair/channel/Channel.kt

Lines changed: 129 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ import fr.acinq.eclair.channel.Channel.ANNOUNCEMENTS_MINCONF
99
import fr.acinq.eclair.channel.Channel.FUNDING_TIMEOUT_FUNDEE_BLOCK
1010
import fr.acinq.eclair.channel.Channel.MAX_NEGOTIATION_ITERATIONS
1111
import fr.acinq.eclair.channel.Channel.handleSync
12+
import fr.acinq.eclair.channel.Helpers.Closing.btcAddressFromScriptPubKey
1213
import fr.acinq.eclair.channel.Helpers.Closing.extractPreimages
1314
import fr.acinq.eclair.channel.Helpers.Closing.onChainOutgoingHtlcs
1415
import fr.acinq.eclair.channel.Helpers.Closing.overriddenOutgoingHtlcs
1516
import fr.acinq.eclair.channel.Helpers.Closing.timedOutHtlcs
1617
import fr.acinq.eclair.crypto.KeyManager
1718
import fr.acinq.eclair.crypto.ShaChain
19+
import fr.acinq.eclair.db.OutgoingPayment.Status.Completed.Succeeded.OnChain.ChannelClosingType
1820
import fr.acinq.eclair.router.Announcements
1921
import fr.acinq.eclair.serialization.Serialization
2022
import fr.acinq.eclair.transactions.CommitmentSpec
@@ -94,7 +96,8 @@ sealed class ChannelAction {
9496
data class StoreHtlcInfos(val htlcs: List<HtlcInfo>) : Storage()
9597
data class GetHtlcInfos(val revokedCommitTxId: ByteVector32, val commitmentNumber: Long) : Storage()
9698
data class StoreIncomingAmount(val amount: MilliSatoshi, val origin: ChannelOrigin?) : Storage()
97-
data class StoreOutgoingAmount(val amount: MilliSatoshi) : Storage()
99+
data class StoreChannelClosing(val amount: MilliSatoshi, val closingAddress: String, val isSentToDefaultAddress: Boolean) : Storage()
100+
data class StoreChannelClosed(val txids: List<ByteVector32>, val claimed: Satoshi, val type: ChannelClosingType) : Storage()
98101
}
99102

100103
data class ProcessIncomingHtlc(val add: UpdateAddHtlc) : ChannelAction()
@@ -164,7 +167,41 @@ sealed class ChannelState {
164167
this is WaitForInit && newState is Closing -> actions
165168
this is Closing && newState is Closing -> actions
166169
this is ChannelStateWithCommitments && newState is Closing -> {
167-
actions + ChannelAction.Storage.StoreOutgoingAmount(this.commitments.localCommit.spec.toLocal)
170+
val channelBalance = commitments.localCommit.spec.toLocal
171+
if (channelBalance > 0.msat) {
172+
val defaultScriptPubKey = commitments.localParams.defaultFinalScriptPubKey
173+
val localShutdown = when (this) {
174+
is Normal -> this.localShutdown
175+
is Negotiating -> this.localShutdown
176+
is ShuttingDown -> this.localShutdown
177+
else -> null
178+
}
179+
if (localShutdown != null && localShutdown.scriptPubKey != defaultScriptPubKey) {
180+
// Non-default output address
181+
val btcAddr = Helpers.Closing.btcAddressFromScriptPubKey(
182+
scriptPubKey = localShutdown.scriptPubKey,
183+
chainHash = staticParams.nodeParams.chainHash
184+
) ?: "unknown"
185+
actions + ChannelAction.Storage.StoreChannelClosing(
186+
amount = channelBalance,
187+
closingAddress = btcAddr,
188+
isSentToDefaultAddress = false
189+
)
190+
} else {
191+
// Default output address
192+
val btcAddr = Helpers.Closing.btcAddressFromScriptPubKey(
193+
scriptPubKey = defaultScriptPubKey,
194+
chainHash = staticParams.nodeParams.chainHash
195+
) ?: "unknown"
196+
actions + ChannelAction.Storage.StoreChannelClosing(
197+
amount = channelBalance,
198+
closingAddress = btcAddr,
199+
isSentToDefaultAddress = true
200+
)
201+
}
202+
} else /* channelBalance <= 0.msat */ {
203+
actions
204+
}
168205
}
169206
else -> actions
170207
}
@@ -723,10 +760,10 @@ data class Offline(val state: ChannelStateWithCommitments) : ChannelState() {
723760
currentTip,
724761
currentOnChainFeerates,
725762
state.commitments,
726-
null,
727-
currentBlockHeight.toLong(),
728-
state.closingTxProposed.flatten().map { it.unsignedTx },
729-
listOf(closingTx)
763+
fundingTx = null,
764+
waitingSinceBlock = currentBlockHeight.toLong(),
765+
mutualCloseProposed = state.closingTxProposed.flatten().map { it.unsignedTx },
766+
mutualClosePublished = listOf(closingTx)
730767
)
731768
val actions = listOf(
732769
ChannelAction.Storage.StoreState(nextState),
@@ -994,10 +1031,10 @@ data class Syncing(val state: ChannelStateWithCommitments, val waitForTheirReest
9941031
currentTip,
9951032
currentOnChainFeerates,
9961033
state.commitments,
997-
null,
998-
currentBlockHeight.toLong(),
999-
state.closingTxProposed.flatten().map { it.unsignedTx },
1000-
listOf(closingTx)
1034+
fundingTx = null,
1035+
waitingSinceBlock = currentBlockHeight.toLong(),
1036+
mutualCloseProposed = state.closingTxProposed.flatten().map { it.unsignedTx },
1037+
mutualClosePublished = listOf(closingTx)
10011038
)
10021039
val actions = listOf(
10031040
ChannelAction.Storage.StoreState(nextState),
@@ -2331,10 +2368,10 @@ data class Negotiating(
23312368
currentTip,
23322369
currentOnChainFeerates,
23332370
commitments,
2334-
null,
2335-
currentBlockHeight.toLong(),
2336-
this.closingTxProposed.flatten().map { it.unsignedTx },
2337-
listOf(signedClosingTx)
2371+
fundingTx = null,
2372+
waitingSinceBlock = currentBlockHeight.toLong(),
2373+
mutualCloseProposed = this.closingTxProposed.flatten().map { it.unsignedTx },
2374+
mutualClosePublished = listOf(signedClosingTx)
23382375
)
23392376
val actions = listOf(
23402377
ChannelAction.Storage.StoreState(nextState),
@@ -2352,10 +2389,10 @@ data class Negotiating(
23522389
currentTip,
23532390
currentOnChainFeerates,
23542391
commitments,
2355-
null,
2356-
currentBlockHeight.toLong(),
2357-
this.closingTxProposed.flatten().map { it.unsignedTx } + listOf(signedClosingTx),
2358-
listOf(signedClosingTx)
2392+
fundingTx = null,
2393+
waitingSinceBlock = currentBlockHeight.toLong(),
2394+
mutualCloseProposed = this.closingTxProposed.flatten().map { it.unsignedTx } + listOf(signedClosingTx),
2395+
mutualClosePublished = listOf(signedClosingTx)
23592396
)
23602397
val actions = listOf(
23612398
ChannelAction.Storage.StoreState(nextState),
@@ -2399,10 +2436,10 @@ data class Negotiating(
23992436
currentTip,
24002437
currentOnChainFeerates,
24012438
commitments,
2402-
null,
2403-
currentBlockHeight.toLong(),
2404-
this.closingTxProposed.flatten().map { it.unsignedTx },
2405-
listOf(closingTx)
2439+
fundingTx = null,
2440+
waitingSinceBlock = currentBlockHeight.toLong(),
2441+
mutualCloseProposed = this.closingTxProposed.flatten().map { it.unsignedTx },
2442+
mutualClosePublished = listOf(closingTx)
24062443
)
24072444
val actions = listOf(
24082445
ChannelAction.Storage.StoreState(nextState),
@@ -2446,10 +2483,10 @@ data class Negotiating(
24462483
currentTip,
24472484
currentOnChainFeerates,
24482485
commitments,
2449-
null,
2450-
currentBlockHeight.toLong(),
2451-
this.closingTxProposed.flatten().map { it.unsignedTx } + listOf(bestUnpublishedClosingTx),
2452-
listOf(bestUnpublishedClosingTx)
2486+
fundingTx = null,
2487+
waitingSinceBlock = currentBlockHeight.toLong(),
2488+
mutualCloseProposed = this.closingTxProposed.flatten().map { it.unsignedTx } + listOf(bestUnpublishedClosingTx),
2489+
mutualClosePublished = listOf(bestUnpublishedClosingTx)
24532490
)
24542491
val actions = listOf(
24552492
ChannelAction.Storage.StoreState(nextState),
@@ -2590,7 +2627,11 @@ data class Closing(
25902627
futureRemoteCommitPublished = this.futureRemoteCommitPublished?.update(watch.tx),
25912628
revokedCommitPublished = this.revokedCommitPublished.map { it.update(watch.tx) }
25922629
)
2593-
closing1.networkFeePaid(watch.tx)?.let { logger.info { "c:$channelId paid fee=${it.first} for txid=${watch.tx.txid} desc=${it.second}" } }
2630+
closing1.networkFeePaid(watch.tx)?.let {
2631+
logger.info { "c:$channelId paid fee=${it.first} for txid=${watch.tx.txid} desc=${it.second}" }
2632+
} ?: run {
2633+
logger.info { "c:$channelId paid UNKNOWN fee for txid=${watch.tx.txid}" }
2634+
}
25942635

25952636
// we may need to fail some htlcs in case a commitment tx was published and they have reached the timeout threshold
25962637
val htlcSettledActions = mutableListOf<ChannelAction>()
@@ -2631,19 +2672,20 @@ data class Closing(
26312672
commitments.payments[add.id]?.let { paymentId -> logger.info { "c:$channelId paymentId=$paymentId will settle on-chain (htlc #${add.id} sending ${add.amountMsat})" } }
26322673
}
26332674

2634-
val nextState = when (val closingType = closing1.isClosed(watch.tx)) {
2635-
null -> closing1
2675+
val (nextState, closedActions) = when (val closingType = closing1.isClosed(watch.tx)) {
2676+
null -> Pair(closing1, listOf())
26362677
else -> {
26372678
logger.info { "c:$channelId channel is now closed" }
26382679
if (closingType !is MutualClose) {
26392680
logger.debug { "c:$channelId last known remoteChannelData=${commitments.remoteChannelData}" }
26402681
}
2641-
Closed(closing1)
2682+
Pair(Closed(closing1), listOf(closing1.storeChannelClosed(watch.tx)))
26422683
}
26432684
}
26442685
val actions = buildList {
26452686
add(ChannelAction.Storage.StoreState(nextState))
26462687
addAll(htlcSettledActions)
2688+
addAll(closedActions)
26472689
}
26482690
Pair(nextState, actions)
26492691
}
@@ -2846,6 +2888,63 @@ data class Closing(
28462888
parentTxOut?.let { txOut -> txOut.amount - tx.txOut.map { it.amount }.sum() }?.let { it to desc }
28472889
}
28482890
}
2891+
2892+
private fun storeChannelClosed(additionalConfirmedTx: Transaction?): ChannelAction.Storage.StoreChannelClosed {
2893+
// We want to give the user the list of btc transactions for their outputs
2894+
val txids = mutableListOf<ByteVector32>()
2895+
var claimed = 0.sat
2896+
val type = when {
2897+
mutualClosePublished.isNotEmpty() -> ChannelClosingType.Mutual
2898+
localCommitPublished != null -> ChannelClosingType.Local
2899+
remoteCommitPublished != null -> ChannelClosingType.Remote
2900+
nextRemoteCommitPublished != null -> ChannelClosingType.Remote
2901+
futureRemoteCommitPublished != null -> ChannelClosingType.Remote
2902+
revokedCommitPublished.isNotEmpty() -> ChannelClosingType.Revoked
2903+
else -> ChannelClosingType.Other
2904+
}
2905+
additionalConfirmedTx?.let { confirmedTx ->
2906+
mutualClosePublished.firstOrNull { it.tx == confirmedTx }?.let {
2907+
txids += it.tx.txid
2908+
claimed += it.toLocalOutput?.amount ?: 0.sat
2909+
}
2910+
}
2911+
localCommitPublished?.let {
2912+
val confirmedTxids = it.irrevocablySpent.values.map { it.txid }.toSet()
2913+
val allTxs = listOfNotNull(it.claimMainDelayedOutputTx?.tx) +
2914+
it.claimHtlcDelayedTxs.map { it.tx }
2915+
val confirmedTxs = allTxs.filter { confirmedTxids.contains(it.txid) }
2916+
if (confirmedTxs.isNotEmpty()) {
2917+
txids += confirmedTxs.map { it.txid }
2918+
claimed += confirmedTxs.map { it.txOut.first().amount }.sum()
2919+
}
2920+
}
2921+
listOfNotNull(
2922+
remoteCommitPublished,
2923+
nextRemoteCommitPublished,
2924+
futureRemoteCommitPublished
2925+
).forEach {
2926+
val confirmedTxids = it.irrevocablySpent.values.map { it.txid }.toSet()
2927+
val allTxs = listOfNotNull(it.claimMainOutputTx?.tx) +
2928+
it.claimHtlcTxs.mapNotNull { it.value?.tx }
2929+
val confirmedTxs = allTxs.filter { confirmedTxids.contains(it.txid) }
2930+
if (confirmedTxs.isNotEmpty()) {
2931+
txids += confirmedTxs.map { it.txid }
2932+
claimed += confirmedTxs.map { it.txOut.first().amount }.sum()
2933+
}
2934+
}
2935+
revokedCommitPublished.forEach {
2936+
val confirmedTxids = it.irrevocablySpent.values.map { it.txid }.toSet()
2937+
val allTxs = listOfNotNull(it.claimMainOutputTx?.tx, it.mainPenaltyTx?.tx) +
2938+
it.htlcPenaltyTxs.map { it.tx } +
2939+
it.claimHtlcDelayedPenaltyTxs.map { it.tx }
2940+
val confirmedTxs = allTxs.filter { confirmedTxids.contains(it.txid) }
2941+
if (confirmedTxs.isNotEmpty()) {
2942+
txids += confirmedTxs.map { it.txid }
2943+
claimed += confirmedTxs.map { it.txOut.first().amount }.sum()
2944+
}
2945+
}
2946+
return ChannelAction.Storage.StoreChannelClosed(txids, claimed, type)
2947+
}
28492948
}
28502949

28512950
/**

src/commonMain/kotlin/fr/acinq/eclair/channel/Helpers.kt

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,48 @@ object Helpers {
363363

364364
fun isValidFinalScriptPubkey(scriptPubKey: ByteVector): Boolean = isValidFinalScriptPubkey(scriptPubKey.toByteArray())
365365

366+
// To be replaced with corresponding function in bitcoin-kmp
367+
fun btcAddressFromScriptPubKey(scriptPubKey: ByteVector, chainHash: ByteVector32): String? {
368+
return runTrying {
369+
val script = Script.parse(scriptPubKey)
370+
when {
371+
Script.isPay2pkh(script) -> {
372+
// OP_DUP OP_HASH160 OP_PUSHDATA(20) OP_EQUALVERIFY OP_CHECKSIG
373+
val opPushData = script[2] as OP_PUSHDATA
374+
val prefix = when (chainHash) {
375+
Block.LivenetGenesisBlock.hash -> Base58.Prefix.PubkeyAddress
376+
Block.TestnetGenesisBlock.hash, Block.RegtestGenesisBlock.hash -> Base58.Prefix.PubkeyAddressTestnet
377+
else -> null
378+
} ?: return null
379+
Base58Check.encode(prefix, opPushData.data)
380+
}
381+
Script.isPay2sh(script) -> {
382+
// OP_HASH160 OP_PUSHDATA(20) OP_EQUAL
383+
val opPushData = script[1] as OP_PUSHDATA
384+
val prefix = when (chainHash) {
385+
Block.LivenetGenesisBlock.hash -> Base58.Prefix.ScriptAddress
386+
Block.TestnetGenesisBlock.hash, Block.RegtestGenesisBlock.hash -> Base58.Prefix.ScriptAddressTestnet
387+
else -> null
388+
} ?: return null
389+
Base58Check.encode(prefix, opPushData.data)
390+
}
391+
Script.isPay2wpkh(script) || Script.isPay2wsh(script) -> {
392+
// isPay2wpkh : OP_0 OP_PUSHDATA(20)
393+
// isPay2wsh : OP_0 OP_PUSHDATA(32)
394+
val opPushData = script[1] as OP_PUSHDATA
395+
val hrp = when (chainHash) {
396+
Block.LivenetGenesisBlock.hash -> "bc"
397+
Block.TestnetGenesisBlock.hash -> "tb"
398+
Block.RegtestGenesisBlock.hash -> "bcrt"
399+
else -> null
400+
} ?: return null
401+
Bech32.encodeWitnessAddress(hrp, 0, opPushData.data.toByteArray())
402+
}
403+
else -> null
404+
} // </when>
405+
}.getOrElse { null }
406+
}
407+
366408
fun firstClosingFee(commitments: Commitments, localScriptPubkey: ByteArray, remoteScriptPubkey: ByteArray, requestedFeerate: FeeratePerKw): Satoshi {
367409
// this is just to estimate the weight which depends on the size of the pubkey scripts
368410
val dummyClosingTx = Transactions.makeClosingTx(commitments.commitInput, localScriptPubkey, remoteScriptPubkey, commitments.localParams.isFunder, Satoshi(0), Satoshi(0), commitments.localCommit.spec)

0 commit comments

Comments
 (0)