Skip to content

Commit 6d28cbc

Browse files
authored
Rework XxxCommitPublished types (#1728)
Re-work the `CommitPublished` types to work better with anchor outputs. We previously stored the txs spending utxos that we could claim: this doesn't make sense anymore if these txs may be RBF-ed, because the final tx will be different from the initial one. We instead track what `OutPoint`s we can claim, and the information necessary to claim them. This way we can in the future let a different actor finalize the txs that spend these outpoints (set the fees and sign). We also add information on mutual close txs to immediately identify our output and its amount: this makes auditing how much sats we'll get back very easy from the API when we have many channels to watch. This commit contains a DB migration of the channel data types, but in a backwards-compatible way: we can still read from old data. The only scenario impacted is channels that started force-closing before the migration. They need special care to handle the fact that they had less data than migrated channels, which is why we keep some legacy code around.
1 parent 4bc2dec commit 6d28cbc

30 files changed

+2261
-1032
lines changed

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ object ZmqWatcher {
504504
* We need this because fundrawtransaction doesn't allow us to leave non-wallet inputs, so we have to add them
505505
* afterwards which may bring the resulting feerate below our target.
506506
*/
507-
def adjustAnchorOutputChange(unsignedTx: Transactions.ClaimAnchorOutputTx, commitTx: Transaction, amountIn: Satoshi, currentFeerate: FeeratePerKw, targetFeerate: FeeratePerKw, dustLimit: Satoshi): Transactions.ClaimAnchorOutputTx = {
507+
def adjustAnchorOutputChange(unsignedTx: Transactions.ClaimLocalAnchorOutputTx, commitTx: Transaction, amountIn: Satoshi, currentFeerate: FeeratePerKw, targetFeerate: FeeratePerKw, dustLimit: Satoshi): Transactions.ClaimLocalAnchorOutputTx = {
508508
require(unsignedTx.tx.txOut.size == 1, "funded transaction should have a single change output")
509509
// We take into account witness weight and adjust the fee to match our desired feerate.
510510
val dummySignedClaimAnchorTx = Transactions.addSigs(unsignedTx, Transactions.PlaceHolderSig)

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

+95-84
Large diffs are not rendered by default.

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

+102-19
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import fr.acinq.eclair.blockchain.fee.FeeratePerKw
2424
import fr.acinq.eclair.payment.OutgoingPacket.Upstream
2525
import fr.acinq.eclair.router.Announcements
2626
import fr.acinq.eclair.transactions.CommitmentSpec
27-
import fr.acinq.eclair.transactions.Transactions.{AnchorOutputsCommitmentFormat, CommitTx, CommitmentFormat, DefaultCommitmentFormat}
27+
import fr.acinq.eclair.transactions.Transactions._
2828
import fr.acinq.eclair.wire.{AcceptChannel, ChannelAnnouncement, ChannelReestablish, ChannelUpdate, ClosingSigned, FailureMessage, FundingCreated, FundingLocked, FundingSigned, Init, OnionRoutingPacket, OpenChannel, Shutdown, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFulfillHtlc}
2929
import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, ShortChannelId, UInt64}
3030
import scodec.bits.{BitVector, ByteVector}
@@ -268,27 +268,111 @@ sealed trait HasCommitments extends Data {
268268
def commitments: Commitments
269269
}
270270

271-
case class ClosingTxProposed(unsignedTx: Transaction, localClosingSigned: ClosingSigned)
271+
case class ClosingTxProposed(unsignedTx: ClosingTx, localClosingSigned: ClosingSigned)
272+
273+
sealed trait CommitPublished {
274+
/** Commitment tx. */
275+
def commitTx: Transaction
276+
/** Map of relevant outpoints that have been spent and the confirmed transaction that spends them. */
277+
def irrevocablySpent: Map[OutPoint, Transaction]
272278

273-
case class LocalCommitPublished(commitTx: Transaction, claimMainDelayedOutputTx: Option[Transaction], htlcSuccessTxs: List[Transaction], htlcTimeoutTxs: List[Transaction], claimHtlcDelayedTxs: List[Transaction], irrevocablySpent: Map[OutPoint, ByteVector32]) {
274279
def isConfirmed: Boolean = {
275280
// NB: if multiple transactions end up in the same block, the first confirmation we receive may not be the commit tx.
276281
// However if the confirmed tx spends from the commit tx, we know that the commit tx is already confirmed and we know
277282
// the type of closing.
278-
val confirmedTxs = irrevocablySpent.values.toSet
279-
(commitTx :: claimMainDelayedOutputTx.toList ::: htlcSuccessTxs ::: htlcTimeoutTxs ::: claimHtlcDelayedTxs).exists(tx => confirmedTxs.contains(tx.txid))
283+
irrevocablySpent.values.exists(tx => tx.txid == commitTx.txid) || irrevocablySpent.keys.exists(_.txid == commitTx.txid)
280284
}
281285
}
282-
case class RemoteCommitPublished(commitTx: Transaction, claimMainOutputTx: Option[Transaction], claimHtlcSuccessTxs: List[Transaction], claimHtlcTimeoutTxs: List[Transaction], irrevocablySpent: Map[OutPoint, ByteVector32]) {
283-
def isConfirmed: Boolean = {
284-
// NB: if multiple transactions end up in the same block, the first confirmation we receive may not be the commit tx.
285-
// However if the confirmed tx spends from the commit tx, we know that the commit tx is already confirmed and we know
286-
// the type of closing.
287-
val confirmedTxs = irrevocablySpent.values.toSet
288-
(commitTx :: claimMainOutputTx.toList ::: claimHtlcSuccessTxs ::: claimHtlcTimeoutTxs).exists(tx => confirmedTxs.contains(tx.txid))
286+
287+
/**
288+
* Details about a force-close where we published our commitment.
289+
*
290+
* @param claimMainDelayedOutputTx tx claiming our main output (if we have one).
291+
* @param htlcTxs txs claiming HTLCs. There will be one entry for each pending HTLC. The value will be
292+
* None only for incoming HTLCs for which we don't have the preimage (we can't claim them yet).
293+
* @param claimHtlcDelayedTxs 3rd-stage txs (spending the output of HTLC txs).
294+
* @param claimAnchorTxs txs spending anchor outputs to bump the feerate of the commitment tx (if applicable).
295+
*/
296+
case class LocalCommitPublished(commitTx: Transaction, claimMainDelayedOutputTx: Option[ClaimLocalDelayedOutputTx], htlcTxs: Map[OutPoint, Option[HtlcTx]], claimHtlcDelayedTxs: List[ClaimLocalDelayedOutputTx], claimAnchorTxs: List[ClaimAnchorOutputTx], irrevocablySpent: Map[OutPoint, Transaction]) extends CommitPublished {
297+
/**
298+
* A local commit is considered done when:
299+
* - all commitment tx outputs that we can spend have been spent and confirmed (even if the spending tx was not ours)
300+
* - all 3rd stage txs (txs spending htlc txs) have been confirmed
301+
*/
302+
def isDone: Boolean = {
303+
val confirmedTxs = irrevocablySpent.values.map(_.txid).toSet
304+
// is the commitment tx confirmed (we need to check this because we may not have any outputs)?
305+
val isCommitTxConfirmed = confirmedTxs.contains(commitTx.txid)
306+
// is our main output confirmed (if we have one)?
307+
val isMainOutputConfirmed = claimMainDelayedOutputTx.forall(tx => irrevocablySpent.contains(tx.input.outPoint))
308+
// are all htlc outputs from the commitment tx spent (we need to check them all because we may receive preimages later)?
309+
val allHtlcsSpent = (htlcTxs.keySet -- irrevocablySpent.keys).isEmpty
310+
// are all outputs from htlc txs spent?
311+
val unconfirmedHtlcDelayedTxs = claimHtlcDelayedTxs.map(_.input.outPoint)
312+
// only the txs which parents are already confirmed may get confirmed (note that this eliminates outputs that have been double-spent by a competing tx)
313+
.filter(input => confirmedTxs.contains(input.txid))
314+
// has the tx already been confirmed?
315+
.filterNot(input => irrevocablySpent.contains(input))
316+
isCommitTxConfirmed && isMainOutputConfirmed && allHtlcsSpent && unconfirmedHtlcDelayedTxs.isEmpty
317+
}
318+
}
319+
320+
/**
321+
* Details about a force-close where they published their commitment.
322+
*
323+
* @param claimMainOutputTx tx claiming our main output (if we have one).
324+
* @param claimHtlcTxs txs claiming HTLCs. There will be one entry for each pending HTLC. The value will be None
325+
* only for incoming HTLCs for which we don't have the preimage (we can't claim them yet).
326+
* @param claimAnchorTxs txs spending anchor outputs to bump the feerate of the commitment tx (if applicable).
327+
*/
328+
case class RemoteCommitPublished(commitTx: Transaction, claimMainOutputTx: Option[ClaimRemoteCommitMainOutputTx], claimHtlcTxs: Map[OutPoint, Option[ClaimHtlcTx]], claimAnchorTxs: List[ClaimAnchorOutputTx], irrevocablySpent: Map[OutPoint, Transaction]) extends CommitPublished {
329+
/**
330+
* A remote commit is considered done when all commitment tx outputs that we can spend have been spent and confirmed
331+
* (even if the spending tx was not ours).
332+
*/
333+
def isDone: Boolean = {
334+
val confirmedTxs = irrevocablySpent.values.map(_.txid).toSet
335+
// is the commitment tx confirmed (we need to check this because we may not have any outputs)?
336+
val isCommitTxConfirmed = confirmedTxs.contains(commitTx.txid)
337+
// is our main output confirmed (if we have one)?
338+
val isMainOutputConfirmed = claimMainOutputTx.forall(tx => irrevocablySpent.contains(tx.input.outPoint))
339+
// are all htlc outputs from the commitment tx spent (we need to check them all because we may receive preimages later)?
340+
val allHtlcsSpent = (claimHtlcTxs.keySet -- irrevocablySpent.keys).isEmpty
341+
isCommitTxConfirmed && isMainOutputConfirmed && allHtlcsSpent
342+
}
343+
}
344+
345+
/**
346+
* Details about a force-close where they published one of their revoked commitments.
347+
*
348+
* @param claimMainOutputTx tx claiming our main output (if we have one).
349+
* @param mainPenaltyTx penalty tx claiming their main output (if they have one).
350+
* @param htlcPenaltyTxs penalty txs claiming every HTLC output.
351+
* @param claimHtlcDelayedPenaltyTxs penalty txs claiming the output of their HTLC txs (if they managed to get them confirmed before our htlcPenaltyTxs).
352+
*/
353+
case class RevokedCommitPublished(commitTx: Transaction, claimMainOutputTx: Option[ClaimRemoteCommitMainOutputTx], mainPenaltyTx: Option[MainPenaltyTx], htlcPenaltyTxs: List[HtlcPenaltyTx], claimHtlcDelayedPenaltyTxs: List[ClaimHtlcDelayedOutputPenaltyTx], irrevocablySpent: Map[OutPoint, Transaction]) extends CommitPublished {
354+
/**
355+
* A revoked commit is considered done when all commitment tx outputs that we can spend have been spent and confirmed
356+
* (even if the spending tx was not ours).
357+
*/
358+
def isDone: Boolean = {
359+
val confirmedTxs = irrevocablySpent.values.map(_.txid).toSet
360+
// is the commitment tx confirmed (we need to check this because we may not have any outputs)?
361+
val isCommitTxConfirmed = confirmedTxs.contains(commitTx.txid)
362+
// are there remaining spendable outputs from the commitment tx?
363+
val unspentCommitTxOutputs = {
364+
val commitOutputsSpendableByUs = (claimMainOutputTx.toSeq ++ mainPenaltyTx.toSeq ++ htlcPenaltyTxs).map(_.input.outPoint)
365+
commitOutputsSpendableByUs.toSet -- irrevocablySpent.keys
366+
}
367+
// are all outputs from htlc txs spent?
368+
val unconfirmedHtlcDelayedTxs = claimHtlcDelayedPenaltyTxs.map(_.input.outPoint)
369+
// only the txs which parents are already confirmed may get confirmed (note that this eliminates outputs that have been double-spent by a competing tx)
370+
.filter(input => confirmedTxs.contains(input.txid))
371+
// if one of the tx inputs has been spent, the tx has already been confirmed or a competing tx has been confirmed
372+
.filterNot(input => irrevocablySpent.contains(input))
373+
isCommitTxConfirmed && unspentCommitTxOutputs.isEmpty && unconfirmedHtlcDelayedTxs.isEmpty
289374
}
290375
}
291-
case class RevokedCommitPublished(commitTx: Transaction, claimMainOutputTx: Option[Transaction], mainPenaltyTx: Option[Transaction], htlcPenaltyTxs: List[Transaction], claimHtlcDelayedPenaltyTxs: List[Transaction], irrevocablySpent: Map[OutPoint, ByteVector32])
292376

293377
final case class DATA_WAIT_FOR_OPEN_CHANNEL(initFundee: INPUT_INIT_FUNDEE) extends Data {
294378
val channelId: ByteVector32 = initFundee.temporaryChannelId
@@ -352,27 +436,26 @@ final case class DATA_SHUTDOWN(commitments: Commitments,
352436
final case class DATA_NEGOTIATING(commitments: Commitments,
353437
localShutdown: Shutdown, remoteShutdown: Shutdown,
354438
closingTxProposed: List[List[ClosingTxProposed]], // one list for every negotiation (there can be several in case of disconnection)
355-
bestUnpublishedClosingTx_opt: Option[Transaction]) extends Data with HasCommitments {
439+
bestUnpublishedClosingTx_opt: Option[ClosingTx]) extends Data with HasCommitments {
356440
require(closingTxProposed.nonEmpty, "there must always be a list for the current negotiation")
357441
require(!commitments.localParams.isFunder || closingTxProposed.forall(_.nonEmpty), "funder must have at least one closing signature for every negotiation attempt because it initiates the closing")
358442
}
359443
final case class DATA_CLOSING(commitments: Commitments,
360444
fundingTx: Option[Transaction], // this will be non-empty if we are funder and we got in closing while waiting for our own tx to be published
361445
waitingSinceBlock: Long, // how long since we initiated the closing
362-
mutualCloseProposed: List[Transaction], // all exchanged closing sigs are flattened, we use this only to keep track of what publishable tx they have
363-
mutualClosePublished: List[Transaction] = Nil,
446+
mutualCloseProposed: List[ClosingTx], // all exchanged closing sigs are flattened, we use this only to keep track of what publishable tx they have
447+
mutualClosePublished: List[ClosingTx] = Nil,
364448
localCommitPublished: Option[LocalCommitPublished] = None,
365449
remoteCommitPublished: Option[RemoteCommitPublished] = None,
366450
nextRemoteCommitPublished: Option[RemoteCommitPublished] = None,
367451
futureRemoteCommitPublished: Option[RemoteCommitPublished] = None,
368452
revokedCommitPublished: List[RevokedCommitPublished] = Nil) extends Data with HasCommitments {
369-
val spendingTxes = mutualClosePublished ::: localCommitPublished.map(_.commitTx).toList ::: remoteCommitPublished.map(_.commitTx).toList ::: nextRemoteCommitPublished.map(_.commitTx).toList ::: futureRemoteCommitPublished.map(_.commitTx).toList ::: revokedCommitPublished.map(_.commitTx)
370-
require(spendingTxes.nonEmpty, "there must be at least one tx published in this state")
453+
val spendingTxs: List[Transaction] = mutualClosePublished.map(_.tx) ::: localCommitPublished.map(_.commitTx).toList ::: remoteCommitPublished.map(_.commitTx).toList ::: nextRemoteCommitPublished.map(_.commitTx).toList ::: futureRemoteCommitPublished.map(_.commitTx).toList ::: revokedCommitPublished.map(_.commitTx)
454+
require(spendingTxs.nonEmpty, "there must be at least one tx published in this state")
371455
}
372456

373457
final case class DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT(commitments: Commitments, remoteChannelReestablish: ChannelReestablish) extends Data with HasCommitments
374458

375-
376459
/**
377460
* @param features current connection features, or last features used if the channel is disconnected. Note that these
378461
* features are updated at each reconnection and may be different from the ones that were used when the

0 commit comments

Comments
 (0)