Skip to content

Commit f8b1272

Browse files
authored
Watch spent outputs before watching for confirmation (#3092)
We previously immediately watched for confirmation of transactions that our peer couldn't double-spend, which had a few issues: - if we RBF-ed those transactions after a restart and a previous version confirmed, we wouldn't detect it and wouldn't move to the CLOSED state - if the transaction had a long CSV delay, we were wasting performance in the watcher while that CSV isn't complete We change that behavior and instead watch all outputs of the commitment transaction that we may spend. We only watch for confirmations after we detect that the output has been spent (either in the mempool or in a block). This ensures that RBF attempts are correctly handled, and that we don't watch transactions until they've been published (and thus CSV delays are satisfied). We also explicitly split 2nd-stage and 3rd-stage transactions. It is a bit verbose and awkward for now, but it will become cleaner once we stop storing unconfirmed transactions and instead re-compute them when restarting. It also makes testing easier: we took this opportunity to ensure that closing tests cover all scenarios and use better assertions on watches and transactions.
1 parent fb84a9d commit f8b1272

19 files changed

Lines changed: 1235 additions & 1833 deletions

eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,7 @@ object ZmqWatcher {
143143
case class WatchFundingConfirmed(replyTo: ActorRef[WatchFundingConfirmedTriggered], txId: TxId, minDepth: Int) extends WatchConfirmed[WatchFundingConfirmedTriggered]
144144
case class WatchFundingConfirmedTriggered(blockHeight: BlockHeight, txIndex: Int, tx: Transaction) extends WatchConfirmedTriggered
145145

146-
case class RelativeDelay(parentTxId: TxId, delay: Long)
147-
case class WatchTxConfirmed(replyTo: ActorRef[WatchTxConfirmedTriggered], txId: TxId, minDepth: Int, delay_opt: Option[RelativeDelay] = None) extends WatchConfirmed[WatchTxConfirmedTriggered]
146+
case class WatchTxConfirmed(replyTo: ActorRef[WatchTxConfirmedTriggered], txId: TxId, minDepth: Int) extends WatchConfirmed[WatchTxConfirmedTriggered]
148147
case class WatchTxConfirmedTriggered(blockHeight: BlockHeight, txIndex: Int, tx: Transaction) extends WatchConfirmedTriggered
149148

150149
case class WatchParentTxConfirmed(replyTo: ActorRef[WatchParentTxConfirmedTriggered], txId: TxId, minDepth: Int) extends WatchConfirmed[WatchParentTxConfirmedTriggered]
@@ -463,10 +462,10 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
463462

464463
private def checkConfirmed(w: WatchConfirmed[_ <: WatchConfirmedTriggered], currentHeight: BlockHeight): Future[Unit] = {
465464
log.debug("checking confirmations of txid={}", w.txId)
466-
// NB: this is very inefficient since internally we call `getrawtransaction` three times, but it doesn't really
467-
// matter because this only happens once, when the watched transaction has reached min_depth
468465
client.getTxConfirmations(w.txId).flatMap {
469466
case Some(confirmations) if confirmations >= w.minDepth =>
467+
// NB: this is very inefficient since internally we call `getrawtransaction` three times, but it doesn't really
468+
// matter because this only happens once, when the watched transaction has reached min_depth
470469
client.getTransaction(w.txId).flatMap { tx =>
471470
client.getTransactionShortId(w.txId).map {
472471
case (height, index) => w match {
@@ -483,27 +482,11 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
483482
context.self ! SetWatchHint(w, CheckAfterBlock(currentHeight + w.minDepth - confirmations))
484483
Future.successful(())
485484
case None =>
486-
w match {
487-
case WatchTxConfirmed(_, _, _, Some(relativeDelay)) =>
488-
log.debug("txId={} has a relative delay of {} blocks, checking parentTxId={}", w.txId, relativeDelay.delay, relativeDelay.parentTxId)
489-
// Note how we add one block to avoid an off-by-one:
490-
// - if the parent is confirmed at block P
491-
// - the CSV delay is D and the minimum depth is M
492-
// - the first block that can include the child is P + D
493-
// - the first block at which we can reach minimum depth is P + D + M
494-
// - if we are currently at block P + N, the parent has C = N + 1 confirmations
495-
// - we want to check at block P + N + D + M + 1 - C = P + N + D + M + 1 - (N + 1) = P + D + M
496-
val delay = relativeDelay.delay + w.minDepth + 1
497-
client.getTxConfirmations(relativeDelay.parentTxId).map(_.getOrElse(0)).collect {
498-
case confirmations if confirmations < delay => context.self ! SetWatchHint(w, CheckAfterBlock(currentHeight + delay - confirmations))
499-
}
500-
case _ =>
501-
// The transaction is unconfirmed: we don't need to check again at every new block: we can check only once
502-
// every minDepth blocks, which is more efficient. If the transaction is included at the current height in
503-
// a reorg, we will trigger the watch one block later than expected, but this is fine.
504-
context.self ! SetWatchHint(w, CheckAfterBlock(currentHeight + w.minDepth))
505-
Future.successful(())
506-
}
485+
// The transaction is unconfirmed: we don't need to check again at every new block: we can check only once
486+
// every minDepth blocks, which is more efficient. If the transaction is included at the current height in
487+
// a reorg, we will trigger the watch one block later than expected, but this is fine.
488+
context.self ! SetWatchHint(w, CheckAfterBlock(currentHeight + w.minDepth))
489+
Future.successful(())
507490
}
508491
}
509492

eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala

Lines changed: 37 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -875,8 +875,14 @@ object Helpers {
875875

876876
object LocalClose {
877877

878+
/** Transactions spending outputs of our commitment transaction. */
879+
case class SecondStageTransactions(mainDelayedTx_opt: Option[ClaimLocalDelayedOutputTx], anchorTx_opt: Option[ClaimAnchorOutputTx], htlcTxs: Seq[HtlcTx])
880+
881+
/** Transactions spending outputs of our HTLC transactions. */
882+
case class ThirdStageTransactions(htlcDelayedTxs: Seq[HtlcDelayedTx])
883+
878884
/** Claim all the outputs that belong to us in our local commitment transaction. */
879-
def claimCommitTxOutputs(channelKeys: ChannelKeys, commitment: FullCommitment, commitTx: Transaction, feerates: FeeratesPerKw, onChainFeeConf: OnChainFeeConf, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): LocalCommitPublished = {
885+
def claimCommitTxOutputs(channelKeys: ChannelKeys, commitment: FullCommitment, commitTx: Transaction, feerates: FeeratesPerKw, onChainFeeConf: OnChainFeeConf, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): (LocalCommitPublished, SecondStageTransactions) = {
880886
require(commitment.localCommit.commitTxAndRemoteSig.commitTx.tx.txid == commitTx.txid, "txid mismatch, provided tx is not the current local commit tx")
881887
val fundingKey = channelKeys.fundingKey(commitment.fundingTxIndex)
882888
val commitmentKeys = commitment.localKeys(channelKeys)
@@ -891,14 +897,16 @@ object Helpers {
891897
} else {
892898
None
893899
}
894-
LocalCommitPublished(
900+
val lcp = LocalCommitPublished(
895901
commitTx = commitTx,
896902
claimMainDelayedOutputTx = mainDelayedTx_opt,
897903
htlcTxs = htlcTxs,
898904
claimHtlcDelayedTxs = Nil, // we will claim these once the htlc txs are confirmed
899905
claimAnchorTxs = anchorTx_opt.toList,
900906
irrevocablySpent = Map.empty
901907
)
908+
val txs = SecondStageTransactions(mainDelayedTx_opt, anchorTx_opt, htlcTxs.values.flatten.toSeq)
909+
(lcp, txs)
902910
}
903911

904912
def claimAnchor(fundingKey: PrivateKey, commitKeys: LocalCommitmentKeys, commitTx: Transaction, commitmentFormat: CommitmentFormat)(implicit log: LoggingAdapter): Option[ClaimAnchorOutputTx] = {
@@ -1002,28 +1010,31 @@ object Helpers {
10021010
* NB: with anchor outputs, it's possible to have transactions that spend *many* HTLC outputs at once, but we're not
10031011
* doing that because it introduces a lot of subtle edge cases.
10041012
*/
1005-
def claimHtlcDelayedOutput(localCommitPublished: LocalCommitPublished, channelKeys: ChannelKeys, commitment: FullCommitment, tx: Transaction, feerates: FeeratesPerKw, onChainFeeConf: OnChainFeeConf, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): (LocalCommitPublished, Option[HtlcDelayedTx]) = {
1013+
def claimHtlcDelayedOutput(localCommitPublished: LocalCommitPublished, channelKeys: ChannelKeys, commitment: FullCommitment, tx: Transaction, feerates: FeeratesPerKw, onChainFeeConf: OnChainFeeConf, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): (LocalCommitPublished, ThirdStageTransactions) = {
10061014
if (tx.txIn.exists(txIn => localCommitPublished.htlcTxs.contains(txIn.outPoint))) {
1007-
val feeratePerKwDelayed = onChainFeeConf.getClosingFeerate(feerates)
1015+
val feerateDelayed = onChainFeeConf.getClosingFeerate(feerates)
10081016
val commitKeys = commitment.localKeys(channelKeys)
10091017
// Note that this will return None if the transaction wasn't one of our HTLC transactions, which may happen
10101018
// if our peer was able to claim the HTLC output before us (race condition between success and timeout).
10111019
val htlcDelayedTx_opt = withTxGenerationLog("htlc-delayed") {
1012-
HtlcDelayedTx.createSignedTx(commitKeys, tx, commitment.localParams.dustLimit, commitment.remoteParams.toSelfDelay, finalScriptPubKey, feeratePerKwDelayed, commitment.params.commitmentFormat)
1020+
HtlcDelayedTx.createSignedTx(commitKeys, tx, commitment.localParams.dustLimit, commitment.remoteParams.toSelfDelay, finalScriptPubKey, feerateDelayed, commitment.params.commitmentFormat)
10131021
}
10141022
val localCommitPublished1 = localCommitPublished.copy(claimHtlcDelayedTxs = localCommitPublished.claimHtlcDelayedTxs ++ htlcDelayedTx_opt.toSeq)
1015-
(localCommitPublished1, htlcDelayedTx_opt)
1023+
(localCommitPublished1, ThirdStageTransactions(htlcDelayedTx_opt.toSeq))
10161024
} else {
1017-
(localCommitPublished, None)
1025+
(localCommitPublished, ThirdStageTransactions(Nil))
10181026
}
10191027
}
10201028

10211029
}
10221030

10231031
object RemoteClose {
10241032

1033+
/** Transactions spending outputs of a remote commitment transaction. */
1034+
case class SecondStageTransactions(mainTx_opt: Option[ClaimRemoteCommitMainOutputTx], anchorTx_opt: Option[ClaimAnchorOutputTx], htlcTxs: Seq[ClaimHtlcTx])
1035+
10251036
/** Claim all the outputs that belong to us in the remote commitment transaction (which can be either their current or next commitment). */
1026-
def claimCommitTxOutputs(channelKeys: ChannelKeys, commitment: FullCommitment, remoteCommit: RemoteCommit, commitTx: Transaction, feerates: FeeratesPerKw, onChainFeeConf: OnChainFeeConf, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): RemoteCommitPublished = {
1037+
def claimCommitTxOutputs(channelKeys: ChannelKeys, commitment: FullCommitment, remoteCommit: RemoteCommit, commitTx: Transaction, feerates: FeeratesPerKw, onChainFeeConf: OnChainFeeConf, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): (RemoteCommitPublished, SecondStageTransactions) = {
10271038
require(remoteCommit.txid == commitTx.txid, "txid mismatch, provided tx is not the current remote commit tx")
10281039
val fundingKey = channelKeys.fundingKey(commitment.fundingTxIndex)
10291040
val commitKeys = commitment.remoteKeys(channelKeys, remoteCommit.remotePerCommitmentPoint)
@@ -1035,13 +1046,15 @@ object Helpers {
10351046
} else {
10361047
None
10371048
}
1038-
RemoteCommitPublished(
1049+
val rcp = RemoteCommitPublished(
10391050
commitTx = commitTx,
10401051
claimMainOutputTx = mainTx_opt,
10411052
claimHtlcTxs = htlcTxs,
10421053
claimAnchorTxs = anchorTx_opt.toList,
10431054
irrevocablySpent = Map.empty
10441055
)
1056+
val txs = SecondStageTransactions(mainTx_opt, anchorTx_opt, htlcTxs.values.flatten.toSeq)
1057+
(rcp, txs)
10451058
}
10461059

10471060
def claimAnchor(fundingKey: PrivateKey, commitKeys: RemoteCommitmentKeys, commitTx: Transaction, commitmentFormat: CommitmentFormat)(implicit log: LoggingAdapter): Option[ClaimAnchorOutputTx] = {
@@ -1171,6 +1184,12 @@ object Helpers {
11711184

11721185
object RevokedClose {
11731186

1187+
/** Transactions spending outputs of a revoked remote commitment transactions. */
1188+
case class SecondStageTransactions(mainTx_opt: Option[ClaimRemoteCommitMainOutputTx], mainPenaltyTx_opt: Option[MainPenaltyTx], htlcPenaltyTxs: Seq[HtlcPenaltyTx])
1189+
1190+
/** Transactions spending outputs of confirmed remote HTLC transactions. */
1191+
case class ThirdStageTransactions(htlcDelayedPenaltyTxs: Seq[ClaimHtlcDelayedOutputPenaltyTx])
1192+
11741193
/**
11751194
* When an unexpected transaction spending the funding tx is detected, we must be in one of the following scenarios:
11761195
*
@@ -1203,7 +1222,7 @@ object Helpers {
12031222
* When a revoked commitment transaction spending the funding tx is detected, we build a set of transactions that
12041223
* will punish our peer by stealing all their funds.
12051224
*/
1206-
def claimCommitTxOutputs(params: ChannelParams, channelKeys: ChannelKeys, commitTx: Transaction, commitmentNumber: Long, remotePerCommitmentSecret: PrivateKey, db: ChannelsDb, feerates: FeeratesPerKw, onChainFeeConf: OnChainFeeConf, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): RevokedCommitPublished = {
1225+
def claimCommitTxOutputs(params: ChannelParams, channelKeys: ChannelKeys, commitTx: Transaction, commitmentNumber: Long, remotePerCommitmentSecret: PrivateKey, db: ChannelsDb, feerates: FeeratesPerKw, onChainFeeConf: OnChainFeeConf, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): (RevokedCommitPublished, SecondStageTransactions) = {
12071226
import params._
12081227
log.warning("a revoked commit has been published with commitmentNumber={}", commitmentNumber)
12091228

@@ -1236,14 +1255,16 @@ object Helpers {
12361255
val htlcPenaltyTxs = HtlcPenaltyTx.createSignedTxs(commitKeys, revocationKey, commitTx, htlcInfos, localParams.dustLimit, finalScriptPubKey, feeratePenalty, commitmentFormat)
12371256
.flatMap(htlcPenaltyTx => withTxGenerationLog("htlc-penalty")(htlcPenaltyTx))
12381257

1239-
RevokedCommitPublished(
1258+
val rvk = RevokedCommitPublished(
12401259
commitTx = commitTx,
12411260
claimMainOutputTx = mainTx_opt,
12421261
mainPenaltyTx = mainPenaltyTx_opt,
12431262
htlcPenaltyTxs = htlcPenaltyTxs.toList,
12441263
claimHtlcDelayedPenaltyTxs = Nil, // we will generate and spend those if they publish their HtlcSuccessTx or HtlcTimeoutTx
12451264
irrevocablySpent = Map.empty
12461265
)
1266+
val txs = SecondStageTransactions(mainTx_opt, mainPenaltyTx_opt, htlcPenaltyTxs)
1267+
(rvk, txs)
12471268
}
12481269

12491270
/**
@@ -1259,7 +1280,7 @@ object Helpers {
12591280
* NB: when anchor outputs is used, htlc transactions can be aggregated in a single transaction if they share the same
12601281
* lockTime (thanks to the use of sighash_single | sighash_anyonecanpay), so we may need to claim multiple outputs.
12611282
*/
1262-
def claimHtlcTxOutputs(params: ChannelParams, channelKeys: ChannelKeys, remotePerCommitmentSecrets: ShaChain, revokedCommitPublished: RevokedCommitPublished, htlcTx: Transaction, feerates: FeeratesPerKw, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): (RevokedCommitPublished, Seq[ClaimHtlcDelayedOutputPenaltyTx]) = {
1283+
def claimHtlcTxOutputs(params: ChannelParams, channelKeys: ChannelKeys, remotePerCommitmentSecrets: ShaChain, revokedCommitPublished: RevokedCommitPublished, htlcTx: Transaction, feerates: FeeratesPerKw, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): (RevokedCommitPublished, ThirdStageTransactions) = {
12631284
// We published HTLC-penalty transactions for every HTLC output: this transaction may be ours, or it may be one
12641285
// of their HTLC transactions that confirmed before our HTLC-penalty transaction. If it is spending an HTLC
12651286
// output, we assume that it's an HTLC transaction published by our peer and try to create penalty transactions
@@ -1284,10 +1305,11 @@ object Helpers {
12841305
}
12851306
})
12861307
val revokedCommitPublished1 = revokedCommitPublished.copy(claimHtlcDelayedPenaltyTxs = revokedCommitPublished.claimHtlcDelayedPenaltyTxs ++ penaltyTxs)
1287-
(revokedCommitPublished1, penaltyTxs)
1288-
}.getOrElse((revokedCommitPublished, Nil))
1308+
val txs = ThirdStageTransactions(penaltyTxs)
1309+
(revokedCommitPublished1, txs)
1310+
}.getOrElse((revokedCommitPublished, ThirdStageTransactions(Nil)))
12891311
} else {
1290-
(revokedCommitPublished, Nil)
1312+
(revokedCommitPublished, ThirdStageTransactions(Nil))
12911313
}
12921314
}
12931315

@@ -1519,26 +1541,6 @@ object Helpers {
15191541
revokedCommitPublished.copy(irrevocablySpent = revokedCommitPublished.irrevocablySpent ++ relevantOutpoints.map(o => o -> tx).toMap)
15201542
}
15211543

1522-
/**
1523-
* This helper function tells if some of the utxos consumed by the given transaction have already been irrevocably spent (possibly by this very transaction).
1524-
*
1525-
* It can be useful to:
1526-
* - not attempt to publish this tx when we know this will fail
1527-
* - not watch for confirmations if we know the tx is already confirmed
1528-
* - not watch the corresponding utxo when we already know the final spending tx
1529-
*
1530-
* @param tx an arbitrary transaction
1531-
* @param irrevocablySpent a map of known spent outpoints
1532-
* @return true if we know for sure that the utxos consumed by the tx have already irrevocably been spent, false otherwise
1533-
*/
1534-
def inputsAlreadySpent(tx: Transaction, irrevocablySpent: Map[OutPoint, Transaction]): Boolean = {
1535-
tx.txIn.exists(txIn => irrevocablySpent.contains(txIn.outPoint))
1536-
}
1537-
1538-
def inputAlreadySpent(input: OutPoint, irrevocablySpent: Map[OutPoint, Transaction]): Boolean = {
1539-
irrevocablySpent.contains(input)
1540-
}
1541-
15421544
}
15431545

15441546
}

0 commit comments

Comments
 (0)