diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt index 9b83fbf82..b552c817e 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt @@ -10,7 +10,6 @@ import fr.acinq.lightning.channel.Helpers.watchConfirmedIfNeeded import fr.acinq.lightning.channel.Helpers.watchSpentIfNeeded import fr.acinq.lightning.crypto.KeyManager import fr.acinq.lightning.logging.LoggingContext -import fr.acinq.lightning.transactions.Scripts import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.* import fr.acinq.lightning.utils.toMilliSatoshi @@ -70,7 +69,7 @@ data class LocalCommitPublished( // is the commitment tx buried? (we need to check this because we may not have any outputs) val isCommitTxConfirmed = confirmedTxs.contains(commitTx.txid) // is our main output confirmed (if we have one)? - val isMainOutputConfirmed = claimMainDelayedOutputTx?.let { irrevocablySpent.contains(it.input.outPoint) } ?: true + val isMainOutputConfirmed = claimMainDelayedOutputTx == null || irrevocablySpent.contains(claimMainDelayedOutputTx.input.outPoint) // are all htlc outputs from the commitment tx spent (we need to check them all because we may receive preimages later)? val allHtlcsSpent = (htlcTxs.keys - irrevocablySpent.keys).isEmpty() // are all outputs from htlc txs spent? @@ -86,22 +85,6 @@ data class LocalCommitPublished( return irrevocablySpent.values.any { it.txid == commitTx.txid } || irrevocablySpent.keys.any { it.txid == commitTx.txid } } - fun isHtlcTimeout(tx: Transaction): Boolean { - return tx.txIn - .filter { htlcTxs[it.outPoint] is HtlcTx.HtlcTimeoutTx } - .map { it.witness } - .mapNotNull(Scripts.extractPaymentHashFromHtlcTimeout()) - .isNotEmpty() - } - - fun isHtlcSuccess(tx: Transaction): Boolean { - return tx.txIn - .filter { htlcTxs[it.outPoint] is HtlcTx.HtlcSuccessTx } - .map { it.witness } - .mapNotNull(Scripts.extractPreimageFromHtlcSuccess()) - .isNotEmpty() - } - internal fun LoggingContext.doPublish(nodeParams: NodeParams, channelId: ByteVector32): List { val publishQueue = buildList { add(ChannelAction.Blockchain.PublishTx(commitTx, ChannelAction.Blockchain.PublishTx.Type.CommitTx)) @@ -183,7 +166,7 @@ data class RemoteCommitPublished( // is the commitment tx buried? (we need to check this because we may not have any outputs) val isCommitTxConfirmed = confirmedTxs.contains(commitTx.txid) // is our main output confirmed (if we have one)? - val isMainOutputConfirmed = claimMainOutputTx?.let { irrevocablySpent.contains(it.input.outPoint) } ?: true + val isMainOutputConfirmed = claimMainOutputTx == null || irrevocablySpent.contains(claimMainOutputTx.input.outPoint) // are all htlc outputs from the commitment tx spent (we need to check them all because we may receive preimages later)? val allHtlcsSpent = (claimHtlcTxs.keys - irrevocablySpent.keys).isEmpty() return isCommitTxConfirmed && isMainOutputConfirmed && allHtlcsSpent @@ -193,22 +176,6 @@ data class RemoteCommitPublished( return irrevocablySpent.values.any { it.txid == commitTx.txid } || irrevocablySpent.keys.any { it.txid == commitTx.txid } } - fun isClaimHtlcTimeout(tx: Transaction): Boolean { - return tx.txIn - .filter { claimHtlcTxs[it.outPoint] is ClaimHtlcTx.ClaimHtlcTimeoutTx } - .map { it.witness } - .mapNotNull(Scripts.extractPaymentHashFromClaimHtlcTimeout()) - .isNotEmpty() - } - - fun isClaimHtlcSuccess(tx: Transaction): Boolean { - return tx.txIn - .filter { claimHtlcTxs[it.outPoint] is ClaimHtlcTx.ClaimHtlcSuccessTx } - .map { it.witness } - .mapNotNull(Scripts.extractPreimageFromClaimHtlcSuccess()) - .isNotEmpty() - } - internal fun LoggingContext.doPublish(nodeParams: NodeParams, channelId: ByteVector32): List { val publishQueue = buildList { claimMainOutputTx?.let { add(ChannelAction.Blockchain.PublishTx(it)) } diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt index 494bc1d65..67b94e093 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt @@ -55,6 +55,7 @@ data class FundingTxSpent (override val channelId: Byte data class HtlcsTimedOutDownstream (override val channelId: ByteVector32, val htlcs: Set) : ChannelException(channelId, "one or more htlcs timed out downstream: ids=${htlcs.map { it.id } .joinToString(",")}") data class FulfilledHtlcsWillTimeout (override val channelId: ByteVector32, val htlcs: Set) : ChannelException(channelId, "one or more htlcs that should be fulfilled are close to timing out: ids=${htlcs.map { it.id }.joinToString()}") data class HtlcOverriddenByLocalCommit (override val channelId: ByteVector32, val htlc: UpdateAddHtlc) : ChannelException(channelId, "htlc ${htlc.id} was overridden by local commit") +data class HtlcOverriddenByRemoteCommit (override val channelId: ByteVector32, val htlc: UpdateAddHtlc) : ChannelException(channelId, "htlc ${htlc.id} was overridden by remote commit") data class FeerateTooSmall (override val channelId: ByteVector32, val remoteFeeratePerKw: FeeratePerKw) : ChannelException(channelId, "remote fee rate is too small: remoteFeeratePerKw=${remoteFeeratePerKw.toLong()}") data class FeerateTooDifferent (override val channelId: ByteVector32, val localFeeratePerKw: FeeratePerKw, val remoteFeeratePerKw: FeeratePerKw) : ChannelException(channelId, "local/remote feerates are too different: remoteFeeratePerKw=${remoteFeeratePerKw.toLong()} localFeeratePerKw=${localFeeratePerKw.toLong()}") data class InvalidCommitmentSignature (override val channelId: ByteVector32, val txId: TxId) : ChannelException(channelId, "invalid commitment signature: txId=$txId") diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt index 0439e81eb..5e3275ff6 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt @@ -2,7 +2,6 @@ package fr.acinq.lightning.channel import fr.acinq.bitcoin.* import fr.acinq.bitcoin.Crypto.ripemd160 -import fr.acinq.bitcoin.Crypto.sha256 import fr.acinq.bitcoin.Script.pay2wsh import fr.acinq.bitcoin.Script.write import fr.acinq.bitcoin.utils.Either @@ -422,7 +421,7 @@ object Helpers { } /** - * Claim all the outputs that we've received from our current commit tx. This will be done using 2nd stage HTLC transactions. + * Claim all the outputs that we can from our current commit tx. * * @param commitment our commitment data, which includes payment preimages. * @return a list of transactions (one per output that we can claim). @@ -452,24 +451,38 @@ object Helpers { Transactions.addSigs(it, sig) } - // those are the preimages to existing received htlcs - val preimages = commitment.changes.localChanges.all.filterIsInstance().map { it.paymentPreimage } - - val htlcTxs = localCommit.publishableTxs.htlcTxsAndSigs.associate { (txInfo, localSig, remoteSig) -> - when (txInfo) { - is HtlcSuccessTx -> when (val preimage = preimages.firstOrNull { r -> r.sha256() == txInfo.paymentHash }) { - // incoming htlc for which we don't have the preimage: we can't spend it immediately, but we may learn the - // preimage later, otherwise it will eventually timeout and they will get their funds back - null -> Pair(txInfo.input.outPoint, null) - // incoming htlc for which we have the preimage: we can spend it directly - else -> Pair(txInfo.input.outPoint, Transactions.addSigs(txInfo, localSig, remoteSig, preimage)) + // We collect all the preimages we wanted to reveal to our peer. + val hash2Preimage = commitment.changes.localChanges.all.filterIsInstance().map { it.paymentPreimage }.associate { r -> r.sha256() to r } + // We collect incoming HTLCs that we started failing but didn't cross-sign. + val failedIncomingHtlcs = commitment.changes.localChanges.all.mapNotNull { + when (it) { + is UpdateFailHtlc -> it.id + is UpdateFailMalformedHtlc -> it.id + else -> null + } + }.toSet() + + val htlcTxs = localCommit.publishableTxs.htlcTxsAndSigs.mapNotNull { + when (it.txinfo) { + is HtlcSuccessTx -> when { + // We immediately spend incoming htlcs for which we have the preimage. + hash2Preimage.containsKey(it.txinfo.paymentHash) -> Pair(it.txinfo.input.outPoint, Transactions.addSigs(it.txinfo, it.localSig, it.remoteSig, hash2Preimage[it.txinfo.paymentHash]!!)) + // We can ignore incoming htlcs that we started failing: our peer will claim them after the timeout. + // We don't track those outputs because we want to forget the channel even if our peer never claims them. + failedIncomingHtlcs.contains(it.txinfo.htlcId) -> null + // For all other incoming htlcs, we may reveal the preimage later if it matches one of our unpaid invoices. + // We thus want to track the corresponding outputs to ensure we don't forget the channel until they've been spent, + // either by us if we accept the payment, or by our peer after the timeout. + else -> Pair(it.txinfo.input.outPoint, null) } - // outgoing htlc: they may or may not have the preimage, the only thing to do is try to get back our funds after timeout - is HtlcTimeoutTx -> Pair(txInfo.input.outPoint, Transactions.addSigs(txInfo, localSig, remoteSig)) + // We track all outputs that belong to outgoing htlcs. Our peer may or may not have the preimage: if they + // claim the output, we will learn the preimage from their transaction, otherwise we will get our funds + // back after the timeout. + is HtlcTimeoutTx -> Pair(it.txinfo.input.outPoint, Transactions.addSigs(it.txinfo, it.localSig, it.remoteSig)) } - } + }.toMap() - // all htlc output to us are delayed, so we need to claim them as soon as the delay is over + // All htlc output to us are delayed, so we need to claim them as soon as the delay is over. val htlcDelayedTxs = htlcTxs.values.filterNotNull().mapNotNull { txInfo -> generateTx("claim-htlc-delayed") { Transactions.makeClaimLocalDelayedOutputTx( @@ -498,7 +511,7 @@ object Helpers { } /** - * Claim all the outputs that we've received from their current commit tx. + * Claim all the outputs that we can from their current commit tx. * * @param commitment our commitment data, which includes payment preimages. * @param remoteCommit the remote commitment data to use to claim outputs (it can be their current or next commitment). @@ -541,13 +554,21 @@ object Helpers { remoteCommit.spec ) - // we need to use a rather high fee for htlc-claim because we compete with the counterparty + // We need to use a rather high fee for htlc-claim because we compete with the counterparty. val feerateClaimHtlc = feerates.fastFeerate - // those are the preimages to existing received htlcs - val preimages = commitment.changes.localChanges.all.filterIsInstance().map { it.paymentPreimage } + // We collect all the preimages we wanted to reveal to our peer. + val hash2Preimage = commitment.changes.localChanges.all.filterIsInstance().map { it.paymentPreimage }.associate { r -> r.sha256() to r } + // We collect incoming HTLCs that we started failing but didn't cross-sign. + val failedIncomingHtlcs = commitment.changes.localChanges.all.mapNotNull { + when (it) { + is UpdateFailHtlc -> it.id + is UpdateFailMalformedHtlc -> it.id + else -> null + } + }.toSet() - // remember we are looking at the remote commitment so IN for them is really OUT for us and vice versa + // Remember we are looking at the remote commitment so IN for them is really OUT for us and vice versa. val claimHtlcTxs = remoteCommit.spec.htlcs.mapNotNull { htlc -> when (htlc) { is OutgoingHtlc -> { @@ -564,20 +585,26 @@ object Helpers { feerateClaimHtlc ) }?.let { claimHtlcTx -> - when (val preimage = preimages.firstOrNull { r -> r.sha256() == htlc.add.paymentHash }) { - // incoming htlc for which we don't have the preimage: we can't spend it immediately, but we may learn the - // preimage later, otherwise it will eventually timeout and they will get their funds back - null -> Pair(claimHtlcTx.input.outPoint, null) - // incoming htlc for which we have the preimage: we can spend it directly - else -> { + when { + // We immediately spend incoming htlcs for which we have the preimage. + hash2Preimage.containsKey(htlc.add.paymentHash) -> { val sig = Transactions.sign(claimHtlcTx, channelKeys.htlcKey.deriveForCommitment(remoteCommit.remotePerCommitmentPoint), SigHash.SIGHASH_ALL) - Pair(claimHtlcTx.input.outPoint, Transactions.addSigs(claimHtlcTx, sig, preimage)) + Pair(claimHtlcTx.input.outPoint, Transactions.addSigs(claimHtlcTx, sig, hash2Preimage[htlc.add.paymentHash]!!)) } + // We can ignore incoming htlcs that we started failing: our peer will claim them after the timeout. + // We don't track those outputs because we want to forget the channel even if our peer never claims them. + failedIncomingHtlcs.contains(htlc.add.id) -> null + // For all other incoming htlcs, we may reveal the preimage later if it matches one of our unpaid invoices. + // We thus want to track the corresponding outputs to ensure we don't forget the channel until they've been spent, + // either by us if we accept the payment, or by our peer after the timeout. + else -> Pair(claimHtlcTx.input.outPoint, null) } } } is IncomingHtlc -> { - // outgoing htlc: they may or may not have the preimage, the only thing to do is try to get back our funds after timeout + // We track all outputs that belong to outgoing htlcs. Our peer may or may not have the preimage: if they + // claim the output, we will learn the preimage from their transaction, otherwise we will get our funds + // back after the timeout. generateTx("claim-htlc-timeout") { Transactions.makeClaimHtlcTimeoutTx( remoteCommitTx.tx, @@ -598,7 +625,7 @@ object Helpers { } }.toMap() - // we claim our output and add the htlc txs we just created + // We claim our output and add the htlc txs we just created. return claimRemoteCommitMainOutput(channelKeys, commitment.params, tx, feerates.claimMainFeerate).copy(claimHtlcTxs = claimHtlcTxs) } @@ -768,22 +795,18 @@ object Helpers { htlcTx: Transaction, feerates: OnChainFeerates ): Pair> { - val claimTxs = buildList { - revokedCommitPublished.claimMainOutputTx?.let { add(it) } - revokedCommitPublished.mainPenaltyTx?.let { add(it) } - addAll(revokedCommitPublished.htlcPenaltyTxs) - } - val isHtlcTx = htlcTx.txIn.any { it.outPoint.txid == revokedCommitPublished.commitTx.txid } && !claimTxs.any { it.tx.txid == htlcTx.txid } - if (isHtlcTx) { - logger.info { "looks like txid=${htlcTx.txid} could be a 2nd level htlc tx spending revoked commit txid=${revokedCommitPublished.commitTx.txid}" } - // Let's assume that htlcTx is an HtlcSuccessTx or HtlcTimeoutTx and try to generate a tx spending its output using a revocation key + // We published HTLC-penalty transactions for every HTLC output: this transaction may be ours, or it may be one + // of their HTLC transactions that confirmed before our HTLC-penalty transaction. If it is spending an HTLC + // output, we assume that it's an HTLC transaction published by our peer and try to create penalty transactions + // that spend it, which will automatically be skipped if this was instead one of our HTLC-penalty transactions. + val htlcOutputs = revokedCommitPublished.htlcPenaltyTxs.map { it.input.outPoint }.toSet() + val spendsHtlcOutput = htlcTx.txIn.any { htlcOutputs.contains(it.outPoint) } + if (spendsHtlcOutput) { val remotePerCommitmentPoint = revokedCommitPublished.remotePerCommitmentSecret.publicKey() val remoteDelayedPaymentPubkey = params.remoteParams.delayedPaymentBasepoint.deriveForCommitment(remotePerCommitmentPoint) val remoteRevocationPubkey = channelKeys.revocationBasepoint.deriveForRevocation(remotePerCommitmentPoint) - // we need to use a high fee here for punishment txs because after a delay they can be spent by the counterparty val feeratePenalty = feerates.fastFeerate - val penaltyTxs = Transactions.makeClaimDelayedOutputPenaltyTxs( htlcTx, params.localParams.dustLimit, @@ -800,12 +823,14 @@ object Helpers { val signedTx = Transactions.addSigs(it, sig) // we need to make sure that the tx is indeed valid when (runTrying { signedTx.tx.correctlySpends(listOf(htlcTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) }) { - is Try.Success -> signedTx + is Try.Success -> { + logger.info { "txId=${htlcTx.txid} is a 2nd level htlc tx spending revoked commit txId=${revokedCommitPublished.commitTx.txid}: publishing htlc-penalty txId=${signedTx.tx.txid}" } + signedTx + } is Try.Failure -> null } } } - return revokedCommitPublished.copy(claimHtlcDelayedPenaltyTxs = revokedCommitPublished.claimHtlcDelayedPenaltyTxs + penaltyTxs) to penaltyTxs } else { return revokedCommitPublished to listOf() @@ -813,101 +838,128 @@ object Helpers { } /** - * In CLOSING state, any time we see a new transaction, we try to extract a preimage from it in order to fulfill the - * corresponding incoming htlc in an upstream channel. + * In CLOSING state, any time we see a new transaction, we try to extract a preimage from it in order to mark + * the corresponding outgoing htlc as sent. * - * Not doing that would result in us losing money, because the downstream node would pull money from one side, and - * the upstream node would get refunded after a timeout. + * If we didn't do that, some of our outgoing payments would appear as failed whereas the recipient has + * revealed the preimage and our peer has claimed our htlcs, which would be misleading. We also would + * not have a proof of payment to show to the recipient if they were malicious. * * @return a set of pairs (add, preimage) if extraction was successful: - * - add is the htlc in the downstream channel from which we extracted the preimage - * - preimage needs to be sent to the upstream channel + * - add is the htlc from which we extracted the preimage + * - preimage needs to be sent to the outgoing payment handler */ - fun LoggingContext.extractPreimages(localCommit: LocalCommit, tx: Transaction): Set> { - val htlcSuccess = tx.txIn.map { it.witness }.mapNotNull(Scripts.extractPreimageFromHtlcSuccess()) - .onEach { logger.info { "extracted paymentPreimage=$it from tx=$tx (htlc-success)" } } - val claimHtlcSuccess = tx.txIn.map { it.witness }.mapNotNull(Scripts.extractPreimageFromClaimHtlcSuccess()) - .onEach { logger.info { "extracted paymentPreimage=$it from tx=$tx (claim-htlc-success)" } } - val paymentPreimages = (htlcSuccess + claimHtlcSuccess).toSet() - + fun LoggingContext.extractPreimages(commitment: FullCommitment, tx: Transaction): Set> { + val htlcSuccess = Scripts.extractPreimagesFromHtlcSuccess(tx) + htlcSuccess.forEach { logger.info { "extracted paymentPreimage=$it from tx=$tx (htlc-success)" } } + val claimHtlcSuccess = Scripts.extractPreimagesFromClaimHtlcSuccess(tx) + claimHtlcSuccess.forEach { logger.info { "extracted paymentPreimage=$it from tx=$tx (claim-htlc-success)" } } + val paymentPreimages = htlcSuccess + claimHtlcSuccess return paymentPreimages.flatMap { paymentPreimage -> - // we only consider htlcs in our local commitment, because we only care about outgoing htlcs, which disappear first in the remote commitment - // if an outgoing htlc is in the remote commitment, then: - // - either it is in the local commitment (it was never fulfilled) - // - or we have already received the fulfill and forwarded it upstream - localCommit.spec.htlcs.filter { it is OutgoingHtlc && it.add.paymentHash.contentEquals(sha256(paymentPreimage)) }.map { it.add to paymentPreimage } + val paymentHash = paymentPreimage.sha256() + // We only care about outgoing HTLCs when we're trying to learn a preimage. + // Note that we may have already relayed the fulfill to the payment handler if we already saw the preimage. + val fromLocal = commitment.localCommit.spec.htlcs + .filter { it is OutgoingHtlc && it.add.paymentHash == paymentHash } + .map { it.add to paymentPreimage } + // From the remote point of view, those are incoming HTLCs. + val fromRemote = commitment.remoteCommit.spec.htlcs + .filter { it is IncomingHtlc && it.add.paymentHash == paymentHash } + .map { it.add to paymentPreimage } + val fromNextRemote = commitment.nextRemoteCommit?.commit?.spec?.htlcs.orEmpty() + .filter { it is IncomingHtlc && it.add.paymentHash == paymentHash } + .map { it.add to paymentPreimage } + fromLocal + fromRemote + fromNextRemote }.toSet() } /** * In CLOSING state, when we are notified that a transaction has been confirmed, we analyze it to find out if one or - * more htlcs have timed out and need to be failed in an upstream channel. + * more htlcs have timed out and need to be considered failed. Trimmed htlcs can be failed as soon as the commitment + * tx has been confirmed. * * @param tx a tx that has reached min_depth - * @return a set of htlcs that need to be failed upstream + * @return a set of outgoing htlcs that can be considered failed */ - fun LoggingContext.timedOutHtlcs(localCommit: LocalCommit, localCommitPublished: LocalCommitPublished, localDustLimit: Satoshi, tx: Transaction): Set { + fun LoggingContext.trimmedOrTimedOutHtlcs(localCommit: LocalCommit, localCommitPublished: LocalCommitPublished, localDustLimit: Satoshi, tx: Transaction): Set { val untrimmedHtlcs = Transactions.trimOfferedHtlcs(localDustLimit, localCommit.spec).map { it.add } return when { tx.txid == localCommit.publishableTxs.commitTx.tx.txid -> { - // the tx is a commitment tx, we can immediately fail all dust htlcs (they don't have an output in the tx) + // The commitment tx is confirmed: we can immediately fail all dust htlcs (they don't have an output in the tx). (localCommit.spec.htlcs.outgoings() - untrimmedHtlcs.toSet()).toSet() } - localCommitPublished.isHtlcTimeout(tx) -> { - // maybe this is a timeout tx, in that case we can resolve and fail the corresponding htlc + else -> { + // Maybe this is a timeout tx: in that case we can resolve and fail the corresponding htlc. tx.txIn.mapNotNull { txIn -> when (val htlcTx = localCommitPublished.htlcTxs[txIn.outPoint]) { - is HtlcTimeoutTx -> when (val htlc = untrimmedHtlcs.find { it.id == htlcTx.htlcId }) { - null -> { - logger.error { "could not find htlc #${htlcTx.htlcId} for htlc-timeout tx=$tx" } - null - } - else -> { - logger.info { "htlc-timeout tx for htlc #${htlc.id} paymentHash=${htlc.paymentHash} expiry=${tx.lockTime} has been confirmed (tx=$tx)" } - htlc + is HtlcTimeoutTx -> { + val htlc = untrimmedHtlcs.find { it.id == htlcTx.htlcId } + when { + // This may also be our peer claiming the HTLC by revealing the preimage: in that case we have already + // extracted the preimage with [extractPreimages] and relayed it to the payment handler. + Scripts.extractPreimagesFromClaimHtlcSuccess(tx).isNotEmpty() -> { + logger.info { "htlc-timeout double-spent by claim-htlc-success txId=${tx.txid} (tx=$tx)" } + null + } + htlc != null -> { + logger.info { "htlc-timeout tx for htlc #${htlc.id} paymentHash=${htlc.paymentHash} expiry=${tx.lockTime} has been confirmed (tx=$tx)" } + htlc + } + else -> { + logger.error { "could not find htlc #${htlcTx.htlcId} for htlc-timeout tx=$tx" } + null + } } } else -> null } }.toSet() } - else -> emptySet() } } /** * In CLOSING state, when we are notified that a transaction has been confirmed, we analyze it to find out if one or - * more htlcs have timed out and need to be failed in an upstream channel. + * more htlcs have timed out and need to be considered failed. Trimmed htlcs can be failed as soon as the commitment + * tx has been confirmed. * * @param tx a tx that has reached min_depth * @return a set of htlcs that need to be failed upstream */ - fun LoggingContext.timedOutHtlcs(remoteCommit: RemoteCommit, remoteCommitPublished: RemoteCommitPublished, remoteDustLimit: Satoshi, tx: Transaction): Set { + fun LoggingContext.trimmedOrTimedOutHtlcs(remoteCommit: RemoteCommit, remoteCommitPublished: RemoteCommitPublished, remoteDustLimit: Satoshi, tx: Transaction): Set { val untrimmedHtlcs = Transactions.trimReceivedHtlcs(remoteDustLimit, remoteCommit.spec).map { it.add } return when { tx.txid == remoteCommit.txid -> { - // the tx is a commitment tx, we can immediately fail all dust htlcs (they don't have an output in the tx) + // The commitment tx is confirmed: we can immediately fail all dust htlcs (they don't have an output in the tx). (remoteCommit.spec.htlcs.incomings() - untrimmedHtlcs.toSet()).toSet() } - remoteCommitPublished.isClaimHtlcTimeout(tx) -> { - // maybe this is a timeout tx, in that case we can resolve and fail the corresponding htlc + else -> { + // Maybe this is a timeout tx: in that case we can resolve and fail the corresponding htlc. tx.txIn.mapNotNull { txIn -> when (val htlcTx = remoteCommitPublished.claimHtlcTxs[txIn.outPoint]) { - is ClaimHtlcTimeoutTx -> when (val htlc = untrimmedHtlcs.find { it.id == htlcTx.htlcId }) { - null -> { - logger.error { "could not find htlc #${htlcTx.htlcId} for claim-htlc-timeout tx=$tx" } - null - } - else -> { - logger.info { "claim-htlc-timeout tx for htlc #${htlc.id} paymentHash=${htlc.paymentHash} expiry=${tx.lockTime} has been confirmed (tx=$tx)" } - htlc + is ClaimHtlcTimeoutTx -> { + val htlc = untrimmedHtlcs.find { it.id == htlcTx.htlcId } + when { + // This may also be our peer claiming the HTLC by revealing the preimage: in that case we have already + // extracted the preimage with [extractPreimages] and relayed it upstream. + Scripts.extractPreimagesFromHtlcSuccess(tx).isNotEmpty() -> { + logger.info { "claim-htlc-timeout double-spent by htlc-success txId=${tx.txid} (tx=$tx)" } + null + } + htlc != null -> { + logger.info { "claim-htlc-timeout tx for htlc #${htlc.id} paymentHash=${htlc.paymentHash} expiry=${tx.lockTime} has been confirmed (tx=$tx)" } + htlc + } + else -> { + logger.error { "could not find htlc #${htlcTx.htlcId} for claim-htlc-timeout tx=$tx" } + null + } } } else -> null } }.toSet() } - else -> emptySet() } } @@ -926,28 +978,27 @@ object Helpers { /** * If a commitment tx reaches min_depth, we need to fail the outgoing htlcs that will never reach the blockchain. - * It could be because only us had signed them, or because a revoked commitment got confirmed. + * It could be because only us had signed them, because a revoked commitment got confirmed, or the next commitment + * didn't contain those HTLCs. */ - fun overriddenOutgoingHtlcs(localCommit: LocalCommit, remoteCommit: RemoteCommit, nextRemoteCommit: RemoteCommit?, revokedCommitPublished: List, tx: Transaction): Set = when { - localCommit.publishableTxs.commitTx.tx.txid == tx.txid -> { - // our commit got confirmed, so any htlc that is in their commitment but not in ours will never reach the chain - val htlcsInRemoteCommit = remoteCommit.spec.htlcs + nextRemoteCommit?.spec?.htlcs.orEmpty() - // NB: from the point of view of the remote, their incoming htlcs are our outgoing htlcs - htlcsInRemoteCommit.incomings().toSet() - localCommit.spec.htlcs.outgoings().toSet() - } - revokedCommitPublished.map { it.commitTx.txid }.contains(tx.txid) -> { - // a revoked commitment got confirmed: we will claim its outputs, but we also need to fail htlcs that are pending in the latest commitment - (nextRemoteCommit ?: remoteCommit).spec.htlcs.incomings().toSet() - } - remoteCommit.txid == tx.txid -> when (nextRemoteCommit) { - null -> emptySet() // their last commitment got confirmed, so no htlcs will be overridden, they will timeout or be fulfilled on chain - else -> { - // we had signed a new commitment but they committed the previous one - // any htlc that we signed in the new commitment that they didn't sign will never reach the chain - nextRemoteCommit.spec.htlcs.incomings().toSet() - localCommit.spec.htlcs.outgoings().toSet() - } + fun overriddenOutgoingHtlcs(localCommit: LocalCommit, remoteCommit: RemoteCommit, nextRemoteCommit: RemoteCommit?, revokedCommitPublished: List, tx: Transaction): Set { + // NB: from the p.o.v of remote, their incoming htlcs are our outgoing htlcs. + val outgoingHtlcs = (localCommit.spec.htlcs.outgoings() + remoteCommit.spec.htlcs.incomings() + nextRemoteCommit?.spec?.htlcs.orEmpty().incomings()).toSet() + return when { + // Our commit got confirmed: any htlc that is *not* in our commit will never reach the chain. + localCommit.publishableTxs.commitTx.tx.txid == tx.txid -> outgoingHtlcs - localCommit.spec.htlcs.outgoings().toSet() + // A revoked commitment got confirmed: we will claim its outputs, but we also need to resolve htlcs. + // We consider *all* outgoing htlcs failed: our peer may reveal the preimage with an HTLC-success transaction, + // but it's more likely that our penalty transaction will confirm first. In any case, since we will get those + // funds back on-chain, it's as if the outgoing htlc had failed, therefore it doesn't hurt to be failed back + // to the payment handler. If we already received the preimage, then the fail will be a no-op. + revokedCommitPublished.map { it.commitTx.txid }.contains(tx.txid) -> outgoingHtlcs + // Their current commit got confirmed: any htlc that is *not* in their current commit will never reach the chain. + remoteCommit.txid == tx.txid -> outgoingHtlcs - remoteCommit.spec.htlcs.incomings().toSet() + // Their next commit got confirmed: any htlc that is *not* in their next commit will never reach the chain. + nextRemoteCommit?.txid == tx.txid -> outgoingHtlcs - nextRemoteCommit.spec.htlcs.incomings().toSet() + else -> emptySet() } - else -> emptySet() } /** diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Closing.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Closing.kt index d595e20ba..13e3648b5 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Closing.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Closing.kt @@ -15,8 +15,10 @@ import fr.acinq.lightning.channel.Helpers.Closing.claimRevokedRemoteCommitTxHtlc import fr.acinq.lightning.channel.Helpers.Closing.extractPreimages import fr.acinq.lightning.channel.Helpers.Closing.onChainOutgoingHtlcs import fr.acinq.lightning.channel.Helpers.Closing.overriddenOutgoingHtlcs -import fr.acinq.lightning.channel.Helpers.Closing.timedOutHtlcs +import fr.acinq.lightning.channel.Helpers.Closing.trimmedOrTimedOutHtlcs import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.ClosingTx +import fr.acinq.lightning.transactions.incomings +import fr.acinq.lightning.transactions.outgoings import fr.acinq.lightning.utils.getValue import fr.acinq.lightning.wire.ChannelReestablish import fr.acinq.lightning.wire.Error @@ -106,20 +108,38 @@ data class Closing( // This commitment may be revoked: we need to verify that its index matches our latest known index before overwriting our previous commitments. when { watch.tx.txid == commitments1.latest.localCommit.publishableTxs.commitTx.tx.txid -> { - // our local commit has been published from the outside, it's unexpected but let's deal with it anyway + // Our local commit has been published from the outside, it's unexpected but let's deal with it anyway. newState.run { spendLocalCurrent() } } watch.tx.txid == commitments1.latest.remoteCommit.txid && commitments1.remoteCommitIndex == commitments.remoteCommitIndex -> { - // counterparty may attempt to spend its last commit tx at any time + // Our counterparty may attempt to spend its last commit tx at any time. newState.run { handleRemoteSpentCurrent(watch.tx, commitments1.latest) } } watch.tx.txid == commitments1.latest.nextRemoteCommit?.commit?.txid && commitments1.remoteCommitIndex == commitments.remoteCommitIndex && commitments.remoteNextCommitInfo.isLeft -> { - // counterparty may attempt to spend its next commit tx at any time + // Our counterparty may attempt to spend its next commit tx at any time. newState.run { handleRemoteSpentNext(watch.tx, commitments1.latest) } } else -> { - // counterparty may attempt to spend a revoked commit tx at any time - newState.run { handleRemoteSpentOther(watch.tx) } + // Our counterparty is trying to broadcast a revoked commit tx (cheating attempt). + // We need to fail pending outgoing HTLCs, otherwise we will never properly settle them. + // We must do it here because since we're overwriting the commitments data, we will lose all information + // about HTLCs that are in the current commitments but were not in the revoked one. + // We fail *all* outgoing HTLCs: + // - those that are not in the revoked commitment will never settle on-chain + // - those that are in the revoked commitment will be claimed on-chain, so it's as if they were failed + // Note that if we already received the preimage for some of these HTLCs, we already relayed it to the + // outgoing payment handler so the fail command will be a no-op. + val outgoingHtlcs = commitments.latest.localCommit.spec.htlcs.outgoings().toSet() + + commitments.latest.remoteCommit.spec.htlcs.incomings().toSet() + + commitments.latest.nextRemoteCommit?.commit?.spec?.htlcs.orEmpty().incomings().toSet() + val htlcSettledActions = outgoingHtlcs.mapNotNull { add -> + commitments.payments[add.id]?.let { paymentId -> + logger.info { "failing htlc #${add.id} paymentHash=${add.paymentHash} paymentId=$paymentId: overridden by revoked remote commit" } + ChannelAction.ProcessCmdRes.AddSettledFail(paymentId, add, ChannelAction.HtlcResult.Fail.OnChainFail(HtlcOverriddenByRemoteCommit(channelId, add))) + } + } + val (nextState, closingActions) = newState.run { handleRemoteSpentOther(watch.tx) } + Pair(nextState, closingActions + htlcSettledActions) } } } @@ -141,15 +161,18 @@ data class Closing( // we may need to fail some htlcs in case a commitment tx was published and they have reached the timeout threshold val htlcSettledActions = mutableListOf() val timedOutHtlcs = when (val closingType = closing1.closingTypeAlreadyKnown()) { - is LocalClose -> timedOutHtlcs(closingType.localCommit, closingType.localCommitPublished, commitments.params.localParams.dustLimit, watch.tx) - is RemoteClose -> timedOutHtlcs(closingType.remoteCommit, closingType.remoteCommitPublished, commitments.params.remoteParams.dustLimit, watch.tx) - else -> setOf() // we lose htlc outputs in option_data_loss_protect scenarios (future remote commit) + is LocalClose -> trimmedOrTimedOutHtlcs(closingType.localCommit, closingType.localCommitPublished, commitments.params.localParams.dustLimit, watch.tx) + is RemoteClose -> trimmedOrTimedOutHtlcs(closingType.remoteCommit, closingType.remoteCommitPublished, commitments.params.remoteParams.dustLimit, watch.tx) + is RevokedClose -> setOf() // revoked commitments are handled using [overriddenOutgoingHtlcs] below + is RecoveryClose -> setOf() // we lose htlc outputs in option_data_loss_protect scenarios (future remote commit) + is MutualClose -> setOf() + null -> setOf() } timedOutHtlcs.forEach { add -> when (val paymentId = commitments.payments[add.id]) { null -> { // same as for fulfilling the htlc (no big deal) - logger.info { "cannot fail timedout htlc #${add.id} paymentHash=${add.paymentHash} (payment not found)" } + logger.info { "cannot fail timed-out htlc #${add.id} paymentHash=${add.paymentHash} (payment not found)" } } else -> { logger.info { "failing htlc #${add.id} paymentHash=${add.paymentHash} paymentId=$paymentId: htlc timed out" } @@ -165,8 +188,12 @@ data class Closing( logger.info { "cannot fail overridden htlc #${add.id} paymentHash=${add.paymentHash} (payment not found)" } } else -> { - logger.info { "failing htlc #${add.id} paymentHash=${add.paymentHash} paymentId=$paymentId: overridden by local commit" } - htlcSettledActions += ChannelAction.ProcessCmdRes.AddSettledFail(paymentId, add, ChannelAction.HtlcResult.Fail.OnChainFail(HtlcOverriddenByLocalCommit(channelId, add))) + logger.info { "failing htlc #${add.id} paymentHash=${add.paymentHash} paymentId=$paymentId: overridden by confirmed commit" } + val failure = when { + watch.tx.txid == commitments.latest.localCommit.publishableTxs.commitTx.tx.txid -> HtlcOverriddenByLocalCommit(channelId, add) + else -> HtlcOverriddenByRemoteCommit(channelId, add) + } + htlcSettledActions += ChannelAction.ProcessCmdRes.AddSettledFail(paymentId, add, ChannelAction.HtlcResult.Fail.OnChainFail(failure)) } } } @@ -243,7 +270,7 @@ data class Closing( // we can then use these preimages to fulfill payments logger.info { "processing spent closing output with txid=${watch.spendingTx.txid} tx=${watch.spendingTx}" } val htlcSettledActions = mutableListOf() - extractPreimages(commitments.latest.localCommit, watch.spendingTx).forEach { (htlc, preimage) -> + extractPreimages(commitments.latest, watch.spendingTx).forEach { (htlc, preimage) -> when (val paymentId = commitments.payments[htlc.id]) { null -> { // if we don't have a reference to the payment, it means that we already have forwarded the fulfill so that's not a big deal. diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index 3a6802cee..e6fb1345f 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -1476,7 +1476,7 @@ class Peer( } is AddLiquidityForIncomingPayment -> { val currentFeerates = peerFeeratesFlow.filterNotNull().first() - val paymentTypes = remoteFundingRates.value?.paymentTypes ?: setOf() + val paymentTypes = remoteFundingRates.value?.paymentTypes.orEmpty() val currentFeeCredit = feeCreditFlow.value when (val available = selectChannelForSplicing()) { is SelectChannelResult.Available -> { diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentFailure.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentFailure.kt index e43f2c332..8ad6aea18 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentFailure.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentFailure.kt @@ -74,6 +74,7 @@ data class OutgoingPaymentFailure(val reason: FinalFailure, val failures: List LightningOutgoingPayment.Part.Status.Failed.Failure.ChannelIsClosing is FundingTxSpent -> LightningOutgoingPayment.Part.Status.Failed.Failure.ChannelIsClosing is HtlcOverriddenByLocalCommit -> LightningOutgoingPayment.Part.Status.Failed.Failure.ChannelIsClosing + is HtlcOverriddenByRemoteCommit -> LightningOutgoingPayment.Part.Status.Failed.Failure.ChannelIsClosing is HtlcsTimedOutDownstream -> LightningOutgoingPayment.Part.Status.Failed.Failure.ChannelIsClosing is NoMoreHtlcsClosingInProgress -> LightningOutgoingPayment.Part.Status.Failed.Failure.ChannelIsClosing else -> LightningOutgoingPayment.Part.Status.Failed.Failure.Uninterpretable(failure.value.message) diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/transactions/Scripts.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/transactions/Scripts.kt index 985d09dec..80db01998 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/transactions/Scripts.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/transactions/Scripts.kt @@ -4,7 +4,9 @@ import fr.acinq.bitcoin.* import fr.acinq.bitcoin.ScriptEltMapping.code2elt import fr.acinq.lightning.CltvExpiry import fr.acinq.lightning.CltvExpiryDelta -import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.transactions.Scripts.htlcOffered +import fr.acinq.lightning.transactions.Scripts.htlcReceived +import fr.acinq.lightning.transactions.Scripts.toLocalDelayed /** * Created by PM on 02/12/2016. @@ -13,22 +15,23 @@ object Scripts { fun der(sig: ByteVector64, sigHash: Int): ByteVector = Crypto.compact2der(sig).concat(sigHash.toByte()) - fun multiSig2of2(pubkey1: PublicKey, pubkey2: PublicKey): List = - if (LexicographicalOrdering.isLessThan(pubkey1.value, pubkey2.value)) { - Script.createMultiSigMofN(2, listOf(pubkey1, pubkey2)) - } else { - Script.createMultiSigMofN(2, listOf(pubkey2, pubkey1)) - } + fun multiSig2of2(pubkey1: PublicKey, pubkey2: PublicKey): List = when { + LexicographicalOrdering.isLessThan(pubkey1.value, pubkey2.value) -> Script.createMultiSigMofN(2, listOf(pubkey1, pubkey2)) + else -> Script.createMultiSigMofN(2, listOf(pubkey2, pubkey1)) + } /** * @return a script witness that matches the msig 2-of-2 pubkey script for pubkey1 and pubkey2 */ - fun witness2of2(sig1: ByteVector64, sig2: ByteVector64, pubkey1: PublicKey, pubkey2: PublicKey): ScriptWitness = - if (LexicographicalOrdering.isLessThan(pubkey1.value, pubkey2.value)) { - ScriptWitness(listOf(ByteVector.empty, der(sig1, SigHash.SIGHASH_ALL), der(sig2, SigHash.SIGHASH_ALL), ByteVector(Script.write(multiSig2of2(pubkey1, pubkey2))))) - } else { - ScriptWitness(listOf(ByteVector.empty, der(sig2, SigHash.SIGHASH_ALL), der(sig1, SigHash.SIGHASH_ALL), ByteVector(Script.write(multiSig2of2(pubkey1, pubkey2))))) + fun witness2of2(sig1: ByteVector64, sig2: ByteVector64, pubkey1: PublicKey, pubkey2: PublicKey): ScriptWitness { + val encodedSig1 = der(sig1, SigHash.SIGHASH_ALL) + val encodedSig2 = der(sig2, SigHash.SIGHASH_ALL) + val redeemScript = ByteVector(Script.write(multiSig2of2(pubkey1, pubkey2))) + return when { + LexicographicalOrdering.isLessThan(pubkey1.value, pubkey2.value) -> ScriptWitness(listOf(ByteVector.empty, encodedSig1, encodedSig2, redeemScript)) + else -> ScriptWitness(listOf(ByteVector.empty, encodedSig2, encodedSig1, redeemScript)) } + } /** * minimal encoding of a number into a script element: @@ -45,14 +48,6 @@ object Scripts { else -> OP_PUSHDATA(Script.encodeNumber(n)) } - fun applyFees(amount_us: Satoshi, amount_them: Satoshi, fee: Satoshi): Pair = - when { - amount_us >= fee / 2 && amount_them >= fee / 2 -> Pair((amount_us - fee) / 2, (amount_them - fee) / 2) - amount_us < fee / 2 -> Pair(0.sat, (amount_them - fee + amount_us).max(0.sat)) - amount_them < fee / 2 -> Pair((amount_us - fee + amount_them).max(0.sat), 0.sat) - else -> error("impossible") - } - /** * This function interprets the locktime for the given transaction, and returns the block height before which this tx cannot be published. * By convention in bitcoin, depending of the value of locktime it might be a number of blocks or a number of seconds since epoch. @@ -61,16 +56,16 @@ object Scripts { * * @return the block height before which this tx cannot be published. */ - fun cltvTimeout(tx: Transaction): Long = - if (tx.lockTime <= Script.LOCKTIME_THRESHOLD) { - // locktime is a number of blocks - tx.lockTime - } else { + fun cltvTimeout(tx: Transaction): Long = when { + // locktime is a number of blocks + tx.lockTime <= Script.LOCKTIME_THRESHOLD -> tx.lockTime + else -> { // locktime is a unix epoch timestamp require(tx.lockTime <= 0x20FFFFFF) { "locktime should be lesser than 0x20FFFFFF" } // since locktime is very well in the past (0x20FFFFFF is in 1987), it is equivalent to no locktime at all 0 } + } /** * @return the number of confirmations of the tx parent before which it can be published @@ -83,7 +78,7 @@ object Scripts { sequence and TxIn.SEQUENCE_LOCKTIME_MASK } - return if (tx.version < 2) 0L else tx.txIn.map { it.sequence }.map { sequenceToBlockHeight(it) }.maxOrNull()!! + return if (tx.version < 2) 0L else tx.txIn.map { it.sequence }.maxOf { sequenceToBlockHeight(it) } } fun toAnchor(fundingPubkey: PublicKey): List = @@ -160,12 +155,16 @@ object Scripts { fun witnessHtlcSuccess(localSig: ByteVector64, remoteSig: ByteVector64, paymentPreimage: ByteVector32, htlcOfferedScript: ByteVector) = ScriptWitness(listOf(ByteVector.empty, der(remoteSig, SigHash.SIGHASH_SINGLE or SigHash.SIGHASH_ANYONECANPAY), der(localSig, SigHash.SIGHASH_ALL), paymentPreimage, htlcOfferedScript)) - /** Extract the payment preimage from a 2nd-stage HTLC Success transaction's witness script */ - fun extractPreimageFromHtlcSuccess(): (ScriptWitness) -> ByteVector32? = f@{ - if (it.stack.size < 5 || !it.stack[0].isEmpty()) return@f null - val paymentPreimage = it.stack[3] - if (paymentPreimage.size() != 32) return@f null - ByteVector32(paymentPreimage) + /** Extract payment preimages from a 2nd-stage HTLC Success transaction's witness script. */ + fun extractPreimagesFromHtlcSuccess(tx: Transaction): Set { + return tx.txIn.map { it.witness }.mapNotNull { + when { + it.stack.size < 5 -> null + !it.stack[0].isEmpty() -> null + it.stack[3].size() != 32 -> null + else -> ByteVector32(it.stack[3]) + } + }.toSet() } /** @@ -175,12 +174,15 @@ object Scripts { fun witnessClaimHtlcSuccessFromCommitTx(localSig: ByteVector64, paymentPreimage: ByteVector32, htlcOffered: ByteVector) = ScriptWitness(listOf(der(localSig, SigHash.SIGHASH_ALL), paymentPreimage, htlcOffered)) - /** Extract the payment preimage from from a fulfilled offered htlc. */ - fun extractPreimageFromClaimHtlcSuccess(): (ScriptWitness) -> ByteVector32? = f@{ - if (it.stack.size < 3) return@f null - val paymentPreimage = it.stack[1] - if (paymentPreimage.size() != 32) return@f null - ByteVector32(paymentPreimage) + /** Extract payment preimages from a claim-htlc transaction. */ + fun extractPreimagesFromClaimHtlcSuccess(tx: Transaction): Set { + return tx.txIn.map { it.witness }.mapNotNull { + when { + it.stack.size < 3 -> null + it.stack[1].size() != 32 -> null + else -> ByteVector32(it.stack[1]) + } + }.toSet() } fun htlcReceived(localHtlcPubkey: PublicKey, remoteHtlcPubkey: PublicKey, revocationPubKey: PublicKey, paymentHash: ByteArray, lockTime: CltvExpiry) = listOf( @@ -213,13 +215,6 @@ object Scripts { fun witnessHtlcTimeout(localSig: ByteVector64, remoteSig: ByteVector64, htlcOfferedScript: ByteVector) = ScriptWitness(listOf(ByteVector.empty, der(remoteSig, SigHash.SIGHASH_SINGLE or SigHash.SIGHASH_ANYONECANPAY), der(localSig, SigHash.SIGHASH_ALL), ByteVector.empty, htlcOfferedScript)) - /** Extract the payment hash from a 2nd-stage HTLC Timeout transaction's witness script */ - fun extractPaymentHashFromHtlcTimeout(): (ScriptWitness) -> ByteVector? = f@{ - if (it.stack.size < 5 || !it.stack[0].isEmpty() || !it.stack[3].isEmpty()) return@f null - val htlcOfferedScript = it.stack[4] - htlcOfferedScript.slice(109, 109 + 20) - } - /** * If remote publishes its commit tx where there was a local->remote htlc, then local uses this script to * claim its funds after timeout (consumes htlcReceived script from commit tx) @@ -227,13 +222,6 @@ object Scripts { fun witnessClaimHtlcTimeoutFromCommitTx(localSig: ByteVector64, htlcReceivedScript: ByteVector) = ScriptWitness(listOf(der(localSig, SigHash.SIGHASH_ALL), ByteVector.empty, htlcReceivedScript)) - /** Extract the payment hash from a timed-out received htlc. */ - fun extractPaymentHashFromClaimHtlcTimeout(): (ScriptWitness) -> ByteVector? = f@{ - if (it.stack.size < 3 || !it.stack[1].isEmpty()) return@f null - val htlcReceivedScript = it.stack[2] - htlcReceivedScript.slice(69, 69 + 20) - } - /** * This witness script spends (steals) a [[htlcOffered]] or [[htlcReceived]] output using a revocation key as a punishment * for having published a revoked transaction diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/ChannelDataTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/ChannelDataTestsCommon.kt index 2a478607f..a0fd78d7d 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/ChannelDataTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/ChannelDataTestsCommon.kt @@ -46,7 +46,7 @@ class ChannelDataTestsCommon : LightningTestSuite(), LoggingContext { val watchSpent = actions.findWatches().map { OutPoint(it.txId, it.outputIndex.toLong()) }.toSet() assertEquals(watchSpent, listOf(2L, 3L, 4L, 5L).map { OutPoint(lcp.commitTx.txid, it) }.toSet()) val txs = actions.findPublishTxs().toSet() - assertEquals(txs, setOf(lcp.commitTx, lcp.claimMainDelayedOutputTx!!.tx) + lcp.htlcTxs.values.filterNotNull().map { it.tx } + lcp.claimHtlcDelayedTxs.map { it.tx }.toSet()) + assertEquals(txs, setOf(lcp.commitTx, lcp.claimMainDelayedOutputTx.tx) + lcp.htlcTxs.values.filterNotNull().map { it.tx } + lcp.claimHtlcDelayedTxs.map { it.tx }.toSet()) } // Commit tx has been confirmed. @@ -117,7 +117,7 @@ class ChannelDataTestsCommon : LightningTestSuite(), LoggingContext { val watchSpent = actions.findWatches().map { OutPoint(it.txId, it.outputIndex.toLong()) }.toSet() assertEquals(watchSpent, listOf(2L, 3L, 4L, 5L).map { OutPoint(rcp.commitTx.txid, it) }.toSet()) val txs = actions.findPublishTxs().toSet() - assertEquals(txs, setOf(rcp.claimMainOutputTx!!.tx) + rcp.claimHtlcTxs.values.filterNotNull().map { it.tx }.toSet()) + assertEquals(txs, setOf(rcp.claimMainOutputTx.tx) + rcp.claimHtlcTxs.values.filterNotNull().map { it.tx }.toSet()) } // Commit tx has been confirmed. @@ -186,7 +186,7 @@ class ChannelDataTestsCommon : LightningTestSuite(), LoggingContext { val watchSpent = actions.findWatches().map { OutPoint(it.txId, it.outputIndex.toLong()) }.toSet() assertEquals(watchSpent, listOf(1L, 2L, 3L, 4L, 5L).map { OutPoint(rvk.commitTx.txid, it) }.toSet()) val txs = actions.findPublishTxs().toSet() - assertEquals(txs, setOf(rvk.claimMainOutputTx!!.tx, rvk.mainPenaltyTx!!.tx) + rvk.htlcPenaltyTxs.map { it.tx }.toSet()) + assertEquals(txs, setOf(rvk.claimMainOutputTx.tx, rvk.mainPenaltyTx!!.tx) + rvk.htlcPenaltyTxs.map { it.tx }.toSet()) } // Commit tx has been confirmed. @@ -294,26 +294,6 @@ class ChannelDataTestsCommon : LightningTestSuite(), LoggingContext { assertEquals(2, claimHtlcTimeoutTxs.size) val claimHtlcSuccessTxs = rcp.claimHtlcSuccessTxs() assertEquals(1, claimHtlcSuccessTxs.size) - - // Valid txs should be detected: - htlcTimeoutTxs.forEach { tx -> assertTrue(lcp.isHtlcTimeout(tx.tx)) } - htlcSuccessTxs.forEach { tx -> assertTrue(lcp.isHtlcSuccess(tx.tx)) } - claimHtlcTimeoutTxs.forEach { tx -> assertTrue(rcp.isClaimHtlcTimeout(tx.tx)) } - claimHtlcSuccessTxs.forEach { tx -> assertTrue(rcp.isClaimHtlcSuccess(tx.tx)) } - - // Invalid txs should be rejected: - htlcSuccessTxs.forEach { tx -> assertFalse(lcp.isHtlcTimeout(tx.tx)) } - claimHtlcTimeoutTxs.forEach { tx -> assertFalse(lcp.isHtlcTimeout(tx.tx)) } - claimHtlcSuccessTxs.forEach { tx -> assertFalse(lcp.isHtlcTimeout(tx.tx)) } - htlcTimeoutTxs.forEach { tx -> assertFalse(lcp.isHtlcSuccess(tx.tx)) } - claimHtlcTimeoutTxs.forEach { tx -> assertFalse(lcp.isHtlcSuccess(tx.tx)) } - claimHtlcSuccessTxs.forEach { tx -> assertFalse(lcp.isHtlcSuccess(tx.tx)) } - htlcTimeoutTxs.forEach { tx -> assertFalse(rcp.isClaimHtlcTimeout(tx.tx)) } - htlcSuccessTxs.forEach { tx -> assertFalse(rcp.isClaimHtlcTimeout(tx.tx)) } - claimHtlcSuccessTxs.forEach { tx -> assertFalse(rcp.isClaimHtlcTimeout(tx.tx)) } - htlcTimeoutTxs.forEach { tx -> assertFalse(rcp.isClaimHtlcSuccess(tx.tx)) } - htlcSuccessTxs.forEach { tx -> assertFalse(rcp.isClaimHtlcSuccess(tx.tx)) } - claimHtlcTimeoutTxs.forEach { tx -> assertFalse(rcp.isClaimHtlcSuccess(tx.tx)) } } companion object { diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/CommitmentsTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/CommitmentsTestsCommon.kt index 36597b9d0..07c7e8bac 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/CommitmentsTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/CommitmentsTestsCommon.kt @@ -5,11 +5,10 @@ import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey -import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.blockchain.WatchSpent import fr.acinq.lightning.blockchain.WatchSpentTriggered import fr.acinq.lightning.blockchain.fee.FeeratePerKw -import fr.acinq.lightning.channel.Helpers.Closing.timedOutHtlcs +import fr.acinq.lightning.channel.Helpers.Closing.trimmedOrTimedOutHtlcs import fr.acinq.lightning.channel.TestsHelper.claimHtlcSuccessTxs import fr.acinq.lightning.channel.TestsHelper.claimHtlcTimeoutTxs import fr.acinq.lightning.channel.TestsHelper.htlcSuccessTxs @@ -64,7 +63,7 @@ class CommitmentsTestsCommon : LightningTestSuite(), LoggingContext { assertEquals(bc0.availableBalanceForReceive(), a) val currentBlockHeight = 144L - val (payment_preimage, cmdAdd) = TestsHelper.makeCmdAdd(p, bob.staticParams.nodeParams.nodeId, currentBlockHeight) + val (preimage, cmdAdd) = TestsHelper.makeCmdAdd(p, bob.staticParams.nodeParams.nodeId, currentBlockHeight) val (ac1, add) = ac0.sendAdd(cmdAdd, UUID.randomUUID(), currentBlockHeight).right!! assertEquals(ac1.availableBalanceForSend(), a - p - htlcOutputFee) // as soon as htlc is sent, alice sees its balance decrease (more than the payment amount because of the commitment fees) assertEquals(ac1.availableBalanceForReceive(), b) @@ -97,7 +96,7 @@ class CommitmentsTestsCommon : LightningTestSuite(), LoggingContext { assertEquals(bc4.availableBalanceForSend(), b) assertEquals(bc4.availableBalanceForReceive(), a - p - htlcOutputFee) - val cmdFulfill = ChannelCommand.Htlc.Settlement.Fulfill(0, payment_preimage) + val cmdFulfill = ChannelCommand.Htlc.Settlement.Fulfill(0, preimage) val (bc5, fulfill) = bc4.sendFulfill(cmdFulfill).right!! assertEquals(bc5.availableBalanceForSend(), b + p) // as soon as we have the fulfill, the balance increases assertEquals(bc5.availableBalanceForReceive(), a - p - htlcOutputFee) @@ -245,7 +244,7 @@ class CommitmentsTestsCommon : LightningTestSuite(), LoggingContext { val currentBlockHeight = 144L - val (payment_preimage1, cmdAdd1) = TestsHelper.makeCmdAdd(p1, bob.staticParams.nodeParams.nodeId, currentBlockHeight) + val (preimage1, cmdAdd1) = TestsHelper.makeCmdAdd(p1, bob.staticParams.nodeParams.nodeId, currentBlockHeight) val (ac1, add1) = ac0.sendAdd(cmdAdd1, UUID.randomUUID(), currentBlockHeight).right!! assertEquals(ac1.availableBalanceForSend(), a - p1 - htlcOutputFee) // as soon as htlc is sent, alice sees its balance decrease (more than the payment amount because of the commitment fees) assertEquals(ac1.availableBalanceForReceive(), b) @@ -255,7 +254,7 @@ class CommitmentsTestsCommon : LightningTestSuite(), LoggingContext { assertEquals(ac2.availableBalanceForSend(), a - p1 - htlcOutputFee - p2 - htlcOutputFee) // as soon as htlc is sent, alice sees its balance decrease (more than the payment amount because of the commitment fees) assertEquals(ac2.availableBalanceForReceive(), b) - val (payment_preimage3, cmdAdd3) = TestsHelper.makeCmdAdd(p3, alice.staticParams.nodeParams.nodeId, currentBlockHeight) + val (preimage3, cmdAdd3) = TestsHelper.makeCmdAdd(p3, alice.staticParams.nodeParams.nodeId, currentBlockHeight) val (bc1, add3) = bc0.sendAdd(cmdAdd3, UUID.randomUUID(), currentBlockHeight).right!! assertEquals(bc1.availableBalanceForSend(), b - p3) // as soon as htlc is sent, alice sees its balance decrease (more than the payment amount because of the commitment fees) assertEquals(bc1.availableBalanceForReceive(), a) @@ -308,7 +307,7 @@ class CommitmentsTestsCommon : LightningTestSuite(), LoggingContext { assertEquals(ac8.availableBalanceForSend(), a - p1 - htlcOutputFee - p2 - htlcOutputFee - htlcOutputFee) assertEquals(ac8.availableBalanceForReceive(), b - p3) - val cmdFulfill1 = ChannelCommand.Htlc.Settlement.Fulfill(0, payment_preimage1) + val cmdFulfill1 = ChannelCommand.Htlc.Settlement.Fulfill(0, preimage1) val (bc8, fulfill1) = bc7.sendFulfill(cmdFulfill1).right!! assertEquals(bc8.availableBalanceForSend(), b + p1 - p3) // as soon as we have the fulfill, the balance increases assertEquals(bc8.availableBalanceForReceive(), a - p1 - htlcOutputFee - p2 - htlcOutputFee - htlcOutputFee) @@ -318,7 +317,7 @@ class CommitmentsTestsCommon : LightningTestSuite(), LoggingContext { assertEquals(bc9.availableBalanceForSend(), b + p1 - p3) assertEquals(bc9.availableBalanceForReceive(), a - p1 - htlcOutputFee - p2 - htlcOutputFee - htlcOutputFee) // a's balance won't return to previous before she acknowledges the fail - val cmdFulfill3 = ChannelCommand.Htlc.Settlement.Fulfill(0, payment_preimage3) + val cmdFulfill3 = ChannelCommand.Htlc.Settlement.Fulfill(0, preimage3) val (ac9, fulfill3) = ac8.sendFulfill(cmdFulfill3).right!! assertEquals(ac9.availableBalanceForSend(), a - p1 - htlcOutputFee - p2 - htlcOutputFee + p3) assertEquals(ac9.availableBalanceForReceive(), b - p3) @@ -461,25 +460,25 @@ class CommitmentsTestsCommon : LightningTestSuite(), LoggingContext { val claimHtlcSuccessTxs = rcp.claimHtlcSuccessTxs() val aliceTimedOutHtlcs = htlcTimeoutTxs.map { htlcTimeout -> - val htlcs = timedOutHtlcs(localCommit, lcp, dustLimit, htlcTimeout.tx) + val htlcs = trimmedOrTimedOutHtlcs(localCommit, lcp, dustLimit, htlcTimeout.tx) assertEquals(1, htlcs.size) htlcs.first() } assertEquals(timedOutHtlcs.take(3).toSet(), aliceTimedOutHtlcs.toSet()) val bobTimedOutHtlcs = claimHtlcTimeoutTxs.map { claimHtlcTimeout -> - val htlcs = timedOutHtlcs(remoteCommit, rcp, dustLimit, claimHtlcTimeout.tx) + val htlcs = trimmedOrTimedOutHtlcs(remoteCommit, rcp, dustLimit, claimHtlcTimeout.tx) assertEquals(1, htlcs.size) htlcs.first() } assertEquals(timedOutHtlcs.drop(3).toSet(), bobTimedOutHtlcs.toSet()) - htlcSuccessTxs.forEach { htlcSuccess -> assertTrue(timedOutHtlcs(localCommit, lcp, dustLimit, htlcSuccess.tx).isEmpty()) } - htlcSuccessTxs.forEach { htlcSuccess -> assertTrue(timedOutHtlcs(remoteCommit, rcp, dustLimit, htlcSuccess.tx).isEmpty()) } - claimHtlcSuccessTxs.forEach { claimHtlcSuccess -> assertTrue(timedOutHtlcs(localCommit, lcp, dustLimit, claimHtlcSuccess.tx).isEmpty()) } - claimHtlcSuccessTxs.forEach { claimHtlcSuccess -> assertTrue(timedOutHtlcs(remoteCommit, rcp, dustLimit, claimHtlcSuccess.tx).isEmpty()) } - htlcTimeoutTxs.forEach { htlcTimeout -> assertTrue(timedOutHtlcs(remoteCommit, rcp, dustLimit, htlcTimeout.tx).isEmpty()) } - claimHtlcTimeoutTxs.forEach { claimHtlcTimeout -> assertTrue(timedOutHtlcs(localCommit, lcp, dustLimit, claimHtlcTimeout.tx).isEmpty()) } + htlcSuccessTxs.forEach { htlcSuccess -> assertTrue(trimmedOrTimedOutHtlcs(localCommit, lcp, dustLimit, htlcSuccess.tx).isEmpty()) } + htlcSuccessTxs.forEach { htlcSuccess -> assertTrue(trimmedOrTimedOutHtlcs(remoteCommit, rcp, dustLimit, htlcSuccess.tx).isEmpty()) } + claimHtlcSuccessTxs.forEach { claimHtlcSuccess -> assertTrue(trimmedOrTimedOutHtlcs(localCommit, lcp, dustLimit, claimHtlcSuccess.tx).isEmpty()) } + claimHtlcSuccessTxs.forEach { claimHtlcSuccess -> assertTrue(trimmedOrTimedOutHtlcs(remoteCommit, rcp, dustLimit, claimHtlcSuccess.tx).isEmpty()) } + htlcTimeoutTxs.forEach { htlcTimeout -> assertTrue(trimmedOrTimedOutHtlcs(remoteCommit, rcp, dustLimit, htlcTimeout.tx).isEmpty()) } + claimHtlcTimeoutTxs.forEach { claimHtlcTimeout -> assertTrue(trimmedOrTimedOutHtlcs(localCommit, lcp, dustLimit, claimHtlcTimeout.tx).isEmpty()) } } companion object { diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt index 77368591f..2438d4dd2 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt @@ -25,8 +25,6 @@ import fr.acinq.lightning.channel.TestsHelper.useAlternativeCommitSig import fr.acinq.lightning.db.ChannelCloseOutgoingPayment.ChannelClosingType import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.tests.utils.LightningTestSuite -import fr.acinq.lightning.transactions.Scripts -import fr.acinq.lightning.transactions.Transactions import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat @@ -276,7 +274,35 @@ class ClosingTestsCommon : LightningTestSuite() { } @Test - fun `recv ClosingTxConfirmed -- local commit -- followed by CMD_FULFILL_HTLC`() { + fun `recv ClosingTxConfirmed -- local commit with fulfill only signed by local`() { + val (alice0, bob0) = reachNormal() + // Bob sends an htlc to Alice. + val (nodes1, r, htlc) = addHtlc(110_000_000.msat, bob0, alice0) + val (bob1, alice1) = crossSign(nodes1.first, nodes1.second) + val aliceCommitTx = alice1.commitments.latest.localCommit.publishableTxs.commitTx.tx + assertEquals(5, aliceCommitTx.txOut.size) // 2 main outputs + 2 anchors + 1 htlc + + // Alice fulfills the HTLC but Bob doesn't receive the signature. + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.Htlc.Settlement.Fulfill(htlc.id, r, commit = true)) + assertEquals(2, actionsAlice2.size) + val fulfill = actionsAlice2.hasOutgoingMessage() + actionsAlice2.hasCommand() + val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(fulfill)) + assertEquals(1, actionsBob2.size) + actionsBob2.find().also { + assertEquals(htlc, it.htlc) + assertEquals(r, it.result.paymentPreimage) + } + + // Then we make Alice unilaterally close the channel. + val (_, localCommitPublished) = localClose(alice2) + assertEquals(aliceCommitTx.txid, localCommitPublished.commitTx.txid) + assertTrue(localCommitPublished.htlcTimeoutTxs().isEmpty()) + assertEquals(1, localCommitPublished.htlcSuccessTxs().size) + } + + @Test + fun `recv ClosingTxConfirmed -- local commit -- followed by preimage`() { val (alice0, bob0) = reachNormal() val (aliceClosing, localCommitPublished, fulfill) = run { // An HTLC Bob -> Alice is cross-signed that will be fulfilled later. @@ -359,71 +385,50 @@ class ClosingTestsCommon : LightningTestSuite() { } @Test - fun `recv BITCOIN_OUTPUT_SPENT -- local commit`() { + fun `recv BITCOIN_OUTPUT_SPENT -- local commit -- extract preimage from claim-htlc-success tx`() { val (alice0, bob0) = reachNormal() - val (aliceClosing, localCommitPublished, preimage) = run { - val (nodes1, preimage, _) = addHtlc(20_000_000.msat, alice0, bob0) - val (alice1, bob1) = nodes1 - val (nodes2, _, _) = addHtlc(15_000_000.msat, alice1, bob1) - val (alice2, bob2) = nodes2 - val (nodes3, ra1, addBob1) = addHtlc(10_000_000.msat, bob2, alice2) - val (bob3, alice3) = nodes3 - val (nodes4, ra2, addBob2) = addHtlc(12_000_000.msat, bob3, alice3) - val (bob4, alice4) = nodes4 - val (alice5, _) = crossSign(alice4, bob4) - // alice is ready to claim incoming htlcs - val (alice6, _) = alice5.process(ChannelCommand.Htlc.Settlement.Fulfill(addBob1.id, ra1, commit = false)) - val (alice7, _) = alice6.process(ChannelCommand.Htlc.Settlement.Fulfill(addBob2.id, ra2, commit = false)) - val (alice8, localCommitPublished) = localClose(alice7) - Triple(alice8, localCommitPublished, preimage) - } - + // Alice sends htlcs to Bob with the same payment_hash. + val (nodes1, preimage, htlc1) = addHtlc(20_000_000.msat, alice0, bob0) + val (alice1, bob1) = nodes1 + val (alice2, bob2, htlc2) = addHtlc(makeCmdAdd(15_000_000.msat, bob1.staticParams.nodeParams.nodeId, alice1.currentBlockHeight.toLong(), preimage).second, alice1, bob1) + assertEquals(htlc1.paymentHash, htlc2.paymentHash) + val (alice3, bob3) = crossSign(alice2, bob2) + // Bob has the preimage for those HTLCs, but Alice force-closes before receiving it. + val (bob4, actionsBob4) = bob3.process(ChannelCommand.Htlc.Settlement.Fulfill(htlc1.id, preimage)) + actionsBob4.hasOutgoingMessage() // ignored + val (alice4, localCommitPublished) = localClose(alice3) assertEquals(2, localCommitPublished.htlcTimeoutTxs().size) - assertEquals(2, localCommitPublished.htlcSuccessTxs().size) - assertEquals(4, localCommitPublished.claimHtlcDelayedTxs.size) - - // Bob tries to claim 2 htlc outputs. - val bobClaimSuccessTx = Transaction( - version = 2, - txIn = listOf(TxIn(localCommitPublished.htlcTimeoutTxs()[0].input.outPoint, ByteVector.empty, 0, Scripts.witnessClaimHtlcSuccessFromCommitTx(Transactions.PlaceHolderSig, preimage, ByteArray(130) { 33 }.byteVector()))), - txOut = emptyList(), - lockTime = 0 - ) - val bobClaimTimeoutTx = Transaction( - version = 2, - txIn = listOf(TxIn(localCommitPublished.htlcSuccessTxs()[0].input.outPoint, ByteVector.empty, 0, Scripts.witnessClaimHtlcTimeoutFromCommitTx(Transactions.PlaceHolderSig, ByteVector.empty))), - txOut = emptyList(), - lockTime = 0 - ) - - val (alice1, actions1) = aliceClosing.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice0.channelId, WatchSpent.ClosingOutputSpent(20_000.sat), bobClaimSuccessTx))) - assertEquals(aliceClosing, alice1) - assertEquals(3, actions1.size) - assertTrue(actions1.contains(ChannelAction.Storage.StoreState(aliceClosing.state))) - assertEquals(WatchConfirmed(alice0.channelId, bobClaimSuccessTx, alice0.staticParams.nodeParams.minDepthBlocks, WatchConfirmed.ClosingTxConfirmed), actions1.findWatch()) - // alice extracts Bob's preimage from his claim-htlc-success tx. - val addSettled = actions1.filterIsInstance().first() - assertEquals(ChannelAction.HtlcResult.Fulfill.OnChainFulfill(preimage), addSettled.result) - - val (alice2, actions2) = aliceClosing.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice0.channelId, WatchSpent.ClosingOutputSpent(10_000.sat), bobClaimTimeoutTx))) - assertEquals(aliceClosing, alice2) - assertEquals(2, actions2.size) - assertTrue(actions2.contains(ChannelAction.Storage.StoreState(aliceClosing.state))) - assertEquals(WatchConfirmed(alice0.channelId, bobClaimTimeoutTx, alice0.staticParams.nodeParams.minDepthBlocks, WatchConfirmed.ClosingTxConfirmed), actions2.findWatch()) - - val claimHtlcSuccessDelayed = localCommitPublished.claimHtlcDelayedTxs.find { it.input.outPoint.txid == localCommitPublished.htlcSuccessTxs()[1].tx.txid }!! - val claimHtlcTimeoutDelayed = localCommitPublished.claimHtlcDelayedTxs.find { it.input.outPoint.txid == localCommitPublished.htlcTimeoutTxs()[1].tx.txid }!! + assertTrue(localCommitPublished.htlcSuccessTxs().isEmpty()) + assertEquals(2, localCommitPublished.claimHtlcDelayedTxs.size) + + val (_, actionsBob5) = bob4.process(ChannelCommand.WatchReceived(WatchSpentTriggered(bob0.channelId, WatchSpent.ChannelSpent(TestConstants.fundingAmount), localCommitPublished.commitTx))) + actionsBob5.has() + val claimHtlcSuccessTxs = actionsBob5.filterIsInstance().filter { it.txType == ChannelAction.Blockchain.PublishTx.Type.ClaimHtlcSuccessTx }.map { it.tx } + assertEquals(2, claimHtlcSuccessTxs.size) + assertEquals(2, claimHtlcSuccessTxs.flatMap { it.txIn.map { it.outPoint } }.toSet().size) + + // Alice extracts the preimage and forwards it to the payment handler. + val (alice5, actionsAlice5) = alice4.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice0.channelId, WatchSpent.ClosingOutputSpent(20_000.sat), claimHtlcSuccessTxs.first()))) + assertEquals(4, actionsAlice5.size) + actionsAlice5.has() + assertEquals(WatchConfirmed(alice0.channelId, claimHtlcSuccessTxs.first(), alice0.staticParams.nodeParams.minDepthBlocks, WatchConfirmed.ClosingTxConfirmed), actionsAlice5.findWatch()) + val addSettled = actionsAlice5.filterIsInstance() + assertEquals(setOf(htlc1, htlc2), addSettled.map { it.htlc }.toSet()) + assertEquals(setOf(ChannelAction.HtlcResult.Fulfill.OnChainFulfill(preimage)), addSettled.map { it.result }.toSet()) + + // The Claim-HTLC-success transaction confirms: nothing to do, preimage has already been relayed. + val (alice6, actionsAlice6) = alice5.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 41, 1, claimHtlcSuccessTxs.first()))) + assertIs>(alice6) + assertEquals(1, actionsAlice6.size) + actionsAlice6.has() + + // The remaining transactions confirm. val watchConfirmed = listOf( - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 42, 0, bobClaimSuccessTx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 42, 1, localCommitPublished.commitTx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 200, 0, localCommitPublished.claimMainDelayedOutputTx!!.tx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 202, 0, localCommitPublished.htlcSuccessTxs()[1].tx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 202, 0, bobClaimTimeoutTx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 202, 0, localCommitPublished.htlcTimeoutTxs()[1].tx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 203, 0, claimHtlcSuccessDelayed.tx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 203, 0, claimHtlcTimeoutDelayed.tx) + WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 41, 0, localCommitPublished.commitTx), + WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 47, 0, claimHtlcSuccessTxs.last()), + WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 200, 0, localCommitPublished.claimMainDelayedOutputTx!!.tx) ) - confirmWatchedTxs(aliceClosing, watchConfirmed) + confirmWatchedTxs(alice6, watchConfirmed) } @Test @@ -615,7 +620,7 @@ class ClosingTestsCommon : LightningTestSuite() { } @Test - fun `recv ClosingTxConfirmed -- remote commit -- followed by CMD_FULFILL_HTLC`() { + fun `recv ClosingTxConfirmed -- remote commit -- followed by preimage`() { val (alice0, bob0) = reachNormal() val (aliceClosing, remoteCommitPublished, fulfill) = run { // An HTLC Bob -> Alice is cross-signed that will be fulfilled later. @@ -689,82 +694,49 @@ class ClosingTestsCommon : LightningTestSuite() { } @Test - fun `recv BITCOIN_OUTPUT_SPENT -- remote commit`() { + fun `recv BITCOIN_OUTPUT_SPENT -- remote commit -- extract preimage from HTLC-success tx`() { val (alice0, bob0) = reachNormal() - val (aliceClosing, remoteCommitPublished, preimage) = run { - val (nodes1, rb1, addAlice1) = addHtlc(20_000_000.msat, alice0, bob0) - val (alice1, bob1) = nodes1 - val (nodes2, rb2, addAlice2) = addHtlc(15_000_000.msat, alice1, bob1) - val (alice2, bob2) = nodes2 - val (nodes3, ra1, addBob1) = addHtlc(10_000_000.msat, bob2, alice2) - val (bob3, alice3) = nodes3 - val (nodes4, ra2, addBob2) = addHtlc(12_000_000.msat, bob3, alice3) - val (bob4, alice4) = nodes4 - val (alice5, bob5) = crossSign(alice4, bob4) - // alice is ready to claim incoming htlcs - val (alice6, _) = alice5.process(ChannelCommand.Htlc.Settlement.Fulfill(addBob1.id, ra1, commit = false)) - val (alice7, _) = alice6.process(ChannelCommand.Htlc.Settlement.Fulfill(addBob2.id, ra2, commit = false)) - // bob is ready to claim incoming htlcs - val (bob6, _) = bob5.process(ChannelCommand.Htlc.Settlement.Fulfill(addAlice1.id, rb1, commit = false)) - val (bob7, _) = bob6.process(ChannelCommand.Htlc.Settlement.Fulfill(addAlice2.id, rb2, commit = false)) - assertIs(bob7.state) - val bobCommitTx = bob7.state.commitments.latest.localCommit.publishableTxs.commitTx.tx - assertEquals(8, bobCommitTx.txOut.size) // 2 main outputs, 2 anchors and 4 htlcs - - // alice publishes her commitment - val (alice8, localCommitPublished) = localClose(alice7) - // bob also publishes his commitment, and wins the race to confirm - val (alice9, remoteCommitPublished) = remoteClose(bobCommitTx, alice8.copy(state = alice8.state.copy(localCommitPublished = null))) - assertEquals(2, localCommitPublished.htlcTimeoutTxs().size) - assertEquals(2, localCommitPublished.htlcSuccessTxs().size) - assertEquals(4, localCommitPublished.claimHtlcDelayedTxs.size) - assertEquals(2, remoteCommitPublished.claimHtlcSuccessTxs().size) - assertEquals(2, remoteCommitPublished.claimHtlcTimeoutTxs().size) - - Triple(alice9, remoteCommitPublished, rb1) - } - - assertNotNull(aliceClosing.state.remoteCommitPublished) - assertNotNull(remoteCommitPublished.claimMainOutputTx) - - // Bob claims 2 htlc outputs, alice will claim the other 2. - val bobHtlcSuccessTx = Transaction( - version = 2, - txIn = listOf(TxIn(remoteCommitPublished.claimHtlcTimeoutTxs()[0].input.outPoint, ByteVector.empty, 0, Scripts.witnessHtlcSuccess(Transactions.PlaceHolderSig, Transactions.PlaceHolderSig, preimage, ByteVector.empty))), - txOut = emptyList(), - lockTime = 0 - ) - val bobHtlcTimeoutTx = Transaction( - version = 2, - txIn = listOf(TxIn(remoteCommitPublished.claimHtlcSuccessTxs()[0].input.outPoint, ByteVector.empty, 0, Scripts.witnessHtlcTimeout(Transactions.PlaceHolderSig, Transactions.PlaceHolderSig, ByteVector.empty))), - txOut = emptyList(), - lockTime = 0 - ) - - val (alice1, actions1) = aliceClosing.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice0.channelId, WatchSpent.ClosingOutputSpent(20_000.sat), bobHtlcSuccessTx))) - assertEquals(aliceClosing, alice1) - assertEquals(3, actions1.size) - assertTrue(actions1.contains(ChannelAction.Storage.StoreState(aliceClosing.state))) - assertEquals(WatchConfirmed(alice0.channelId, bobHtlcSuccessTx, alice0.staticParams.nodeParams.minDepthBlocks, WatchConfirmed.ClosingTxConfirmed), actions1.findWatch()) - // alice extracts Bob's preimage from his htlc-success tx. - val addSettled = actions1.filterIsInstance().first() - assertEquals(ChannelAction.HtlcResult.Fulfill.OnChainFulfill(preimage), addSettled.result) - - val (alice2, actions2) = aliceClosing.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice0.channelId, WatchSpent.ClosingOutputSpent(10_000.sat), bobHtlcTimeoutTx))) - assertEquals(aliceClosing, alice2) - assertEquals(2, actions2.size) - assertTrue(actions2.contains(ChannelAction.Storage.StoreState(aliceClosing.state))) - assertEquals(WatchConfirmed(alice0.channelId, bobHtlcTimeoutTx, alice0.staticParams.nodeParams.minDepthBlocks, WatchConfirmed.ClosingTxConfirmed), actions2.findWatch()) - + // Alice sends htlcs to Bob with the same payment_hash. + val (nodes1, preimage, htlc1) = addHtlc(50_000_000.msat, alice0, bob0) + val (alice1, bob1) = nodes1 + val (alice2, bob2, htlc2) = addHtlc(makeCmdAdd(40_000_000.msat, bob0.staticParams.nodeParams.nodeId, alice0.currentBlockHeight.toLong(), preimage).second, alice1, bob1) + assertEquals(htlc1.paymentHash, htlc2.paymentHash) + val (alice3, bob3) = crossSign(alice2, bob2) + + // Bob has the preimage for those HTLCs, but he force-closes before Alice receives it. + val (bob4, actionsBob4) = bob3.process(ChannelCommand.Htlc.Settlement.Fulfill(htlc1.id, preimage)) + actionsBob4.hasOutgoingMessage() // ignored + val (_, remoteCommitPublished) = localClose(bob4) // + // Bob claims the htlc outputs from his own commit tx using its preimage. + assertEquals(setOf(htlc1.id, htlc2.id), remoteCommitPublished.htlcSuccessTxs().map { it.htlcId }.toSet()) + assertEquals(setOf(preimage.sha256()), remoteCommitPublished.htlcSuccessTxs().map { it.paymentHash }.toSet()) + val htlcSuccessTxs = remoteCommitPublished.htlcSuccessTxs().map { it.tx } + + // Alice extracts the preimage and forwards it to the payment handler. + val (alice4, actionsAlice4) = alice3.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice0.channelId, WatchSpent.ChannelSpent(TestConstants.fundingAmount), remoteCommitPublished.commitTx))) + actionsAlice4.has() + actionsAlice4.hasWatchConfirmed(remoteCommitPublished.commitTx.txid) + val (alice5, actionsAlice5) = alice4.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice0.channelId, WatchSpent.ClosingOutputSpent(50_000.sat), htlcSuccessTxs.first()))) + assertEquals(4, actionsAlice5.size) + actionsAlice5.has() + actionsAlice5.hasWatchConfirmed(htlcSuccessTxs.first().txid) + val addSettled = actionsAlice5.filterIsInstance() + assertEquals(setOf(htlc1, htlc2), addSettled.map { it.htlc }.toSet()) + assertEquals(setOf(ChannelAction.HtlcResult.Fulfill.OnChainFulfill(preimage)), addSettled.map { it.result }.toSet()) + + // The HTLC-success transaction confirms: nothing to do, preimage has already been relayed. + val (alice6, actionsAlice6) = alice5.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 42, 1, htlcSuccessTxs.first()))) + assertIs>(alice6) + assertEquals(1, actionsAlice6.size) + actionsAlice6.has() + + // The remaining transactions confirm. val watchConfirmed = listOf( - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 42, 0, bobHtlcSuccessTx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 42, 1, remoteCommitPublished.commitTx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 200, 0, remoteCommitPublished.claimMainOutputTx.tx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 202, 0, remoteCommitPublished.claimHtlcSuccessTxs()[1].tx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 202, 0, bobHtlcTimeoutTx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 202, 0, remoteCommitPublished.claimHtlcTimeoutTxs()[1].tx) + WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 42, 0, remoteCommitPublished.commitTx), + WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 47, 0, htlcSuccessTxs.last()), + WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 47, 0, alice6.state.remoteCommitPublished?.claimMainOutputTx?.tx!!), ) - confirmWatchedTxs(aliceClosing, watchConfirmed) + confirmWatchedTxs(alice6, watchConfirmed) } @Test @@ -878,7 +850,7 @@ class ClosingTestsCommon : LightningTestSuite() { } @Test - fun `recv ClosingTxConfirmed -- next remote commit -- followed by CMD_FULFILL_HTLC`() { + fun `recv ClosingTxConfirmed -- next remote commit -- followed by preimage`() { val (alice0, bob0) = reachNormal() val (aliceClosing, remoteCommitPublished, fulfill) = run { // An HTLC Bob -> Alice is cross-signed that will be fulfilled later. @@ -955,86 +927,192 @@ class ClosingTestsCommon : LightningTestSuite() { } @Test - fun `recv BITCOIN_OUTPUT_SPENT -- next remote commit`() { + fun `recv ClosingTxConfirmed -- next remote commit -- with settled htlcs`() { val (alice0, bob0) = reachNormal() - val (aliceClosing, remoteCommitPublished) = run { - val (nodes1, rb1, addAlice1) = addHtlc(20_000_000.msat, alice0, bob0) - val (alice1, bob1) = nodes1 - val (nodes2, rb2, addAlice2) = addHtlc(15_000_000.msat, alice1, bob1) - val (alice2, bob2) = nodes2 - val (nodes3, ra1, addBob1) = addHtlc(10_000_000.msat, bob2, alice2) - val (bob3, alice3) = nodes3 - val (nodes4, ra2, addBob2) = addHtlc(12_000_000.msat, bob3, alice3) - val (bob4, alice4) = nodes4 - val (alice5, bob5) = crossSign(alice4, bob4) - // add another htlc that bob doesn't revoke - val (nodes6, _, _) = addHtlc(20_000_000.msat, alice5, bob5) - val (alice6, bob6) = nodes6 - val (alice7, actionsAlice7) = alice6.process(ChannelCommand.Commitment.Sign) - val commitSig = actionsAlice7.hasOutgoingMessage() - val (bob7, actionsBob7) = bob6.process(ChannelCommand.MessageReceived(commitSig)) - actionsBob7.hasOutgoingMessage() // not forwarded to Alice (malicious Bob) - // alice is ready to claim incoming htlcs - val (alice8, _) = alice7.process(ChannelCommand.Htlc.Settlement.Fulfill(addBob1.id, ra1, commit = false)) - val (alice9, _) = alice8.process(ChannelCommand.Htlc.Settlement.Fulfill(addBob2.id, ra2, commit = false)) - // bob is ready to claim incoming htlcs - val (bob8, _) = bob7.process(ChannelCommand.Htlc.Settlement.Fulfill(addAlice1.id, rb1, commit = false)) - val (bob9, _) = bob8.process(ChannelCommand.Htlc.Settlement.Fulfill(addAlice2.id, rb2, commit = false)) - val bobCommitTx = (bob9.state as Normal).commitments.latest.localCommit.publishableTxs.commitTx.tx - assertEquals(9, bobCommitTx.txOut.size) // 2 main outputs, 2 anchors and 5 htlcs - - // alice publishes her commitment - val (alice10, localCommitPublished) = localClose(alice9) - // bob also publishes his next commitment, and wins the race to confirm - val (alice11, remoteCommitPublished) = remoteClose(bobCommitTx, alice10.copy(state = alice10.state.copy(localCommitPublished = null))) - assertEquals(2, localCommitPublished.htlcTimeoutTxs().size) - assertEquals(2, localCommitPublished.htlcSuccessTxs().size) - assertEquals(4, localCommitPublished.claimHtlcDelayedTxs.size) - assertEquals(2, remoteCommitPublished.claimHtlcSuccessTxs().size) - assertEquals(3, remoteCommitPublished.claimHtlcTimeoutTxs().size) - - Pair(alice11, remoteCommitPublished) + // Alice sends two htlcs to Bob. + val (nodes1, preimage1, htlc1) = addHtlc(30_000_000.msat, alice0, bob0) + val (nodes2, _, htlc2) = addHtlc(30_000_000.msat, nodes1.first, nodes1.second) + val (alice3, bob3) = crossSign(nodes2.first, nodes2.second) + + // Bob fulfills one HTLC and fails the other one without revoking its previous commitment. + val (bob4, actionsBob4) = bob3.process(ChannelCommand.Htlc.Settlement.Fulfill(htlc1.id, preimage1)) + val fulfill = actionsBob4.hasOutgoingMessage() + val (bob5, actionsBob5) = bob4.process(ChannelCommand.Htlc.Settlement.Fail(htlc2.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure))) + val fail = actionsBob5.hasOutgoingMessage() + val (bob6, actionsBob6) = bob5.process(ChannelCommand.Commitment.Sign) + val commitBob = actionsBob6.hasOutgoingMessage() + val (alice4, actionsAlice4) = alice3.process(ChannelCommand.MessageReceived(fulfill)) + val addSettled = actionsAlice4.find() + assertEquals(htlc1, addSettled.htlc) + assertEquals(preimage1, addSettled.result.paymentPreimage) + val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(fail)) + assertTrue(actionsAlice5.isEmpty()) // we don't settle the failed HTLC until revoked + val (alice6, actionsAlice6) = alice5.process(ChannelCommand.MessageReceived(commitBob)) + assertEquals(3, actionsAlice6.size) + actionsAlice6.has() + val revokeAlice = actionsAlice6.hasOutgoingMessage() + actionsAlice6.hasCommand() + val (alice7, actionsAlice7) = alice6.process(ChannelCommand.Commitment.Sign) + val commitAlice = actionsAlice7.hasOutgoingMessage() + val (bob7, _) = bob6.process(ChannelCommand.MessageReceived(revokeAlice)) + val (bob8, actionsBob8) = bob7.process(ChannelCommand.MessageReceived(commitAlice)) + actionsBob8.hasOutgoingMessage() // ignored + + // Bob closes the channel using his latest commitment, which doesn't contain any htlc. + val bobCommit = bob8.commitments.latest.localCommit + assertTrue(bobCommit.publishableTxs.htlcTxsAndSigs.isEmpty()) + val commitTx = bobCommit.publishableTxs.commitTx.tx + val (alice8, actionsAlice8) = alice7.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice0.channelId, WatchSpent.ChannelSpent(TestConstants.fundingAmount), commitTx))) + assertTrue(actionsAlice8.filterIsInstance().isEmpty()) + actionsAlice8.hasWatchConfirmed(commitTx.txid) + val (_, actionsAlice9) = alice8.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 40, 3, commitTx))) + // The two HTLCs have been overridden by the on-chain commit. + // The first one is a no-op since we already relayed the fulfill upstream. + val addFailed = actionsAlice9.filterIsInstance() + assertEquals(setOf(htlc1, htlc2), addFailed.map { it.htlc }.toSet()) + addFailed.forEach { assertIs(it.result) } + } + + @Test + fun `recv BITCOIN_OUTPUT_SPENT -- next remote commit -- extract preimage from removed HTLC`() { + val (alice0, bob0) = reachNormal() + // Alice sends htlcs to Bob with the same payment_hash. + val (nodes1, preimage, htlc1) = addHtlc(50_000_000.msat, alice0, bob0) + val (alice2, bob2, htlc2) = addHtlc(makeCmdAdd(40_000_000.msat, bob0.staticParams.nodeParams.nodeId, alice0.currentBlockHeight.toLong(), preimage).second, nodes1.first, nodes1.second) + assertEquals(htlc1.paymentHash, htlc2.paymentHash) + // And another HTLC with a different payment_hash. + val (nodes3, _, htlc3) = addHtlc(60_000_000.msat, alice2, bob2) + val (alice4, bob4) = crossSign(nodes3.first, nodes3.second) + + // Bob has the preimage for the first two HTLCs, but he fails them instead of fulfilling them. + val alice5 = run { + val (alice5, bob5) = failHtlc(htlc1.id, alice4, bob4) + val (alice6, bob6) = failHtlc(htlc2.id, alice5, bob5) + val (alice7, bob7) = failHtlc(htlc3.id, alice6, bob6) + val (_, actionsBob8) = bob7.process(ChannelCommand.Commitment.Sign) + val (alice8, actionsAlice8) = alice7.process(ChannelCommand.MessageReceived(actionsBob8.findOutgoingMessage())) + actionsAlice8.hasOutgoingMessage() + alice8 } - assertNotNull(aliceClosing.state.nextRemoteCommitPublished) - assertNotNull(remoteCommitPublished.claimMainOutputTx) + // At that point, the HTLCs are not in Alice's commitment anymore. + // But Bob has not revoked his commitment yet that contains them. + // Bob claims the htlc outputs from his previous commit tx using its preimage. + val remoteCommitPublished = run { + val (bob5, _) = bob4.process(ChannelCommand.Htlc.Settlement.Fulfill(htlc1.id, preimage)) + val (bob6, _) = bob5.process(ChannelCommand.Htlc.Settlement.Fulfill(htlc2.id, preimage)) + val (_, remoteCommitPublished) = localClose(bob6) + assertEquals(3, remoteCommitPublished.htlcTxs.size) + assertEquals(2, remoteCommitPublished.htlcSuccessTxs().size) // Bob doesn't have the preimage for the last HTLC. + remoteCommitPublished + } - // Bob claims 2 htlc outputs, alice will claim the other 3. - val bobHtlcSuccessTx = Transaction( - version = 2, - txIn = listOf(TxIn(remoteCommitPublished.claimHtlcTimeoutTxs()[0].input.outPoint, ByteVector.empty, 0, ScriptWitness.empty)), - txOut = emptyList(), - lockTime = 0 - ) - val bobHtlcTimeoutTx = Transaction( - version = 2, - txIn = listOf(TxIn(remoteCommitPublished.claimHtlcSuccessTxs()[0].input.outPoint, ByteVector.empty, 0, ScriptWitness.empty)), - txOut = emptyList(), - lockTime = 0 + // Alice prepares Claim-HTLC-timeout transactions for each HTLC. + val (alice6, actionsAlice6) = alice5.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice0.channelId, WatchSpent.ChannelSpent(TestConstants.fundingAmount), remoteCommitPublished.commitTx))) + assertIs>(alice6) + actionsAlice6.has() + assertNotNull(alice6.state.remoteCommitPublished) + actionsAlice6.hasWatchConfirmed(remoteCommitPublished.commitTx.txid) + alice6.state.remoteCommitPublished.claimMainOutputTx?.let { + actionsAlice6.hasPublishTx(it.tx) + actionsAlice6.hasWatchConfirmed(it.tx.txid) + } + val claimHtlcTimeoutTxs = alice6.state.remoteCommitPublished.claimHtlcTimeoutTxs() + assertEquals(setOf(htlc1.id, htlc2.id, htlc3.id), claimHtlcTimeoutTxs.map { it.htlcId }.toSet()) + claimHtlcTimeoutTxs.forEach { actionsAlice6.hasPublishTx(it.tx) } + assertEquals(claimHtlcTimeoutTxs.map { it.input.outPoint }.toSet(), actionsAlice6.findWatches().map { OutPoint(it.txId, it.outputIndex.toLong()) }.toSet()) + + // Bob's commitment confirms. + val (alice7, actionsAlice7) = alice6.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 42, 7, remoteCommitPublished.commitTx))) + assertEquals(1, actionsAlice7.size) + actionsAlice7.has() + + // Alice extracts the preimage from Bob's HTLC-success and forwards it upstream. + val (alice8, actionsAlice8) = alice7.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice0.channelId, WatchSpent.ClosingOutputSpent(50_000.sat), remoteCommitPublished.htlcSuccessTxs().first().tx))) + val addSettled = actionsAlice8.filterIsInstance() + assertEquals(setOf(htlc1, htlc2), addSettled.map { it.htlc }.toSet()) + assertEquals(setOf(ChannelAction.HtlcResult.Fulfill.OnChainFulfill(preimage)), addSettled.map { it.result }.toSet()) + + // Alice's Claim-HTLC-timeout transaction confirms: we relay the failure upstream. + val claimHtlcTimeout = claimHtlcTimeoutTxs.find { it.htlcId == htlc3.id } + assertNotNull(claimHtlcTimeout) + val (alice9, actionsAlice9) = alice8.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 43, 1, claimHtlcTimeout.tx))) + assertIs>(alice9) + assertEquals(2, actionsAlice9.size) + actionsAlice9.has() + val addFailed = actionsAlice9.filterIsInstance() + assertEquals(setOf(htlc3), addFailed.map { it.htlc }.toSet()) + + // The remaining transactions confirm. + val watchConfirmed = listOf( + WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 47, 0, remoteCommitPublished.htlcSuccessTxs().first().tx), + WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 47, 1, remoteCommitPublished.htlcSuccessTxs().last().tx), + WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 47, 0, alice9.state.remoteCommitPublished?.claimMainOutputTx?.tx!!), ) + confirmWatchedTxs(alice9, watchConfirmed) + } - val (alice1, actions1) = aliceClosing.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice0.channelId, WatchSpent.ClosingOutputSpent(20_000.sat), bobHtlcSuccessTx))) - assertEquals(aliceClosing, alice1) - assertEquals(2, actions1.size) - assertTrue(actions1.contains(ChannelAction.Storage.StoreState(aliceClosing.state))) - assertEquals(WatchConfirmed(alice0.channelId, bobHtlcSuccessTx, alice0.staticParams.nodeParams.minDepthBlocks, WatchConfirmed.ClosingTxConfirmed), actions1.findWatch()) - - val (alice2, actions2) = aliceClosing.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice0.channelId, WatchSpent.ClosingOutputSpent(10_000.sat), bobHtlcTimeoutTx))) - assertEquals(aliceClosing, alice2) - assertEquals(2, actions2.size) - assertTrue(actions2.contains(ChannelAction.Storage.StoreState(aliceClosing.state))) - assertEquals(WatchConfirmed(alice0.channelId, bobHtlcTimeoutTx, alice0.staticParams.nodeParams.minDepthBlocks, WatchConfirmed.ClosingTxConfirmed), actions2.findWatch()) - + @Test + fun `recv BITCOIN_OUTPUT_SPENT -- next remote commit -- extract preimage from next batch of HTLCs`() { + val (alice0, bob0) = reachNormal() + // Alice sends htlcs to Bob with the same payment_hash. + val (nodes1, preimage, htlc1) = addHtlc(50_000_000.msat, alice0, bob0) + val (alice2, bob2, htlc2) = addHtlc(makeCmdAdd(40_000_000.msat, bob0.staticParams.nodeParams.nodeId, alice0.currentBlockHeight.toLong(), preimage).second, nodes1.first, nodes1.second) + assertEquals(htlc1.paymentHash, htlc2.paymentHash) + // And another HTLC with a different payment_hash. + val (nodes3, _, htlc3) = addHtlc(60_000_000.msat, alice2, bob2) + val (alice3, bob3) = nodes3 + val (alice4, _) = alice3.process(ChannelCommand.Commitment.Sign) + // We want to test what happens when we stop at that point. + // But for Bob to create HTLC transactions, he must have received Alice's revocation. + // So for the sake of the test, we exchange revocation and then reset Alice's state. + val (_, bob4) = crossSign(alice3, bob3) + + // At that point, the HTLCs are not in Alice's commitment yet. + val (bob5, remoteCommitPublished) = localClose(bob4) + assertEquals(3, remoteCommitPublished.htlcTxs.size) + // Bob doesn't have the preimage yet for any of those HTLCs. + remoteCommitPublished.htlcTxs.forEach { assertNull(it.value) } + // Bob receives the preimage for the first two HTLCs. + val (bob6, actionsBob6) = bob5.process(ChannelCommand.Htlc.Settlement.Fulfill(htlc1.id, preimage)) + assertIs>(bob6) + assertEquals(setOf(htlc1.id, htlc2.id), bob6.state.localCommitPublished?.htlcSuccessTxs().orEmpty().map { it.htlcId }.toSet()) + val htlcSuccessTxs = actionsBob6.filterIsInstance().filter { it.txType == ChannelAction.Blockchain.PublishTx.Type.HtlcSuccessTx } + assertEquals(2, htlcSuccessTxs.size) + val batchHtlcSuccessTx = Transaction(2, htlcSuccessTxs.flatMap { it.tx.txIn }, htlcSuccessTxs.flatMap { it.tx.txOut }, 0) + + // Alice prepares Claim-HTLC-timeout transactions for each HTLC. + val (alice5, actionsAlice5) = alice4.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice0.channelId, WatchSpent.ChannelSpent(TestConstants.fundingAmount), remoteCommitPublished.commitTx))) + assertIs>(alice5) + val rcp = alice5.state.nextRemoteCommitPublished + assertNotNull(rcp) + assertEquals(setOf(htlc1.id, htlc2.id, htlc3.id), rcp.claimHtlcTxs.values.filterNotNull().map { it.htlcId }.toSet()) + val claimHtlcTimeoutTxs = actionsAlice5.filterIsInstance().filter { it.txType == ChannelAction.Blockchain.PublishTx.Type.ClaimHtlcTimeoutTx } + assertEquals(3, claimHtlcTimeoutTxs.size) + assertEquals(rcp.claimHtlcTxs.keys, actionsAlice5.findWatches().map { OutPoint(it.txId, it.outputIndex.toLong()) }.toSet()) + + // Alice extracts the preimage from Bob's batched HTLC-success and forwards it upstream. + val (alice6, actionsAlice6) = alice5.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice0.channelId, WatchSpent.ClosingOutputSpent(50_000.sat), batchHtlcSuccessTx))) + val addSettled = actionsAlice6.filterIsInstance() + assertEquals(setOf(htlc1, htlc2), addSettled.map { it.htlc }.toSet()) + assertEquals(setOf(ChannelAction.HtlcResult.Fulfill.OnChainFulfill(preimage)), addSettled.map { it.result }.toSet()) + actionsAlice6.hasWatchConfirmed(batchHtlcSuccessTx.txid) + + // Alice's Claim-HTLC-timeout transaction confirms: we relay the failure upstream. + val claimHtlcTimeout = rcp.claimHtlcTimeoutTxs().find { it.htlcId == htlc3.id } + assertNotNull(claimHtlcTimeout) + val (alice7, actionsAlice7) = alice6.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 42, 0, claimHtlcTimeout.tx))) + assertIs>(alice7) + val addFailed = actionsAlice7.filterIsInstance() + assertEquals(setOf(htlc3), addFailed.map { it.htlc }.toSet()) + + // The remaining transactions confirm. val watchConfirmed = listOf( - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 42, 0, bobHtlcSuccessTx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 42, 1, remoteCommitPublished.commitTx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 200, 0, remoteCommitPublished.claimMainOutputTx.tx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 202, 0, remoteCommitPublished.claimHtlcSuccessTxs()[1].tx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 202, 0, bobHtlcTimeoutTx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 202, 0, remoteCommitPublished.claimHtlcTimeoutTxs()[2].tx), - WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 202, 0, remoteCommitPublished.claimHtlcTimeoutTxs()[1].tx) + WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 40, 0, remoteCommitPublished.commitTx), + WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 41, 1, batchHtlcSuccessTx), + WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 47, 7, alice7.state.nextRemoteCommitPublished?.claimMainOutputTx?.tx!!), ) - confirmWatchedTxs(aliceClosing, watchConfirmed) + confirmWatchedTxs(alice7, watchConfirmed) } @Test diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt index 11cb63698..3bbb6d4c1 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt @@ -1356,89 +1356,185 @@ class SpliceTestsCommon : LightningTestSuite() { @Test fun `force-close -- revoked previous active commitment`() { val (alice, bob) = reachNormalWithConfirmedFundingTx() - val (alice0, bob0, _) = setupHtlcs(alice, bob) - val (alice1, bob1) = spliceOut(alice0, bob0, 50_000.sat) - val bobCommitTx = bob1.commitments.active.last().localCommit.publishableTxs.commitTx.tx - - // Alice sends an HTLC to Bob, which revokes the previous commitment. - val (nodes2, preimage, htlc) = addHtlc(25_000_000.msat, alice1, bob1) - val (alice3, bob3) = crossSign(nodes2.first, nodes2.second, commitmentsCount = 2) - val (alice4, bob4) = fulfillHtlc(htlc.id, preimage, alice3, bob3) - val (bob5, alice5) = crossSign(bob4, alice4, commitmentsCount = 2) - assertEquals(alice5.commitments.active.size, 2) - assertEquals(bob5.commitments.active.size, 2) - - // Bob force-closes using the revoked commitment. - handlePreviousRevokedRemoteClose(alice5, bobCommitTx) - } - - @Test - fun `force-close -- revoked previous inactive commitment`() { - val (alice, bob) = reachNormalWithConfirmedFundingTx(zeroConf = true) - val (alice0, bob0, _) = setupHtlcs(alice, bob) + val (alice0, bob0, htlcs) = setupHtlcs(alice, bob) + // We make a first splice transaction, but don't exchange splice_locked. val (alice1, bob1) = spliceOut(alice0, bob0, 50_000.sat) - val spliceTx = alice1.commitments.latest.localFundingStatus.signedTx!! - val (alice2, _) = alice1.process(ChannelCommand.MessageReceived(SpliceLocked(alice.channelId, spliceTx.txid))) - assertIs>(alice2) - assertEquals(alice2.commitments.active.size, 1) - assertEquals(alice2.commitments.inactive.size, 1) - val (bob2, _) = bob1.process(ChannelCommand.MessageReceived(SpliceLocked(bob.channelId, spliceTx.txid))) - assertIs>(bob2) - assertEquals(bob2.commitments.active.size, 1) - assertEquals(bob2.commitments.inactive.size, 1) - val bobCommitTx = bob2.commitments.inactive.first().localCommit.publishableTxs.commitTx.tx - - // Alice sends an HTLC to Bob, which revokes the inactive commitment. - val (nodes3, preimage, htlc) = addHtlc(25_000_000.msat, alice2, bob2) - val (alice4, bob4) = crossSign(nodes3.first, nodes3.second, commitmentsCount = 1) - val (alice5, bob5) = fulfillHtlc(htlc.id, preimage, alice4, bob4) - val (_, alice6) = crossSign(bob5, alice5, commitmentsCount = 1) - - // Bob force-closes using the revoked commitment. - handlePreviousRevokedRemoteClose(alice6, bobCommitTx) + val spliceTx1 = bob1.commitments.latest.localFundingStatus.signedTx!! + val bobRevokedCommitTx = bob1.commitments.active.last().localCommit.publishableTxs.commitTx.tx + // We make a second splice transaction, but don't exchange splice_locked. + val (alice2, bob2) = spliceOut(alice1, bob1, 60_000.sat) + // From Alice's point of view, we now have two unconfirmed splices, both active. + // They both send additional HTLCs, that apply to both commitments. + val (nodes3, _, _) = addHtlc(10_000_000.msat, bob2, alice2) + val (bob3, alice3) = nodes3 + val (nodes4, _, htlcOut1) = addHtlc(20_000_000.msat, alice3, bob3) + val (alice5, bob5) = crossSign(nodes4.first, nodes4.second, commitmentsCount = 3) + // Alice adds another HTLC that isn't signed by Bob. + val (nodes6, _, htlcOut2) = addHtlc(15_000_000.msat, alice5, bob5) + val (alice6, bob6) = nodes6 + val (alice7, actionsAlice7) = alice6.process(ChannelCommand.Commitment.Sign) + actionsAlice7.hasOutgoingMessage() // Bob ignores Alice's message + assertEquals(3, bob6.commitments.active.size) + assertEquals(3, alice7.commitments.active.size) + + // The first splice transaction confirms. + val (alice8, actionsAlice8) = alice7.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ChannelFundingDepthOk, 40, 2, spliceTx1))) + actionsAlice8.has() + actionsAlice8.hasWatchFundingSpent(spliceTx1.txid) + + // Bob publishes a revoked commitment for fundingTx1! + val (alice9, actionsAlice9) = alice8.process(ChannelCommand.WatchReceived(WatchSpentTriggered(bob0.channelId, WatchSpent.ChannelSpent(TestConstants.fundingAmount), bobRevokedCommitTx))) + assertIs>(alice9) + assertEquals(WatchConfirmed.AlternativeCommitTxConfirmed, actionsAlice9.hasWatchConfirmed(bobRevokedCommitTx.txid).event) + + // Bob's revoked commit tx confirms. + val (alice10, actionsAlice10) = alice9.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(bob0.channelId, WatchConfirmed.AlternativeCommitTxConfirmed, 41, 7, bobRevokedCommitTx))) + assertIs>(alice10) + actionsAlice10.hasWatchConfirmed(bobRevokedCommitTx.txid).also { assertEquals(WatchConfirmed.ClosingTxConfirmed, it.event) } + val rvk = alice10.state.revokedCommitPublished.firstOrNull() + assertNotNull(rvk) + // Alice reacts by punishing Bob. + assertNotNull(rvk.claimMainOutputTx) + actionsAlice10.hasPublishTx(rvk.claimMainOutputTx.tx) + actionsAlice10.hasWatchConfirmed(rvk.claimMainOutputTx.tx.txid) + assertNotNull(rvk.mainPenaltyTx) + actionsAlice10.hasPublishTx(rvk.mainPenaltyTx.tx) + actionsAlice10.hasWatch().also { assertEquals(rvk.mainPenaltyTx.input.outPoint, OutPoint(it.txId, it.outputIndex.toLong())) } + Transaction.correctlySpends(rvk.mainPenaltyTx.tx, listOf(bobRevokedCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + actionsAlice10.findWatches().map { OutPoint(it.txId, it.outputIndex.toLong()) } + // Alice marks every outgoing HTLC as failed, including the ones that don't appear in the revoked commitment. + val outgoingHtlcs = htlcs.aliceToBob.map { it.second }.toSet() + setOf(htlcOut1, htlcOut2) + val addSettled = actionsAlice10.filterIsInstance() + assertEquals(outgoingHtlcs, addSettled.map { it.htlc }.toSet()) + addSettled.forEach { assertTrue(it.result == ChannelAction.HtlcResult.Fail.OnChainFail(HtlcOverriddenByRemoteCommit(it.htlc.channelId, it.htlc))) } + val getHtlcInfos = actionsAlice10.find() + assertEquals(bobRevokedCommitTx.txid, getHtlcInfos.revokedCommitTxId) + // Alice claims every HTLC output from the revoked commitment. + val htlcInfos = (htlcs.aliceToBob + htlcs.bobToAlice).map { ChannelAction.Storage.HtlcInfo(bob0.channelId, getHtlcInfos.commitmentNumber, it.second.paymentHash, it.second.cltvExpiry) } + val (alice11, actionsAlice11) = alice10.process(ChannelCommand.Closing.GetHtlcInfosResponse(bobRevokedCommitTx.txid, htlcInfos)) + assertIs>(alice11) + val htlcPenaltyTxs = alice11.state.revokedCommitPublished.first().htlcPenaltyTxs + assertEquals(htlcs.aliceToBob.size + htlcs.bobToAlice.size, htlcPenaltyTxs.map { it.input.outPoint }.toSet().size) + htlcPenaltyTxs.forEach { + actionsAlice11.hasPublishTx(it.tx) + Transaction.correctlySpends(it.tx, listOf(bobRevokedCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + } + assertTrue(actionsAlice11.findWatches().map { OutPoint(it.txId, it.outputIndex.toLong()) }.toSet().containsAll(htlcPenaltyTxs.map { it.input.outPoint })) + + // The remaining transactions confirm. + val (alice12, _) = alice11.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 57, 0, bobRevokedCommitTx))) + val (alice13, _) = alice12.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 57, 1, rvk.claimMainOutputTx.tx))) + val (alice14, _) = alice13.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 57, 3, rvk.mainPenaltyTx.tx))) + val (alice15, _) = alice14.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 57, 2, htlcPenaltyTxs[0].tx))) + val (alice16, _) = alice15.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 57, 5, htlcPenaltyTxs[1].tx))) + val (alice17, _) = alice16.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 57, 7, htlcPenaltyTxs[2].tx))) + assertIs(alice17.state) + val (alice18, _) = alice17.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 57, 6, htlcPenaltyTxs[3].tx))) + assertIs(alice18.state) } @Test - fun `force-close -- revoked previous inactive commitment after two splices`() { + fun `force-close -- revoked inactive commitment`() { val (alice, bob) = reachNormalWithConfirmedFundingTx(zeroConf = true) - val (alice0, bob0, _) = setupHtlcs(alice, bob) + val (alice0, bob0, htlcs) = setupHtlcs(alice, bob) val (alice1, bob1) = spliceOut(alice0, bob0, 50_000.sat) - val spliceTx = alice1.commitments.latest.localFundingStatus.signedTx!! - val (alice2, _) = alice1.process(ChannelCommand.MessageReceived(SpliceLocked(alice.channelId, spliceTx.txid))) + val spliceTx1 = alice1.commitments.latest.localFundingStatus.signedTx!! + val (alice2, _) = alice1.process(ChannelCommand.MessageReceived(SpliceLocked(alice.channelId, spliceTx1.txid))) assertIs>(alice2) assertEquals(alice2.commitments.active.size, 1) assertEquals(alice2.commitments.inactive.size, 1) - val (bob2, _) = bob1.process(ChannelCommand.MessageReceived(SpliceLocked(bob.channelId, spliceTx.txid))) + val (bob2, _) = bob1.process(ChannelCommand.MessageReceived(SpliceLocked(bob.channelId, spliceTx1.txid))) assertIs>(bob2) assertEquals(bob2.commitments.active.size, 1) assertEquals(bob2.commitments.inactive.size, 1) - val bobCommitTx = bob2.commitments.inactive.first().localCommit.publishableTxs.commitTx.tx + val bobRevokedCommitTx = bob2.commitments.inactive.first().localCommit.publishableTxs.commitTx.tx // Alice sends an HTLC to Bob, which revokes the inactive commitment. - val (nodes3, preimage, htlc) = addHtlc(25_000_000.msat, alice2, bob2) + val (nodes3, _, htlcOut1) = addHtlc(25_000_000.msat, alice2, bob2) val (alice4, bob4) = crossSign(nodes3.first, nodes3.second, commitmentsCount = 1) - val (alice5, bob5) = fulfillHtlc(htlc.id, preimage, alice4, bob4) - val (bob6, alice6) = crossSign(bob5, alice5, commitmentsCount = 1) - - val (alice7, bob7) = spliceOut(alice6, bob6, 50_000.sat) - val spliceTx1 = alice7.commitments.latest.localFundingStatus.signedTx!! - val (alice8, _) = alice7.process(ChannelCommand.MessageReceived(SpliceLocked(alice.channelId, spliceTx1.txid))) - assertIs>(alice8) - assertEquals(alice8.commitments.active.size, 1) - assertEquals(alice8.commitments.inactive.size, 2) - val (bob8, _) = bob7.process(ChannelCommand.MessageReceived(SpliceLocked(bob.channelId, spliceTx1.txid))) - assertIs>(bob8) - assertEquals(bob8.commitments.active.size, 1) - assertEquals(bob8.commitments.inactive.size, 2) - // Alice sends an HTLC to Bob, which revokes the inactive commitment. - val (nodes9, preimage1, htlc1) = addHtlc(25_000_000.msat, alice8, bob8) - val (alice10, bob10) = crossSign(nodes9.first, nodes9.second, commitmentsCount = 1) - val (alice11, bob11) = fulfillHtlc(htlc1.id, preimage1, alice10, bob10) - val (_, alice12) = crossSign(bob11, alice11, commitmentsCount = 1) - - // Bob force-closes using the revoked commitment. - handlePreviousRevokedRemoteClose(alice12, bobCommitTx) + // We create another splice transaction. + val (alice5, bob5) = spliceOut(alice4, bob4, 50_000.sat) + val spliceTx2 = alice5.commitments.latest.localFundingStatus.signedTx!! + val (alice6, _) = alice5.process(ChannelCommand.MessageReceived(SpliceLocked(alice.channelId, spliceTx2.txid))) + assertIs>(alice6) + assertEquals(alice6.commitments.active.size, 1) + assertEquals(alice6.commitments.inactive.size, 2) + val (bob6, _) = bob5.process(ChannelCommand.MessageReceived(SpliceLocked(bob.channelId, spliceTx2.txid))) + assertIs>(bob6) + assertEquals(bob6.commitments.active.size, 1) + assertEquals(bob6.commitments.inactive.size, 2) + + // Alice sends another HTLC to Bob, which revokes the inactive commitment. + val (nodes7, _, htlcOut2) = addHtlc(25_000_000.msat, alice6, bob6) + val (alice7, bob7) = nodes7 + // Bob also sends an HTLC to Alice. + val (nodes8, _, htlcIn) = addHtlc(15_000_000.msat, bob7, alice7) + val (bob8, alice8) = nodes8 + val (alice9, bob9) = crossSign(alice8, bob8, commitmentsCount = 1) + // Alice adds another HTLC that isn't signed by Bob. + val (nodes10, _, htlcOut3) = addHtlc(20_000_000.msat, alice9, bob9) + val (alice10, _) = nodes10 + val (alice11, actionsAlice11) = alice10.process(ChannelCommand.Commitment.Sign) + actionsAlice11.hasOutgoingMessage() // Bob ignores Alice's message + + // Bob publishes his latest commitment for the initial fundingTx, which is now revoked. + val (alice12, actionsAlice12) = alice11.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice.channelId, WatchSpent.ChannelSpent(TestConstants.fundingAmount), bobRevokedCommitTx))) + assertIs>(alice12) + assertEquals(actionsAlice12.hasWatchConfirmed(bobRevokedCommitTx.txid).event, WatchConfirmed.AlternativeCommitTxConfirmed) + // Alice attempts to force-close with her latest active commitment. + val localCommit = actionsAlice12.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.CommitTx) + assertEquals(localCommit.txid, alice12.commitments.active.first().localCommit.publishableTxs.commitTx.tx.txid) + val localOutgoingHtlcs = htlcs.aliceToBob.map { it.second }.toSet() + setOf(htlcOut1, htlcOut2) // the last HTLC was not signed by Bob yet + assertEquals(localOutgoingHtlcs.size, actionsAlice12.filterIsInstance().filter { it.txType == ChannelAction.Blockchain.PublishTx.Type.HtlcTimeoutTx }.size) + val incomingHtlcs = htlcs.bobToAlice.map { it.second }.toSet() + setOf(htlcIn) + assertEquals(incomingHtlcs.size + localOutgoingHtlcs.size, actionsAlice12.findWatches().size) + actionsAlice12.has() + + // Bob's revoked commit tx confirms. + val (alice13, actionsAlice13) = alice12.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(bob0.channelId, WatchConfirmed.AlternativeCommitTxConfirmed, 41, 7, bobRevokedCommitTx))) + assertIs>(alice13) + actionsAlice13.hasWatchConfirmed(bobRevokedCommitTx.txid).also { assertEquals(WatchConfirmed.ClosingTxConfirmed, it.event) } + val rvk = alice13.state.revokedCommitPublished.firstOrNull() + assertNotNull(rvk) + // Alice reacts by punishing Bob. + assertNotNull(rvk.claimMainOutputTx) + actionsAlice13.hasPublishTx(rvk.claimMainOutputTx.tx) + actionsAlice13.hasWatchConfirmed(rvk.claimMainOutputTx.tx.txid) + assertNotNull(rvk.mainPenaltyTx) + actionsAlice13.hasPublishTx(rvk.mainPenaltyTx.tx) + actionsAlice13.hasWatch().also { assertEquals(rvk.mainPenaltyTx.input.outPoint, OutPoint(it.txId, it.outputIndex.toLong())) } + Transaction.correctlySpends(rvk.mainPenaltyTx.tx, listOf(bobRevokedCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + actionsAlice13.findWatches().map { OutPoint(it.txId, it.outputIndex.toLong()) } + // Alice marks every outgoing HTLC as failed, including the ones that don't appear in the revoked commitment. + val addSettled = actionsAlice13.filterIsInstance() + val outgoingHtlcs = htlcs.aliceToBob.map { it.second }.toSet() + setOf(htlcOut1, htlcOut2, htlcOut3) + assertEquals(outgoingHtlcs, addSettled.map { it.htlc }.toSet()) + addSettled.forEach { assertTrue(it.result == ChannelAction.HtlcResult.Fail.OnChainFail(HtlcOverriddenByRemoteCommit(it.htlc.channelId, it.htlc))) } + val getHtlcInfos = actionsAlice13.find() + assertEquals(bobRevokedCommitTx.txid, getHtlcInfos.revokedCommitTxId) + // Alice claims every HTLC output from the revoked commitment. + val htlcInfos = (htlcs.aliceToBob + htlcs.bobToAlice).map { ChannelAction.Storage.HtlcInfo(bob0.channelId, getHtlcInfos.commitmentNumber, it.second.paymentHash, it.second.cltvExpiry) } + val (alice14, actionsAlice14) = alice13.process(ChannelCommand.Closing.GetHtlcInfosResponse(bobRevokedCommitTx.txid, htlcInfos)) + assertIs>(alice14) + val htlcPenaltyTxs = alice14.state.revokedCommitPublished.first().htlcPenaltyTxs + assertEquals(htlcs.aliceToBob.size + htlcs.bobToAlice.size, htlcPenaltyTxs.map { it.input.outPoint }.toSet().size) + htlcPenaltyTxs.forEach { + actionsAlice14.hasPublishTx(it.tx) + Transaction.correctlySpends(it.tx, listOf(bobRevokedCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + } + assertTrue(actionsAlice14.findWatches().map { OutPoint(it.txId, it.outputIndex.toLong()) }.toSet().containsAll(htlcPenaltyTxs.map { it.input.outPoint })) + + // The remaining transactions confirm. + val (alice15, _) = alice14.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 57, 0, bobRevokedCommitTx))) + val (alice16, _) = alice15.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 57, 1, rvk.claimMainOutputTx.tx))) + val (alice17, _) = alice16.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 57, 3, rvk.mainPenaltyTx.tx))) + val (alice18, _) = alice17.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 57, 2, htlcPenaltyTxs[0].tx))) + val (alice19, _) = alice18.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 57, 5, htlcPenaltyTxs[1].tx))) + val (alice20, _) = alice19.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 57, 7, htlcPenaltyTxs[2].tx))) + assertIs(alice20.state) + val (alice21, _) = alice20.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice0.channelId, WatchConfirmed.ClosingTxConfirmed, 57, 6, htlcPenaltyTxs[3].tx))) + assertIs(alice21.state) } @Test @@ -1808,15 +1904,20 @@ class SpliceTestsCommon : LightningTestSuite() { val (alice2, actionsAlice2) = alice1.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice1.channelId, WatchSpent.ChannelSpent(TestConstants.fundingAmount), bobCommitTx))) assertIs(alice2.state) assertEquals(actionsAlice2.size, 9) - val claimMain = actionsAlice2.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.ClaimRemoteDelayedOutputTx) - Transaction.correctlySpends(claimMain, bobCommitTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - val claimRemotePenalty = actionsAlice2.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.MainPenaltyTx) - Transaction.correctlySpends(claimRemotePenalty, bobCommitTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + val rvk = alice2.state.revokedCommitPublished.firstOrNull() + assertNotNull(rvk) + assertNotNull(rvk.claimMainOutputTx) + actionsAlice2.hasPublishTx(rvk.claimMainOutputTx.tx) + Transaction.correctlySpends(rvk.claimMainOutputTx.tx, bobCommitTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + assertNotNull(rvk.mainPenaltyTx) + actionsAlice2.hasPublishTx(rvk.mainPenaltyTx.tx) + Transaction.correctlySpends(rvk.mainPenaltyTx.tx, bobCommitTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) val watchCommitConfirmed = actionsAlice2.hasWatchConfirmed(bobCommitTx.txid) - actionsAlice2.hasWatchConfirmed(claimMain.txid) + assertEquals(WatchConfirmed.ClosingTxConfirmed, watchCommitConfirmed.event) + actionsAlice2.hasWatchConfirmed(rvk.claimMainOutputTx.tx.txid) val watchSpent = actionsAlice2.hasWatch() - assertEquals(watchSpent.txId, claimRemotePenalty.txIn.first().outPoint.txid) - assertEquals(watchSpent.outputIndex, claimRemotePenalty.txIn.first().outPoint.index.toInt()) + assertEquals(watchSpent.txId, rvk.mainPenaltyTx.input.outPoint.txid) + assertEquals(watchSpent.outputIndex, rvk.mainPenaltyTx.input.outPoint.index.toInt()) assertEquals(actionsAlice2.find().revokedCommitTxId, bobCommitTx.txid) actionsAlice2.hasOutgoingMessage() actionsAlice2.has() @@ -1828,66 +1929,16 @@ class SpliceTestsCommon : LightningTestSuite() { actionsAlice3.has() // Alice's transactions confirm. - val (alice4, actionsAlice4) = alice3.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice3.channelId, WatchConfirmed.ClosingTxConfirmed, alice3.currentBlockHeight, 44, claimMain))) + val (alice4, actionsAlice4) = alice3.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice3.channelId, WatchConfirmed.ClosingTxConfirmed, alice3.currentBlockHeight, 44, rvk.claimMainOutputTx.tx))) assertIs(alice4.state) assertEquals(actionsAlice4.size, 1) actionsAlice4.has() - val (alice5, actionsAlice5) = alice4.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice4.channelId, WatchConfirmed.ClosingTxConfirmed, alice4.currentBlockHeight, 45, claimRemotePenalty))) + val (alice5, actionsAlice5) = alice4.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice4.channelId, WatchConfirmed.ClosingTxConfirmed, alice4.currentBlockHeight, 45, rvk.mainPenaltyTx.tx))) assertIs(alice5.state) actionsAlice5.has() actionsAlice5.has() } - private fun handlePreviousRevokedRemoteClose(alice1: LNChannel, bobCommitTx: Transaction) { - // Alice detects that the remote force-close is not based on the latest funding transaction. - val (alice2, actionsAlice2) = alice1.process(ChannelCommand.WatchReceived(WatchSpentTriggered(alice1.channelId, WatchSpent.ChannelSpent(TestConstants.fundingAmount), bobCommitTx))) - assertIs(alice2.state) - // Alice attempts to force-close and in parallel puts a watch on the remote commit. - val localCommit = actionsAlice2.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.CommitTx) - assertEquals(localCommit.txid, alice1.commitments.active.first().localCommit.publishableTxs.commitTx.tx.txid) - val claimMain = actionsAlice2.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.ClaimLocalDelayedOutputTx) - val pendingHtlcs = alice1.commitments.active.first().localCommit.spec.htlcs - assertEquals(pendingHtlcs.outgoings().size, actionsAlice2.filterIsInstance().filter { it.txType == ChannelAction.Blockchain.PublishTx.Type.HtlcTimeoutTx }.size) - actionsAlice2.hasWatchConfirmed(localCommit.txid) - actionsAlice2.hasWatchConfirmed(claimMain.txid) - assertEquals(actionsAlice2.hasWatchConfirmed(bobCommitTx.txid).event, WatchConfirmed.AlternativeCommitTxConfirmed) - assertEquals(pendingHtlcs.size, actionsAlice2.findWatches().size) - actionsAlice2.has() - actionsAlice2.has() - - // Bob's revoked commitment confirms. - val (alice3, actionsAlice3) = alice2.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice2.channelId, WatchConfirmed.AlternativeCommitTxConfirmed, alice2.currentBlockHeight, 43, bobCommitTx))) - assertIs(alice3.state) - assertEquals(actionsAlice3.size, 8) - val claimMainPenalty = actionsAlice3.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.ClaimRemoteDelayedOutputTx) - Transaction.correctlySpends(claimMainPenalty, bobCommitTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - val claimRemotePenalty = actionsAlice3.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.MainPenaltyTx) - Transaction.correctlySpends(claimRemotePenalty, bobCommitTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - assertEquals(WatchConfirmed.ClosingTxConfirmed, actionsAlice3.hasWatchConfirmed(bobCommitTx.txid).event) - actionsAlice3.hasWatchConfirmed(claimMainPenalty.txid) - val watchSpent = actionsAlice3.hasWatch() - assertEquals(watchSpent.txId, claimRemotePenalty.txIn.first().outPoint.txid) - assertEquals(watchSpent.outputIndex, claimRemotePenalty.txIn.first().outPoint.index.toInt()) - assertEquals(actionsAlice3.find().revokedCommitTxId, bobCommitTx.txid) - actionsAlice3.hasOutgoingMessage() - actionsAlice3.has() - - // Alice's transactions confirm. - val (alice4, actionsAlice4) = alice3.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice3.channelId, WatchConfirmed.ClosingTxConfirmed, alice3.currentBlockHeight, 43, bobCommitTx))) - actionsAlice4.has() - assertEquals( - pendingHtlcs.outgoings().toSet(), - actionsAlice4.filterIsInstance().map { it.htlc }.toSet() - ) - val (alice5, actionsAlice5) = alice4.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice4.channelId, WatchConfirmed.ClosingTxConfirmed, alice4.currentBlockHeight, 44, claimMainPenalty))) - assertEquals(actionsAlice5.size, 1) - actionsAlice5.has() - val (alice6, actionsAlice6) = alice5.process(ChannelCommand.WatchReceived(WatchConfirmedTriggered(alice5.channelId, WatchConfirmed.ClosingTxConfirmed, alice5.currentBlockHeight, 45, claimRemotePenalty))) - assertIs(alice6.state) - actionsAlice6.has() - actionsAlice6.has() - } - private fun reachQuiescent(cmd: ChannelCommand.Commitment.Splice.Request, alice: LNChannel, bob: LNChannel): Triple, LNChannel, SpliceInit> { val (alice1, actionsAlice1) = alice.process(cmd) val aliceStfu = actionsAlice1.findOutgoingMessage() diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt index f71b7529f..46992560a 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt @@ -477,6 +477,37 @@ class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { assertTrue(dbPayment2.parts.all { it.status is LightningOutgoingPayment.Part.Status.Succeeded }) } + @Test + fun `successful attempt followed by failure`() = runSuspendTest { + val (alice, _) = TestsHelper.reachNormal() + val outgoingPaymentHandler = OutgoingPaymentHandler(TestConstants.Alice.nodeParams, defaultWalletParams, InMemoryPaymentsDb()) + val recipientKey = randomKey() + val invoice = makeInvoice(amount = null, supportsTrampoline = true, privKey = recipientKey) + val payment = PayInvoice(UUID.randomUUID(), 300_000.msat, LightningOutgoingPayment.Details.Normal(invoice)) + + val progress = outgoingPaymentHandler.sendPayment(payment, mapOf(alice.channelId to alice.state), TestConstants.defaultBlockHeight) + assertIs(progress) + val add = makeUpdateAddHtlc(alice.channelId, findAddHtlcCommand(progress).second) + + // The payment succeeds. + val preimage = randomBytes32() + val success = outgoingPaymentHandler.processAddSettledFulfilled(createRemoteFulfill(alice.channelId, findAddHtlcCommand(progress).second, preimage)) + assertIs(success) + + // But then we receive a failure command (e.g. a revoked commitment was published for the outgoing channel). + val failure = ChannelAction.HtlcResult.Fail.OnChainFail(HtlcOverriddenByLocalCommit(alice.channelId, add)) + val ignored = outgoingPaymentHandler.processAddSettledFailed(alice.channelId, ChannelAction.ProcessCmdRes.AddSettledFail(payment.paymentId, add, failure), mapOf(alice.channelId to alice.state), TestConstants.defaultBlockHeight) + assertNull(ignored) + + // The payment is successfully settled. + assertNull(outgoingPaymentHandler.getPendingPayment(payment.paymentId)) + val dbPayment = outgoingPaymentHandler.db.getLightningOutgoingPayment(payment.paymentId) + assertNotNull(dbPayment) + assertIs(dbPayment.status) + assertEquals(1, dbPayment.parts.size) + assertTrue(dbPayment.parts.all { it.status is LightningOutgoingPayment.Part.Status.Succeeded }) + } + @Test fun `insufficient funds when retrying with higher fees`() = runSuspendTest { val (alice, _) = TestsHelper.reachNormal(aliceFundingAmount = 100_000.sat, bobFundingAmount = 0.sat)