1
0
Fork 0
mirror of https://github.com/ACINQ/eclair.git synced 2025-02-24 22:58:23 +01:00

Settle HTLCs revoked commit (#1630)

When a revoked commitment is published, we didn't correctly settle pending
HTLCs, which could lead to upstream channel closure.

This would not cause a loss of funds (especially since we would gain
funds from the revoked channel) but it's a temporary liquidity loss that
we'd like to avoid.
This commit is contained in:
Bastien Teinturier 2020-12-15 12:35:26 +01:00 committed by GitHub
parent 95b34f270f
commit 810323ca30
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 544 additions and 224 deletions

View file

@ -1354,7 +1354,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
}
// for our outgoing payments, let's send events if we know that they will settle on chain
Closing
.onchainOutgoingHtlcs(d.commitments.localCommit, d.commitments.remoteCommit, d.commitments.remoteNextCommitInfo.left.toOption.map(_.nextRemoteCommit), tx)
.onChainOutgoingHtlcs(d.commitments.localCommit, d.commitments.remoteCommit, d.commitments.remoteNextCommitInfo.left.toOption.map(_.nextRemoteCommit), tx)
.map(add => (add, d.commitments.originChannels.get(add.id).collect { case o: Origin.Local => o.id })) // we resolve the payment id if this was a local payment
.collect { case (add, Some(id)) => context.system.eventStream.publish(PaymentSettlingOnChain(id, amount = add.amountMsat, add.paymentHash)) }
// and we also send events related to fee

View file

