Skip to content

Commit

Permalink
Settle HTLCs revoked commit (#1630)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
t-bast committed Dec 15, 2020
1 parent 95b34f2 commit 810323c
Show file tree
Hide file tree
Showing 6 changed files with 543 additions and 223 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
55 changes: 25 additions & 30 deletions eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

/**
Expand Down
Loading

0 comments on commit 810323c

Please sign in to comment.