@ -269,8 +269,24 @@ sealed trait HasCommitments extends Data {
case class ClosingTxProposed(unsignedTx: Transaction, localClosingSigned: ClosingSigned)
case class LocalCommitPublished(commitTx: Transaction, claimMainDelayedOutputTx: Option[Transaction], htlcSuccessTxs: List[Transaction], htlcTimeoutTxs: List[Transaction], claimHtlcDelayedTxs: List[Transaction], irrevocablySpent: Map[OutPoint, ByteVector32])
case class RemoteCommitPublished(commitTx: Transaction, claimMainOutputTx: Option[Transaction], claimHtlcSuccessTxs: List[Transaction], claimHtlcTimeoutTxs: List[Transaction], irrevocablySpent: Map[OutPoint, ByteVector32])
case class LocalCommitPublished(commitTx: Transaction, claimMainDelayedOutputTx: Option[Transaction], htlcSuccessTxs: List[Transaction], htlcTimeoutTxs: List[Transaction], claimHtlcDelayedTxs: List[Transaction], irrevocablySpent: Map[OutPoint, ByteVector32]) {
def isConfirmed: Boolean = {
// NB: if multiple transactions end up in the same block, the first confirmation we receive may not be the commit tx.
// However if the confirmed tx spends from the commit tx, we know that the commit tx is already confirmed and we know
// the type of closing.
val confirmedTxs = irrevocablySpent.values.toSet
(commitTx :: claimMainDelayedOutputTx.toList ::: htlcSuccessTxs ::: htlcTimeoutTxs ::: claimHtlcDelayedTxs).exists(tx => confirmedTxs.contains(tx.txid))
}
}
case class RemoteCommitPublished(commitTx: Transaction, claimMainOutputTx: Option[Transaction], claimHtlcSuccessTxs: List[Transaction], claimHtlcTimeoutTxs: List[Transaction], irrevocablySpent: Map[OutPoint, ByteVector32]) {
def isConfirmed: Boolean = {
// NB: if multiple transactions end up in the same block, the first confirmation we receive may not be the commit tx.
// However if the confirmed tx spends from the commit tx, we know that the commit tx is already confirmed and we know
// the type of closing.
val confirmedTxs = irrevocablySpent.values.toSet
(commitTx :: claimMainOutputTx.toList ::: claimHtlcSuccessTxs ::: claimHtlcTimeoutTxs).exists(tx => confirmedTxs.contains(tx.txid))
}
}
case class RevokedCommitPublished(commitTx: Transaction, claimMainOutputTx: Option[Transaction], mainPenaltyTx: Option[Transaction], htlcPenaltyTxs: List[Transaction], claimHtlcDelayedPenaltyTxs: List[Transaction], irrevocablySpent: Map[OutPoint, ByteVector32])
final case class DATA_WAIT_FOR_OPEN_CHANNEL(initFundee: INPUT_INIT_FUNDEE) extends Data {

View file

@ -380,27 +380,14 @@ object Helpers {
* @return the channel closing type, if applicable
*/
def isClosingTypeAlreadyKnown(closing: DATA_CLOSING): Option[ClosingType] = {
// NB: if multiple transactions end up in the same block, the first confirmation we receive may not be the commit tx.
// However if the confirmed tx spends from the commit tx, we know that the commit tx is already confirmed and we know
// the type of closing.
def isLocalCommitConfirmed(lcp: LocalCommitPublished): Boolean = {
val confirmedTxs = lcp.irrevocablySpent.values.toSet
(lcp.commitTx :: lcp.claimMainDelayedOutputTx.toList ::: lcp.htlcSuccessTxs ::: lcp.htlcTimeoutTxs ::: lcp.claimHtlcDelayedTxs).exists(tx => confirmedTxs.contains(tx.txid))
}
def isRemoteCommitConfirmed(rcp: RemoteCommitPublished): Boolean = {
val confirmedTxs = rcp.irrevocablySpent.values.toSet
(rcp.commitTx :: rcp.claimMainOutputTx.toList ::: rcp.claimHtlcSuccessTxs ::: rcp.claimHtlcTimeoutTxs).exists(tx => confirmedTxs.contains(tx.txid))
}
closing match {
case _ if closing.localCommitPublished.exists(isLocalCommitConfirmed) =>
case _ if closing.localCommitPublished.exists(_.isConfirmed) =>
Some(LocalClose(closing.commitments.localCommit, closing.localCommitPublished.get))
case _ if closing.remoteCommitPublished.exists(isRemoteCommitConfirmed) =>
case _ if closing.remoteCommitPublished.exists(_.isConfirmed) =>
Some(CurrentRemoteClose(closing.commitments.remoteCommit, closing.remoteCommitPublished.get))
case _ if closing.nextRemoteCommitPublished.exists(isRemoteCommitConfirmed) =>
case _ if closing.nextRemoteCommitPublished.exists(_.isConfirmed) =>
Some(NextRemoteClose(closing.commitments.remoteNextCommitInfo.left.get.nextRemoteCommit, closing.nextRemoteCommitPublished.get))
case _ if closing.futureRemoteCommitPublished.exists(isRemoteCommitConfirmed) =>
case _ if closing.futureRemoteCommitPublished.exists(_.isConfirmed) =>
Some(RecoveryClose(closing.futureRemoteCommitPublished.get))
case _ if closing.revokedCommitPublished.exists(rcp => rcp.irrevocablySpent.values.toSet.contains(rcp.commitTx.txid)) =>
Some(RevokedClose(closing.revokedCommitPublished.find(rcp => rcp.irrevocablySpent.values.toSet.contains(rcp.commitTx.txid)).get))
@ -953,7 +940,7 @@ object Helpers {
*
* @param tx a transaction that is sufficiently buried in the blockchain
*/
def onchainOutgoingHtlcs(localCommit: LocalCommit, remoteCommit: RemoteCommit, nextRemoteCommit_opt: Option[RemoteCommit], tx: Transaction): Set[UpdateAddHtlc] = {
def onChainOutgoingHtlcs(localCommit: LocalCommit, remoteCommit: RemoteCommit, nextRemoteCommit_opt: Option[RemoteCommit], tx: Transaction): Set[UpdateAddHtlc] = {
if (localCommit.publishableTxs.commitTx.tx.txid == tx.txid) {
localCommit.spec.htlcs.collect(outgoing)
} else if (remoteCommit.txid == tx.txid) {
@ -966,10 +953,8 @@ object Helpers {
}
/**
* If a local commitment tx reaches min_depth, we need to fail the outgoing htlcs that only us had signed, because
* they will never reach the blockchain.
*
* Those are only present in the remote's commitment.
* 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.
*/
def overriddenOutgoingHtlcs(d: DATA_CLOSING, tx: Transaction)(implicit log: LoggingAdapter): Set[UpdateAddHtlc] = {
val localCommit = d.commitments.localCommit
@ -994,6 +979,12 @@ object Helpers {
} else if (nextRemoteCommit_opt.map(_.txid).contains(tx.txid)) {
// their last commitment got confirmed, so no htlcs will be overridden, they will timeout or be fulfilled on chain
Set.empty
} else if (d.revokedCommitPublished.map(_.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:
// - outgoing htlcs that are in the local commitment but not in remote/nextRemote have already been fulfilled/failed so we don't care about them
// - outgoing htlcs that are in the remote/nextRemote commitment may not really be overridden, but since we are going to claim their output as a
// punishment we will never get the preimage and may as well consider them failed in the context of relaying htlcs
nextRemoteCommit_opt.getOrElse(remoteCommit).spec.htlcs.collect(incoming)
} else {
Set.empty
}
@ -1061,17 +1052,19 @@ object Helpers {
* @param tx a transaction that has been irrevocably confirmed
*/
def updateRevokedCommitPublished(revokedCommitPublished: RevokedCommitPublished, tx: Transaction): RevokedCommitPublished = {
// even if our txes only have one input, maybe our counterparty uses a different scheme so we need to iterate
// even if our txs only have one input, maybe our counterparty uses a different scheme so we need to iterate
// over all of them to check if they are relevant
val relevantOutpoints = tx.txIn.map(_.outPoint).filter { outPoint =>
// is this the commit tx itself ? (we could do this outside of the loop...)
val isCommitTx = revokedCommitPublished.commitTx.txid == tx.txid
// does the tx spend an output of the remote commitment tx?
val spendsTheCommitTx = revokedCommitPublished.commitTx.txid == outPoint.txid
// is the tx one of our 3rd stage delayed txes? (a 3rd stage tx is a tx spending the output of an htlc tx, which
// is the tx one of our 3rd stage delayed txs? (a 3rd stage tx is a tx spending the output of an htlc tx, which
// is itself spending the output of the commitment tx)
val is3rdStageDelayedTx = revokedCommitPublished.claimHtlcDelayedPenaltyTxs.map(_.txid).contains(tx.txid)
isCommitTx || spendsTheCommitTx || is3rdStageDelayedTx
// does the tx spend an output of an htlc tx? (in which case it may invalidate one of our claim-htlc-delayed-penalty)
val spendsHtlcOutput = revokedCommitPublished.claimHtlcDelayedPenaltyTxs.flatMap(_.txIn).map(_.outPoint).contains(outPoint)
isCommitTx || spendsTheCommitTx || is3rdStageDelayedTx || spendsHtlcOutput
}
// then we add the relevant outpoints to the map keeping track of which txid spends which outpoint
revokedCommitPublished.copy(irrevocablySpent = revokedCommitPublished.irrevocablySpent ++ relevantOutpoints.map(o => o -> tx.txid).toMap)
@ -1119,11 +1112,13 @@ object Helpers {
// are there remaining spendable outputs from the commitment tx?
val commitOutputsSpendableByUs = (revokedCommitPublished.claimMainOutputTx.toSeq ++ revokedCommitPublished.mainPenaltyTx ++ revokedCommitPublished.htlcPenaltyTxs)
.flatMap(_.txIn.map(_.outPoint)).toSet -- revokedCommitPublished.irrevocablySpent.keys
// which htlc delayed txes can we expect to be confirmed?
val unconfirmedHtlcDelayedTxes = revokedCommitPublished.claimHtlcDelayedPenaltyTxs
.filter(tx => (tx.txIn.map(_.outPoint.txid).toSet -- revokedCommitPublished.irrevocablySpent.values).isEmpty) // only the txes which parents are already confirmed may get confirmed (note that this also eliminates outputs that have been double-spent by a competing tx)
.filterNot(tx => revokedCommitPublished.irrevocablySpent.values.toSet.contains(tx.txid)) // has the tx already been confirmed?
isCommitTxConfirmed && commitOutputsSpendableByUs.isEmpty && unconfirmedHtlcDelayedTxes.isEmpty
// which htlc delayed txs can we expect to be confirmed?
val unconfirmedHtlcDelayedTxs = revokedCommitPublished.claimHtlcDelayedPenaltyTxs
// only the txs which parents are already confirmed may get confirmed (note that this also eliminates outputs that have been double-spent by a competing tx)
.filter(tx => (tx.txIn.map(_.outPoint.txid).toSet -- revokedCommitPublished.irrevocablySpent.values).isEmpty)
// if one of the tx inputs has been spent, the tx has already been confirmed or a competing tx has been confirmed
.filterNot(tx => tx.txIn.exists(txIn => revokedCommitPublished.irrevocablySpent.contains(txIn.outPoint)))
isCommitTxConfirmed && commitOutputsSpendableByUs.isEmpty && unconfirmedHtlcDelayedTxs.isEmpty
}
/**

View file

@ -1,7 +1,11 @@
package fr.acinq.eclair.channel
import fr.acinq.bitcoin.{OutPoint, Transaction, TxIn, TxOut}
import fr.acinq.eclair.channel.Helpers.Closing
import fr.acinq.eclair.transactions.Transactions
import fr.acinq.eclair.{LongToBtcAmount, randomBytes32}
import org.scalatest.funsuite.AnyFunSuite
import scodec.bits.ByteVector
class ChannelTypesSpec extends AnyFunSuite {
@ -45,4 +49,198 @@ class ChannelTypesSpec extends AnyFunSuite {
}
}
test("local commit published") {
val (lcp, _, _) = createClosingTransactions()
assert(!lcp.isConfirmed)
assert(!Closing.isLocalCommitDone(lcp))
// Commit tx has been confirmed.
val lcp1 = Closing.updateLocalCommitPublished(lcp, lcp.commitTx)
assert(lcp1.irrevocablySpent.nonEmpty)
assert(lcp1.isConfirmed)
assert(!Closing.isLocalCommitDone(lcp1))
// Main output has been confirmed.
val lcp2 = Closing.updateLocalCommitPublished(lcp1, lcp.claimMainDelayedOutputTx.get)
assert(lcp2.isConfirmed)
assert(!Closing.isLocalCommitDone(lcp2))
// Our htlc-success txs and their 3rd-stage claim txs have been confirmed.
val lcp3 = Seq(lcp.htlcSuccessTxs.head, lcp.claimHtlcDelayedTxs.head, lcp.htlcSuccessTxs(1), lcp.claimHtlcDelayedTxs(1)).foldLeft(lcp2) {
case (current, tx) => Closing.updateLocalCommitPublished(current, tx)
}
assert(lcp3.isConfirmed)
assert(!Closing.isLocalCommitDone(lcp3))
// Scenario 1: our htlc-timeout txs and their 3rd-stage claim txs have been confirmed.
{
val lcp4a = Seq(lcp.htlcTimeoutTxs.head, lcp.claimHtlcDelayedTxs(2), lcp.htlcTimeoutTxs(1)).foldLeft(lcp3) {
case (current, tx) => Closing.updateLocalCommitPublished(current, tx)
}
assert(lcp4a.isConfirmed)
assert(!Closing.isLocalCommitDone(lcp4a))
val lcp4b = Closing.updateLocalCommitPublished(lcp4a, lcp.claimHtlcDelayedTxs(3))
assert(lcp4b.isConfirmed)
assert(Closing.isLocalCommitDone(lcp4b))
}
// Scenario 2: they claim the htlcs we sent before our htlc-timeout.
{
val claimHtlcSuccess1 = lcp.htlcTimeoutTxs.head.copy(txOut = Seq(TxOut(3000.sat, ByteVector.empty), TxOut(2500.sat, ByteVector.empty)))
val lcp4a = Closing.updateLocalCommitPublished(lcp3, claimHtlcSuccess1)
assert(lcp4a.isConfirmed)
assert(!Closing.isLocalCommitDone(lcp4a))
val claimHtlcSuccess2 = lcp.htlcTimeoutTxs(1).copy(txOut = Seq(TxOut(3500.sat, ByteVector.empty), TxOut(3100.sat, ByteVector.empty)))
val lcp4b = Closing.updateLocalCommitPublished(lcp4a, claimHtlcSuccess2)
assert(lcp4b.isConfirmed)
assert(Closing.isLocalCommitDone(lcp4b))
}
}
test("remote commit published") {
val (_, rcp, _) = createClosingTransactions()
assert(!rcp.isConfirmed)
assert(!Closing.isRemoteCommitDone(rcp))
// Commit tx has been confirmed.
val rcp1 = Closing.updateRemoteCommitPublished(rcp, rcp.commitTx)
assert(rcp1.irrevocablySpent.nonEmpty)
assert(rcp1.isConfirmed)
assert(!Closing.isRemoteCommitDone(rcp1))
// Main output has been confirmed.
val rcp2 = Closing.updateRemoteCommitPublished(rcp1, rcp.claimMainOutputTx.get)
assert(rcp2.isConfirmed)
assert(!Closing.isRemoteCommitDone(rcp2))
// One of our claim-htlc-success and claim-htlc-timeout has been confirmed.
val rcp3 = Seq(rcp.claimHtlcSuccessTxs.head, rcp.claimHtlcTimeoutTxs.head).foldLeft(rcp2) {
case (current, tx) => Closing.updateRemoteCommitPublished(current, tx)
}
assert(rcp3.isConfirmed)
assert(!Closing.isRemoteCommitDone(rcp3))
// Scenario 1: our remaining claim-htlc txs have been confirmed.
{
val rcp4a = Closing.updateRemoteCommitPublished(rcp3, rcp.claimHtlcSuccessTxs(1))
assert(rcp4a.isConfirmed)
assert(!Closing.isRemoteCommitDone(rcp4a))
val rcp4b = Closing.updateRemoteCommitPublished(rcp4a, rcp.claimHtlcTimeoutTxs(1))
assert(rcp4b.isConfirmed)
assert(Closing.isRemoteCommitDone(rcp4b))
}
// Scenario 2: they claim the remaining htlc outputs.
{
val htlcSuccess = rcp.claimHtlcSuccessTxs(1).copy(txOut = Seq(TxOut(3000.sat, ByteVector.empty), TxOut(2500.sat, ByteVector.empty)))
val rcp4a = Closing.updateRemoteCommitPublished(rcp3, htlcSuccess)
assert(rcp4a.isConfirmed)
assert(!Closing.isRemoteCommitDone(rcp4a))
val htlcTimeout = rcp.claimHtlcTimeoutTxs(1).copy(txOut = Seq(TxOut(3500.sat, ByteVector.empty), TxOut(3100.sat, ByteVector.empty)))
val rcp4b = Closing.updateRemoteCommitPublished(rcp4a, htlcTimeout)
assert(rcp4b.isConfirmed)
assert(Closing.isRemoteCommitDone(rcp4b))
}
}
test("revoked commit published") {
val (_, _, rvk) = createClosingTransactions()
assert(!Closing.isRevokedCommitDone(rvk))
// Commit tx has been confirmed.
val rvk1 = Closing.updateRevokedCommitPublished(rvk, rvk.commitTx)
assert(rvk1.irrevocablySpent.nonEmpty)
assert(!Closing.isRevokedCommitDone(rvk1))
// Main output has been confirmed.
val rvk2 = Closing.updateRevokedCommitPublished(rvk1, rvk.claimMainOutputTx.get)
assert(!Closing.isRevokedCommitDone(rvk2))
// Two of our htlc penalty txs have been confirmed.
val rvk3 = Seq(rvk.htlcPenaltyTxs.head, rvk.htlcPenaltyTxs(1)).foldLeft(rvk2) {
case (current, tx) => Closing.updateRevokedCommitPublished(current, tx)
}
assert(!Closing.isRevokedCommitDone(rvk3))
// Scenario 1: the remaining penalty txs have been confirmed.
{
val rvk4a = Seq(rvk.htlcPenaltyTxs(2), rvk.htlcPenaltyTxs(3)).foldLeft(rvk3) {
case (current, tx) => Closing.updateRevokedCommitPublished(current, tx)
}
assert(!Closing.isRevokedCommitDone(rvk4a))
val rvk4b = Closing.updateRevokedCommitPublished(rvk4a, rvk.mainPenaltyTx.get)
assert(Closing.isRevokedCommitDone(rvk4b))
}
// Scenario 2: they claim the remaining outputs.
{
val remoteMainOutput = rvk.mainPenaltyTx.get.copy(txOut = Seq(TxOut(35000.sat, ByteVector.empty)))
val rvk4a = Closing.updateRevokedCommitPublished(rvk3, remoteMainOutput)
assert(!Closing.isRevokedCommitDone(rvk4a))
val htlcSuccess = rvk.htlcPenaltyTxs(2).copy(txOut = Seq(TxOut(3000.sat, ByteVector.empty), TxOut(2500.sat, ByteVector.empty)))
val htlcTimeout = rvk.htlcPenaltyTxs(3).copy(txOut = Seq(TxOut(3500.sat, ByteVector.empty), TxOut(3100.sat, ByteVector.empty)))
// When Bob claims these outputs, the channel should call Helpers.claimRevokedHtlcTxOutputs to punish them by claiming the output of their htlc tx.
val rvk4b = Seq(htlcSuccess, htlcTimeout).foldLeft(rvk4a) {
case (current, tx) => Closing.updateRevokedCommitPublished(current, tx)
}.copy(
claimHtlcDelayedPenaltyTxs = List(
Transaction(2, Seq(TxIn(OutPoint(htlcSuccess, 0), ByteVector.empty, 0)), Seq(TxOut(5000.sat, ByteVector.empty)), 0),
Transaction(2, Seq(TxIn(OutPoint(htlcTimeout, 0), ByteVector.empty, 0)), Seq(TxOut(6000.sat, ByteVector.empty)), 0)
)
)
assert(!Closing.isRevokedCommitDone(rvk4b))
// We claim one of the remaining outputs, they claim the other.
val rvk5a = Closing.updateRevokedCommitPublished(rvk4b, rvk4b.claimHtlcDelayedPenaltyTxs.head)
assert(!Closing.isRevokedCommitDone(rvk5a))
val theyClaimHtlcTimeout = rvk4b.claimHtlcDelayedPenaltyTxs(1).copy(txOut = Seq(TxOut(1500.sat, ByteVector.empty), TxOut(2500.sat, ByteVector.empty)))
val rvk5b = Closing.updateRevokedCommitPublished(rvk5a, theyClaimHtlcTimeout)
assert(Closing.isRevokedCommitDone(rvk5b))
}
}
private def createClosingTransactions(): (LocalCommitPublished, RemoteCommitPublished, RevokedCommitPublished) = {
val commitTx = Transaction(
2,
Seq(TxIn(OutPoint(randomBytes32, 0), ByteVector.empty, 0)),
Seq(
TxOut(50000.sat, ByteVector.empty), // main output Alice
TxOut(40000.sat, ByteVector.empty), // main output Bob
TxOut(4000.sat, ByteVector.empty), // htlc received #1
TxOut(5000.sat, ByteVector.empty), // htlc received #2
TxOut(6000.sat, ByteVector.empty), // htlc sent #1
TxOut(7000.sat, ByteVector.empty), // htlc sent #2
),
0
)
val claimMainAlice = Transaction(2, Seq(TxIn(OutPoint(commitTx, 0), ByteVector.empty, 144)), Seq(TxOut(49500.sat, ByteVector.empty)), 0)
val htlcSuccess1 = Transaction(2, Seq(TxIn(OutPoint(commitTx, 2), ByteVector.empty, 1)), Seq(TxOut(3500.sat, ByteVector.empty)), 0)
val htlcSuccess2 = Transaction(2, Seq(TxIn(OutPoint(commitTx, 3), ByteVector.empty, 1)), Seq(TxOut(4500.sat, ByteVector.empty)), 0)
val htlcTimeout1 = Transaction(2, Seq(TxIn(OutPoint(commitTx, 4), ByteVector.empty, 1)), Seq(TxOut(5500.sat, ByteVector.empty)), 0)
val htlcTimeout2 = Transaction(2, Seq(TxIn(OutPoint(commitTx, 5), ByteVector.empty, 1)), Seq(TxOut(6500.sat, ByteVector.empty)), 0)
val localCommit = {
val claimHtlcDelayedTxs = List(
Transaction(2, Seq(TxIn(OutPoint(htlcSuccess1, 0), ByteVector.empty, 1)), Seq(TxOut(3400.sat, ByteVector.empty)), 0),
Transaction(2, Seq(TxIn(OutPoint(htlcSuccess2, 0), ByteVector.empty, 1)), Seq(TxOut(4400.sat, ByteVector.empty)), 0),
Transaction(2, Seq(TxIn(OutPoint(htlcTimeout1, 0), ByteVector.empty, 1)), Seq(TxOut(5400.sat, ByteVector.empty)), 0),
Transaction(2, Seq(TxIn(OutPoint(htlcTimeout2, 0), ByteVector.empty, 1)), Seq(TxOut(6400.sat, ByteVector.empty)), 0),
)
LocalCommitPublished(commitTx, Some(claimMainAlice), List(htlcSuccess1, htlcSuccess2), List(htlcTimeout1, htlcTimeout2), claimHtlcDelayedTxs, Map.empty)
}
val remoteCommit = RemoteCommitPublished(commitTx, Some(claimMainAlice), List(htlcSuccess1, htlcSuccess2), List(htlcTimeout1, htlcTimeout2), Map.empty)
val revokedCommit = {
val mainPenalty = Transaction(2, Seq(TxIn(OutPoint(commitTx, 1), ByteVector.empty, 0)), Seq(TxOut(39500.sat, ByteVector.empty)), 0)
RevokedCommitPublished(commitTx, Some(claimMainAlice), Some(mainPenalty), List(htlcSuccess1, htlcSuccess2, htlcTimeout1, htlcTimeout2), Nil, Map.empty)
}
(localCommit, remoteCommit, revokedCommit)
}
}

View file

@ -151,13 +151,20 @@ trait StateTestsHelperMethods extends TestKitBase with FixtureTestSuite with Par
htlc
}
def fulfillHtlc(id: Long, R: ByteVector32, s: TestFSMRef[State, Data, Channel], r: TestFSMRef[State, Data, Channel], s2r: TestProbe, r2s: TestProbe): Unit = {
s ! CMD_FULFILL_HTLC(id, R)
def fulfillHtlc(id: Long, preimage: ByteVector32, s: TestFSMRef[State, Data, Channel], r: TestFSMRef[State, Data, Channel], s2r: TestProbe, r2s: TestProbe): Unit = {
s ! CMD_FULFILL_HTLC(id, preimage)
val fulfill = s2r.expectMsgType[UpdateFulfillHtlc]
s2r.forward(r)
awaitCond(r.stateData.asInstanceOf[HasCommitments].commitments.remoteChanges.proposed.contains(fulfill))
}
def failHtlc(id: Long, s: TestFSMRef[State, Data, Channel], r: TestFSMRef[State, Data, Channel], s2r: TestProbe, r2s: TestProbe): Unit = {
s ! CMD_FAIL_HTLC(id, Right(TemporaryNodeFailure))
val fail = s2r.expectMsgType[UpdateFailHtlc]
s2r.forward(r)
awaitCond(r.stateData.asInstanceOf[HasCommitments].commitments.remoteChanges.proposed.contains(fail))
}
def crossSign(s: TestFSMRef[State, Data, Channel], r: TestFSMRef[State, Data, Channel], s2r: TestProbe, r2s: TestProbe): Unit = {
val sender = TestProbe()
val sCommitIndex = s.stateData.asInstanceOf[HasCommitments].commitments.localCommit.index

View file

@ -84,7 +84,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
} else {
within(30 seconds) {
reachNormal(setup, test.tags)
val bobCommitTxes: List[PublishableTxs] = (for (amt <- List(100000000 msat, 200000000 msat, 300000000 msat)) yield {
val bobCommitTxs: List[PublishableTxs] = (for (amt <- List(100000000 msat, 200000000 msat, 300000000 msat)) yield {
val (r, htlc) = addHtlc(amt, alice, bob, alice2bob, bob2alice)
crossSign(alice, bob, alice2bob, bob2alice)
relayerB.expectMsgType[RelayForward]
@ -101,7 +101,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
awaitCond(alice.stateName == NORMAL)
awaitCond(bob.stateName == NORMAL)
withFixture(test.toNoArgTest(FixtureParam(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain, relayerA, relayerB, channelUpdateListener, bobCommitTxes)))
withFixture(test.toNoArgTest(FixtureParam(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain, relayerA, relayerB, channelUpdateListener, bobCommitTxs)))
}
}
}
@ -934,235 +934,339 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
awaitCond(alice.stateName == CLOSED)
}
private def testFundingSpentRevokedTx(f: FixtureParam, channelVersion: ChannelVersion): Transaction = {
case class RevokedCloseFixture(bobRevokedTxs: Seq[PublishableTxs], htlcsAlice: Seq[UpdateAddHtlc], htlcsBob: Seq[UpdateAddHtlc])
private def prepareRevokedClose(f: FixtureParam, channelVersion: ChannelVersion): RevokedCloseFixture = {
import f._
mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain)
val initialState = alice.stateData.asInstanceOf[DATA_CLOSING]
assert(initialState.commitments.channelVersion === channelVersion)
// bob publishes one of his revoked txes
val bobRevokedTx = bobCommitTxes.head.commitTx.tx
// Bob's first commit tx doesn't contain any htlc
val commitTx1 = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs
if (channelVersion.hasAnchorOutputs) {
assert(commitTx1.commitTx.tx.txOut.size === 4) // 2 main outputs + 2 anchors
} else {
assert(commitTx1.commitTx.tx.txOut.size === 2) // 2 main outputs
}
// Bob's second commit tx contains 1 incoming htlc and 1 outgoing htlc
val (commitTx2, htlcAlice1, htlcBob1) = {
val (_, htlcAlice) = addHtlc(35000000 msat, alice, bob, alice2bob, bob2alice)
crossSign(alice, bob, alice2bob, bob2alice)
val (_, htlcBob) = addHtlc(20000000 msat, bob, alice, bob2alice, alice2bob)
crossSign(bob, alice, bob2alice, alice2bob)
val commitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs
(commitTx, htlcAlice, htlcBob)
}
assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs.commitTx.tx.txOut.size == commitTx2.commitTx.tx.txOut.size)
if (channelVersion.hasAnchorOutputs) {
assert(commitTx2.commitTx.tx.txOut.size === 6)
} else {
assert(commitTx2.commitTx.tx.txOut.size === 4)
}
// Bob's third commit tx contains 2 incoming htlcs and 2 outgoing htlcs
val (commitTx3, htlcAlice2, htlcBob2) = {
val (_, htlcAlice) = addHtlc(25000000 msat, alice, bob, alice2bob, bob2alice)
crossSign(alice, bob, alice2bob, bob2alice)
val (_, htlcBob) = addHtlc(18000000 msat, bob, alice, bob2alice, alice2bob)
crossSign(bob, alice, bob2alice, alice2bob)
val commitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs
(commitTx, htlcAlice, htlcBob)
}
assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs.commitTx.tx.txOut.size == commitTx3.commitTx.tx.txOut.size)
if (channelVersion.hasAnchorOutputs) {
assert(commitTx3.commitTx.tx.txOut.size === 8)
} else {
assert(commitTx3.commitTx.tx.txOut.size === 6)
}
// Bob's fourth commit tx doesn't contain any htlc
val commitTx4 = {
Seq(htlcAlice1, htlcAlice2).foreach(htlcAlice => failHtlc(htlcAlice.id, bob, alice, bob2alice, alice2bob))
Seq(htlcBob1, htlcBob2).foreach(htlcBob => failHtlc(htlcBob.id, alice, bob, alice2bob, bob2alice))
crossSign(alice, bob, alice2bob, bob2alice)
bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs
}
assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs.commitTx.tx.txOut.size == commitTx4.commitTx.tx.txOut.size)
if (channelVersion.hasAnchorOutputs) {
assert(commitTx4.commitTx.tx.txOut.size === 4)
} else {
assert(commitTx4.commitTx.tx.txOut.size === 2)
}
RevokedCloseFixture(Seq(commitTx1, commitTx2, commitTx3, commitTx4), Seq(htlcAlice1, htlcAlice2), Seq(htlcBob1, htlcBob2))
}
private def testFundingSpentRevokedTx(f: FixtureParam, channelVersion: ChannelVersion): Unit = {
import f._
val revokedCloseFixture = prepareRevokedClose(f, channelVersion)
assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.channelVersion === channelVersion)
// bob publishes one of his revoked txs
val bobRevokedTx = revokedCloseFixture.bobRevokedTxs(1).commitTx.tx
alice ! WatchEventSpent(BITCOIN_FUNDING_SPENT, bobRevokedTx)
awaitCond(alice.stateData.isInstanceOf[DATA_CLOSING])
awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.size == 1)
awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].copy(revokedCommitPublished = Nil) == initialState)
bobRevokedTx
val rvk = alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.head
assert(rvk.commitTx === bobRevokedTx)
if (!channelVersion.paysDirectlyToWallet) {
assert(rvk.claimMainOutputTx.nonEmpty)
}
assert(rvk.mainPenaltyTx.nonEmpty)
assert(rvk.htlcPenaltyTxs.size === 2)
assert(rvk.claimHtlcDelayedPenaltyTxs.isEmpty)
val penaltyTxs = rvk.claimMainOutputTx.toList ++ rvk.mainPenaltyTx.toList ++ rvk.htlcPenaltyTxs
// alice publishes the penalty txs
if (!channelVersion.paysDirectlyToWallet) {
alice2blockchain.expectMsg(PublishAsap(rvk.claimMainOutputTx.get))
}
alice2blockchain.expectMsg(PublishAsap(rvk.mainPenaltyTx.get))
assert(Set(alice2blockchain.expectMsgType[PublishAsap].tx, alice2blockchain.expectMsgType[PublishAsap].tx) === rvk.htlcPenaltyTxs.toSet)
for (penaltyTx <- penaltyTxs) {
Transaction.correctlySpends(penaltyTx, bobRevokedTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}
// alice spends all outpoints of the revoked tx, except her main output when it goes directly to our wallet
val spentOutpoints = penaltyTxs.flatMap(_.txIn.map(_.outPoint)).toSet
assert(spentOutpoints.forall(_.txid === bobRevokedTx.txid))
if (channelVersion.hasAnchorOutputs) {
assert(spentOutpoints.size === bobRevokedTx.txOut.size - 2) // we don't claim the anchors
}
else if (channelVersion.paysDirectlyToWallet) {
assert(spentOutpoints.size === bobRevokedTx.txOut.size - 1) // we don't claim our main output, it directly goes to our wallet
} else {
assert(spentOutpoints.size === bobRevokedTx.txOut.size)
}
// alice watches confirmation for the outputs only her can claim
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === bobRevokedTx.txid)
if (!channelVersion.paysDirectlyToWallet) {
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === rvk.claimMainOutputTx.get.txid)
}
// alice watches outputs that can be spent by both parties
val watchedOutpoints = Seq(alice2blockchain.expectMsgType[WatchSpent], alice2blockchain.expectMsgType[WatchSpent], alice2blockchain.expectMsgType[WatchSpent]).map(_.outputIndex).toSet
assert(watchedOutpoints === (rvk.mainPenaltyTx.get :: rvk.htlcPenaltyTxs).map(_.txIn.head.outPoint.index).toSet)
alice2blockchain.expectNoMsg(1 second)
// once all txs are confirmed, alice can move to the closed state
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(bobRevokedTx), 100, 3, bobRevokedTx)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(rvk.mainPenaltyTx.get), 110, 1, rvk.mainPenaltyTx.get)
if (!channelVersion.paysDirectlyToWallet) {
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(rvk.claimMainOutputTx.get), 110, 2, rvk.claimMainOutputTx.get)
}
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(rvk.htlcPenaltyTxs.head), 115, 0, rvk.htlcPenaltyTxs.head)
assert(alice.stateName === CLOSING)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(rvk.htlcPenaltyTxs(1)), 115, 2, rvk.htlcPenaltyTxs(1))
awaitCond(alice.stateName === CLOSED)
}
test("recv BITCOIN_FUNDING_SPENT (one revoked tx)") { f =>
import f._
val revokedTx = testFundingSpentRevokedTx(f, ChannelVersion.STANDARD)
assert(revokedTx.txOut.length === 3)
// alice publishes and watches the penalty tx
val claimMain = alice2blockchain.expectMsgType[PublishAsap].tx
val mainPenalty = alice2blockchain.expectMsgType[PublishAsap].tx
val htlcPenalty = alice2blockchain.expectMsgType[PublishAsap].tx
for (penaltyTx <- Seq(claimMain, mainPenalty, htlcPenalty)) {
Transaction.correctlySpends(penaltyTx, revokedTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}
assert(Seq(claimMain, mainPenalty, htlcPenalty).map(_.txIn.head.outPoint).toSet.size === revokedTx.txOut.length) // spend all outpoints of the revoked tx
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === revokedTx.txid)
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === claimMain.txid)
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === mainPenalty.txIn.head.outPoint.index)
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === htlcPenalty.txIn.head.outPoint.index)
alice2blockchain.expectNoMsg(1 second)
testFundingSpentRevokedTx(f, ChannelVersion.STANDARD)
}
test("recv BITCOIN_FUNDING_SPENT (one revoked tx, option_static_remotekey)", Tag("static_remotekey")) { f =>
import f._
val revokedTx = testFundingSpentRevokedTx(f, ChannelVersion.STATIC_REMOTEKEY)
assert(revokedTx.txOut.length === 3)
// alice publishes and watches the penalty tx, but she won't claim her main output (claim-main)
val mainPenalty = alice2blockchain.expectMsgType[PublishAsap].tx
val htlcPenalty = alice2blockchain.expectMsgType[PublishAsap].tx
for (penaltyTx <- Seq(mainPenalty, htlcPenalty)) {
Transaction.correctlySpends(penaltyTx, revokedTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}
assert(Seq(mainPenalty, htlcPenalty).map(_.txIn.head.outPoint).toSet.size === revokedTx.txOut.length - 1) // spend all outpoints of the revoked tx except our main output
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === revokedTx.txid)
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === mainPenalty.txIn.head.outPoint.index)
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === htlcPenalty.txIn.head.outPoint.index)
alice2blockchain.expectNoMsg(1 second)
testFundingSpentRevokedTx(f, ChannelVersion.STATIC_REMOTEKEY)
}
test("recv BITCOIN_FUNDING_SPENT (one revoked tx, anchor outputs)", Tag("anchor_outputs")) { f =>
import f._
val revokedTx = testFundingSpentRevokedTx(f, ChannelVersion.ANCHOR_OUTPUTS)
assert(revokedTx.txOut.length === 5)
// alice publishes and watches the penalty tx
val claimMain = alice2blockchain.expectMsgType[PublishAsap].tx
val mainPenalty = alice2blockchain.expectMsgType[PublishAsap].tx
val htlcPenalty = alice2blockchain.expectMsgType[PublishAsap].tx
for (penaltyTx <- Seq(claimMain, mainPenalty, htlcPenalty)) {
Transaction.correctlySpends(penaltyTx, revokedTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}
assert(Seq(claimMain, mainPenalty, htlcPenalty).map(_.txIn.head.outPoint).toSet.size === revokedTx.txOut.length - 2) // spend all outpoints of the revoked tx except anchors
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === revokedTx.txid)
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === claimMain.txid)
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === mainPenalty.txIn.head.outPoint.index)
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === htlcPenalty.txIn.head.outPoint.index)
alice2blockchain.expectNoMsg(1 second)
testFundingSpentRevokedTx(f, ChannelVersion.ANCHOR_OUTPUTS)
}
test("recv BITCOIN_FUNDING_SPENT (multiple revoked tx)") { f =>
import f._
mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain)
assert(bobCommitTxes.map(_.commitTx.tx.txid).toSet.size === bobCommitTxes.size) // all commit txs are distinct
// bob publishes multiple revoked txes (last one isn't revoked)
alice ! WatchEventSpent(BITCOIN_FUNDING_SPENT, bobCommitTxes.head.commitTx.tx)
// alice publishes and watches the penalty tx
val claimMain1 = alice2blockchain.expectMsgType[PublishAsap].tx
val mainPenalty1 = alice2blockchain.expectMsgType[PublishAsap].tx
val htlcPenalty1 = alice2blockchain.expectMsgType[PublishAsap].tx
for (penaltyTx <- Seq(claimMain1, mainPenalty1, htlcPenalty1)) {
Transaction.correctlySpends(penaltyTx, bobCommitTxes.head.commitTx.tx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === bobCommitTxes.head.commitTx.tx.txid)
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === claimMain1.txid)
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === mainPenalty1.txIn.head.outPoint.index)
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === htlcPenalty1.txIn.head.outPoint.index)
val revokedCloseFixture = prepareRevokedClose(f, ChannelVersion.STANDARD)
assert(revokedCloseFixture.bobRevokedTxs.map(_.commitTx.tx.txid).toSet.size === revokedCloseFixture.bobRevokedTxs.size) // all commit txs are distinct
def broadcastBobRevokedTx(revokedTx: Transaction, htlcCount: Int, revokedCount: Int): RevokedCommitPublished = {
alice ! WatchEventSpent(BITCOIN_FUNDING_SPENT, revokedTx)
awaitCond(alice.stateData.isInstanceOf[DATA_CLOSING])
awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.size == revokedCount)
assert(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.last.commitTx === revokedTx)
// alice publishes penalty txs
val claimMain = alice2blockchain.expectMsgType[PublishAsap].tx
val mainPenalty = alice2blockchain.expectMsgType[PublishAsap].tx
val htlcPenaltyTxs = (1 to htlcCount).map(_ => alice2blockchain.expectMsgType[PublishAsap].tx)
(claimMain +: mainPenalty +: htlcPenaltyTxs).foreach(tx => Transaction.correctlySpends(tx, revokedTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS))
// alice watches confirmation for the outputs only her can claim
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === revokedTx.txid)
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === claimMain.txid)
// alice watches outputs that can be spent by both parties
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === mainPenalty.txIn.head.outPoint.index)
val htlcOutpoints = (1 to htlcCount).map(_ => alice2blockchain.expectMsgType[WatchSpent].outputIndex).toSet
assert(htlcOutpoints === htlcPenaltyTxs.flatMap(_.txIn.map(_.outPoint.index)).toSet)
alice2blockchain.expectNoMsg(1 second)
alice ! WatchEventSpent(BITCOIN_FUNDING_SPENT, bobCommitTxes(1).commitTx.tx)
// alice publishes and watches the penalty tx (no HTLC in that commitment)
val claimMain2 = alice2blockchain.expectMsgType[PublishAsap].tx
val mainPenalty2 = alice2blockchain.expectMsgType[PublishAsap].tx
for (penaltyTx <- Seq(claimMain2, mainPenalty2)) {
Transaction.correctlySpends(penaltyTx, bobCommitTxes(1).commitTx.tx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === bobCommitTxes(1).commitTx.tx.txid)
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === claimMain2.txid)
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === mainPenalty2.txIn.head.outPoint.index)
alice2blockchain.expectNoMsg(1 second)
alice ! WatchEventSpent(BITCOIN_FUNDING_SPENT, bobCommitTxes(2).commitTx.tx)
// alice publishes and watches the penalty tx
val claimMain3 = alice2blockchain.expectMsgType[PublishAsap].tx
val mainPenalty3 = alice2blockchain.expectMsgType[PublishAsap].tx
val htlcPenalty3 = alice2blockchain.expectMsgType[PublishAsap].tx
for (penaltyTx <- Seq(claimMain3, mainPenalty3, htlcPenalty3)) {
Transaction.correctlySpends(penaltyTx, bobCommitTxes(2).commitTx.tx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === bobCommitTxes(2).commitTx.tx.txid)
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === claimMain3.txid)
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === mainPenalty3.txIn.head.outPoint.index)
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === htlcPenalty3.txIn.head.outPoint.index)
alice2blockchain.expectNoMsg(1 second)
assert(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.size == 3)
alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.last
}
def prepareOutputSpentRevokedTx(f: FixtureParam, channelVersion: ChannelVersion): PublishableTxs = {
// bob publishes a first revoked tx (no htlc in that commitment)
broadcastBobRevokedTx(revokedCloseFixture.bobRevokedTxs.head.commitTx.tx, 0, 1)
// bob publishes a second revoked tx
val rvk2 = broadcastBobRevokedTx(revokedCloseFixture.bobRevokedTxs(1).commitTx.tx, 2, 2)
// bob publishes a third revoked tx
broadcastBobRevokedTx(revokedCloseFixture.bobRevokedTxs(2).commitTx.tx, 4, 3)
// bob's second revoked tx confirms: once all penalty txs are confirmed, alice can move to the closed state
// NB: if multiple txs confirm in the same block, we may receive the events in any order
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(rvk2.mainPenaltyTx.get), 100, 1, rvk2.mainPenaltyTx.get)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(rvk2.claimMainOutputTx.get), 100, 2, rvk2.claimMainOutputTx.get)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(rvk2.commitTx), 100, 3, rvk2.commitTx)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(rvk2.htlcPenaltyTxs.head), 115, 0, rvk2.htlcPenaltyTxs.head)
assert(alice.stateName === CLOSING)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(rvk2.htlcPenaltyTxs(1)), 115, 2, rvk2.htlcPenaltyTxs(1))
awaitCond(alice.stateName === CLOSED)
}
def testOutputSpentRevokedTx(f: FixtureParam, channelVersion: ChannelVersion): Unit = {
import f._
mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain)
assert(alice.stateData.asInstanceOf[DATA_CLOSING].commitments.channelVersion === channelVersion)
// bob publishes one of his revoked txes
val bobRevokedTx = bobCommitTxes.head
alice ! WatchEventSpent(BITCOIN_FUNDING_SPENT, bobRevokedTx.commitTx.tx)
// alice publishes and watches the penalty tx
val claimMainTx = alice2blockchain.expectMsgType[PublishAsap].tx
val mainPenaltyTx = alice2blockchain.expectMsgType[PublishAsap].tx
val htlcPenaltyTx = alice2blockchain.expectMsgType[PublishAsap].tx
for (penaltyTx <- Seq(claimMainTx, mainPenaltyTx, htlcPenaltyTx)) {
Transaction.correctlySpends(penaltyTx, bobRevokedTx.commitTx.tx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === bobRevokedTx.commitTx.tx.txid)
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === claimMainTx.txid)
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === mainPenaltyTx.txIn.head.outPoint.index)
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === htlcPenaltyTx.txIn.head.outPoint.index)
val revokedCloseFixture = prepareRevokedClose(f, channelVersion)
assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.channelVersion === channelVersion)
// bob publishes one of his revoked txs
val bobRevokedTxs = revokedCloseFixture.bobRevokedTxs(2)
alice ! WatchEventSpent(BITCOIN_FUNDING_SPENT, bobRevokedTxs.commitTx.tx)
awaitCond(alice.stateData.isInstanceOf[DATA_CLOSING])
awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.size == 1)
val rvk = alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.head
assert(rvk.commitTx === bobRevokedTxs.commitTx.tx)
assert(rvk.claimMainOutputTx.nonEmpty)
assert(rvk.mainPenaltyTx.nonEmpty)
assert(rvk.htlcPenaltyTxs.size === 4)
assert(rvk.claimHtlcDelayedPenaltyTxs.isEmpty)
// alice publishes the penalty txs and watches outputs
(1 to 6).foreach(_ => alice2blockchain.expectMsgType[PublishAsap]) // 2 main outputs and 4 htlcs
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === rvk.commitTx.txid)
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === rvk.claimMainOutputTx.get.txid)
(1 to 5).foreach(_ => alice2blockchain.expectMsgType[WatchSpent]) // main output penalty and 4 htlc penalties
alice2blockchain.expectNoMsg(1 second)
awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.head.commitTx == bobRevokedTx.commitTx.tx)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(bobRevokedTx.commitTx.tx), 0, 0, bobRevokedTx.commitTx.tx)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(claimMainTx), 0, 0, claimMainTx)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(mainPenaltyTx), 0, 0, mainPenaltyTx)
alice ! WatchEventSpent(BITCOIN_OUTPUT_SPENT, htlcPenaltyTx) // we published this
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === htlcPenaltyTx.txid)
// bob manages to claim 2 htlc outputs before alice can penalize him: 1 htlc-success and 1 htlc-timeout.
val bobHtlcSuccessTx1 = bobRevokedTxs.htlcTxsAndSigs.filter(tx => tx.txinfo.input.txOut.amount == revokedCloseFixture.htlcsAlice.head.amountMsat.truncateToSatoshi).head
val bobHtlcTimeoutTx = bobRevokedTxs.htlcTxsAndSigs.filter(tx => tx.txinfo.input.txOut.amount == revokedCloseFixture.htlcsBob.last.amountMsat.truncateToSatoshi).head
val bobOutpoints = Seq(bobHtlcSuccessTx1, bobHtlcTimeoutTx).map(_.txinfo.input.outPoint).toSet
assert(bobOutpoints.size === 2)
bobRevokedTx
// alice reacts by publishing penalty txs that spend bob's htlc transactions
alice ! WatchEventSpent(BITCOIN_OUTPUT_SPENT, bobHtlcSuccessTx1.txinfo.tx)
awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.head.claimHtlcDelayedPenaltyTxs.size == 1)
val claimHtlcSuccessPenalty1 = alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.head.claimHtlcDelayedPenaltyTxs.head
Transaction.correctlySpends(claimHtlcSuccessPenalty1, bobHtlcSuccessTx1.txinfo.tx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === bobHtlcSuccessTx1.txinfo.tx.txid)
assert(alice2blockchain.expectMsgType[PublishAsap].tx === claimHtlcSuccessPenalty1)
val watchSpent1 = alice2blockchain.expectMsgType[WatchSpent]
assert(watchSpent1.txId === bobHtlcSuccessTx1.txinfo.tx.txid)
assert(Set(watchSpent1.outputIndex) === claimHtlcSuccessPenalty1.txIn.map(_.outPoint.index).toSet)
alice2blockchain.expectNoMsg(1 second)
alice ! WatchEventSpent(BITCOIN_OUTPUT_SPENT, bobHtlcTimeoutTx.txinfo.tx)
awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.head.claimHtlcDelayedPenaltyTxs.size == 2)
val claimHtlcTimeoutPenalty = alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.head.claimHtlcDelayedPenaltyTxs.last
Transaction.correctlySpends(claimHtlcTimeoutPenalty, bobHtlcTimeoutTx.txinfo.tx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === bobHtlcTimeoutTx.txinfo.tx.txid)
assert(alice2blockchain.expectMsgType[PublishAsap].tx === claimHtlcTimeoutPenalty)
val watchSpent2 = alice2blockchain.expectMsgType[WatchSpent]
assert(watchSpent2.txId === bobHtlcTimeoutTx.txinfo.tx.txid)
assert(Set(watchSpent2.outputIndex) === claimHtlcTimeoutPenalty.txIn.map(_.outPoint.index).toSet)
alice2blockchain.expectNoMsg(1 second)
// bob RBFs his htlc-success with a different transaction
val bobHtlcSuccessTx2 = bobHtlcSuccessTx1.txinfo.tx.copy(txIn = TxIn(OutPoint(randomBytes32, 0), Nil, 0) +: bobHtlcSuccessTx1.txinfo.tx.txIn)
assert(bobHtlcSuccessTx2.txid !== bobHtlcSuccessTx1.txinfo.tx.txid)
alice ! WatchEventSpent(BITCOIN_OUTPUT_SPENT, bobHtlcSuccessTx2)
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === bobHtlcSuccessTx2.txid)
val claimHtlcSuccessPenalty2 = alice2blockchain.expectMsgType[PublishAsap].tx
assert(claimHtlcSuccessPenalty1.txid != claimHtlcSuccessPenalty2.txid)
Transaction.correctlySpends(claimHtlcSuccessPenalty2, bobHtlcSuccessTx2 :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
val watchSpent3 = alice2blockchain.expectMsgType[WatchSpent]
assert(watchSpent3.txId === bobHtlcSuccessTx2.txid)
assert(Set(watchSpent3.outputIndex) === claimHtlcSuccessPenalty2.txIn.map(_.outPoint.index).toSet)
alice2blockchain.expectNoMsg(1 second)
// transactions confirm: alice can move to the closed state
val remainingHtlcPenaltyTxs = rvk.htlcPenaltyTxs.filterNot(tx => bobOutpoints.contains(tx.txIn.head.outPoint))
assert(remainingHtlcPenaltyTxs.size === 2)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(rvk.commitTx), 100, 3, rvk.commitTx)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(rvk.mainPenaltyTx.get), 110, 0, rvk.mainPenaltyTx.get)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(rvk.claimMainOutputTx.get), 110, 1, rvk.claimMainOutputTx.get)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(remainingHtlcPenaltyTxs.head), 110, 2, remainingHtlcPenaltyTxs.head)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(remainingHtlcPenaltyTxs.last), 115, 2, remainingHtlcPenaltyTxs.last)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(bobHtlcTimeoutTx.txinfo.tx), 115, 0, bobHtlcTimeoutTx.txinfo.tx)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(bobHtlcSuccessTx2), 115, 1, bobHtlcSuccessTx2)
assert(alice.stateName === CLOSING)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(claimHtlcTimeoutPenalty), 120, 0, claimHtlcTimeoutPenalty)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(claimHtlcSuccessPenalty2), 121, 0, claimHtlcSuccessPenalty2)
awaitCond(alice.stateName === CLOSED)
}
test("recv BITCOIN_OUTPUT_SPENT (one revoked tx, counterparty published htlc-success tx)") { f =>
import f._
val bobRevokedTx = prepareOutputSpentRevokedTx(f, ChannelVersion.STANDARD)
assert(bobRevokedTx.commitTx.tx.txOut.size === 3)
val bobHtlcSuccessTx = bobRevokedTx.htlcTxsAndSigs.head.txinfo.tx
alice ! WatchEventSpent(BITCOIN_OUTPUT_SPENT, bobHtlcSuccessTx) // bob published his htlc-success tx
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === bobHtlcSuccessTx.txid)
val claimHtlcDelayedPenaltyTx = alice2blockchain.expectMsgType[PublishAsap].tx // we publish a tx spending the output of bob's htlc-success tx
Transaction.correctlySpends(claimHtlcDelayedPenaltyTx, bobHtlcSuccessTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
val watchOutput = alice2blockchain.expectMsgType[WatchSpent]
assert(watchOutput.txId === claimHtlcDelayedPenaltyTx.txIn.head.outPoint.txid)
assert(watchOutput.outputIndex === claimHtlcDelayedPenaltyTx.txIn.head.outPoint.index)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(bobHtlcSuccessTx), 0, 0, bobHtlcSuccessTx) // bob won
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(claimHtlcDelayedPenaltyTx), 0, 0, claimHtlcDelayedPenaltyTx) // but alice claims the htlc output
awaitCond(alice.stateName == CLOSED)
testOutputSpentRevokedTx(f, ChannelVersion.STANDARD)
}
test("recv BITCOIN_OUTPUT_SPENT (one revoked tx, counterparty published htlc-success tx, anchor outputs)", Tag("anchor_outputs")) { f =>
import f._
val bobRevokedTx = prepareOutputSpentRevokedTx(f, ChannelVersion.ANCHOR_OUTPUTS)
assert(bobRevokedTx.commitTx.tx.txOut.size === 5)
val bobHtlcSuccessTx1 = bobRevokedTx.htlcTxsAndSigs.head.txinfo.tx
alice ! WatchEventSpent(BITCOIN_OUTPUT_SPENT, bobHtlcSuccessTx1) // bob published his htlc-success tx
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === bobHtlcSuccessTx1.txid)
val claimHtlcDelayedPenaltyTx1 = alice2blockchain.expectMsgType[PublishAsap].tx // we publish a tx spending the output of bob's htlc-success tx
Transaction.correctlySpends(claimHtlcDelayedPenaltyTx1, bobHtlcSuccessTx1 :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
val watchOutput1 = alice2blockchain.expectMsgType[WatchSpent]
assert(watchOutput1.txId === claimHtlcDelayedPenaltyTx1.txIn.head.outPoint.txid)
assert(watchOutput1.outputIndex === claimHtlcDelayedPenaltyTx1.txIn.head.outPoint.index)
// Bob may RBF his htlc-success with a different transaction
val bobHtlcSuccessTx2 = bobHtlcSuccessTx1.copy(txIn = TxIn(OutPoint(randomBytes32, 0), Nil, 0) +: bobHtlcSuccessTx1.txIn)
assert(bobHtlcSuccessTx2.txid !== bobHtlcSuccessTx1.txid)
alice ! WatchEventSpent(BITCOIN_OUTPUT_SPENT, bobHtlcSuccessTx2) // bob published a new version of his htlc-success tx
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === bobHtlcSuccessTx2.txid)
val claimHtlcDelayedPenaltyTx2 = alice2blockchain.expectMsgType[PublishAsap].tx // we publish a tx spending the output of bob's new htlc-success tx
Transaction.correctlySpends(claimHtlcDelayedPenaltyTx2, bobHtlcSuccessTx2 :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
val watchOutput2 = alice2blockchain.expectMsgType[WatchSpent]
assert(watchOutput2.txId === claimHtlcDelayedPenaltyTx2.txIn.head.outPoint.txid)
assert(watchOutput2.outputIndex === claimHtlcDelayedPenaltyTx2.txIn.head.outPoint.index)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(bobHtlcSuccessTx2), 0, 0, bobHtlcSuccessTx2) // bob won
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(claimHtlcDelayedPenaltyTx2), 0, 0, claimHtlcDelayedPenaltyTx2) // but alice claims the htlc output
awaitCond(alice.stateName == CLOSED)
testOutputSpentRevokedTx(f, ChannelVersion.ANCHOR_OUTPUTS)
}
private def testRevokedTxConfirmed(f: FixtureParam, channelVersion: ChannelVersion): Unit = {
import f._
mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain)
assert(alice.stateData.asInstanceOf[DATA_CLOSING].commitments.channelVersion === channelVersion)
// bob publishes one of his revoked txes
val bobRevokedTx = bobCommitTxes.head
alice ! WatchEventSpent(BITCOIN_FUNDING_SPENT, bobRevokedTx.commitTx.tx)
// alice publishes and watches the penalty tx
val claimMainTx = alice2blockchain.expectMsgType[PublishAsap].tx
val mainPenaltyTx = alice2blockchain.expectMsgType[PublishAsap].tx
val htlcPenaltyTx = alice2blockchain.expectMsgType[PublishAsap].tx
for (penaltyTx <- Seq(claimMainTx, mainPenaltyTx, htlcPenaltyTx)) {
Transaction.correctlySpends(penaltyTx, bobRevokedTx.commitTx.tx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === bobRevokedTx.commitTx.tx.txid)
assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === claimMainTx.txid)
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === mainPenaltyTx.txIn.head.outPoint.index)
assert(alice2blockchain.expectMsgType[WatchSpent].outputIndex === htlcPenaltyTx.txIn.head.outPoint.index)
alice2blockchain.expectNoMsg(1 second)
awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.head.commitTx == bobRevokedTx.commitTx.tx)
assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.channelVersion === channelVersion)
val initOutputCount = if (channelVersion.hasAnchorOutputs) 4 else 2
assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs.commitTx.tx.txOut.size === initOutputCount)
// actual test starts here
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(bobRevokedTx.commitTx.tx), 0, 0, bobRevokedTx.commitTx.tx)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(claimMainTx), 0, 0, claimMainTx)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(mainPenaltyTx), 0, 0, mainPenaltyTx)
alice ! WatchEventSpent(BITCOIN_OUTPUT_SPENT, htlcPenaltyTx)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(htlcPenaltyTx), 0, 0, htlcPenaltyTx)
awaitCond(alice.stateName == CLOSED)
// bob's second commit tx contains 2 incoming htlcs
val (bobRevokedTx, htlcs1) = {
val (_, htlc1) = addHtlc(35000000 msat, alice, bob, alice2bob, bob2alice)
val (_, htlc2) = addHtlc(20000000 msat, alice, bob, alice2bob, bob2alice)
crossSign(alice, bob, alice2bob, bob2alice)
val bobCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs.commitTx.tx
assert(bobCommitTx.txOut.size === initOutputCount + 2)
(bobCommitTx, Seq(htlc1, htlc2))
}
test("recv BITCOIN_TX_CONFIRMED (one revoked tx)") { f =>
// bob's third commit tx contains 1 of the previous htlcs and 2 new htlcs
val htlcs2 = {
val (_, htlc3) = addHtlc(25000000 msat, alice, bob, alice2bob, bob2alice)
val (_, htlc4) = addHtlc(18000000 msat, alice, bob, alice2bob, bob2alice)
failHtlc(htlcs1.head.id, bob, alice, bob2alice, alice2bob)
crossSign(bob, alice, bob2alice, alice2bob)
assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs.commitTx.tx.txOut.size === initOutputCount + 3)
Seq(htlc3, htlc4)
}
// alice's first htlc has been failed
assert(relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.Fail]].htlc === htlcs1.head)
relayerA.expectNoMsg(1 second)
// bob publishes one of his revoked txs which quickly confirms
alice ! WatchEventSpent(BITCOIN_FUNDING_SPENT, bobRevokedTx)
alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(bobRevokedTx), 100, 1, bobRevokedTx)
awaitCond(alice.stateName === CLOSING)
// alice should fail all pending htlcs
val htlcFails = Seq(
relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.OnChainFail]],
relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.OnChainFail]],
relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.OnChainFail]]
).map(_.htlc).toSet
assert(htlcFails === Set(htlcs1(1), htlcs2.head, htlcs2(1)))
relayerA.expectNoMsg(1 second)
}
test("recv BITCOIN_TX_CONFIRMED (one revoked tx, pending htlcs)") { f =>
testRevokedTxConfirmed(f, ChannelVersion.STANDARD)
}
test("recv BITCOIN_TX_CONFIRMED (one revoked tx, anchor outputs)", Tag("anchor_outputs")) { f =>
test("recv BITCOIN_TX_CONFIRMED (one revoked tx, pending htlcs, anchor outputs)", Tag("anchor_outputs")) { f =>
testRevokedTxConfirmed(f, ChannelVersion.ANCHOR_OUTPUTS)
}