From 3b1537e36ad9259633d3efb10aea093f1e6d1977 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 16 Dec 2021 10:23:34 +0100 Subject: [PATCH 01/23] Add deadline information to tx publication When a channel is force-closed, we tell the `TxPublisher` the block before which the transaction should be confirmed. Then it will be up to the `TxPublisher` to set the feerate appropriately and fee-bump whenever it becomes necessary. --- .../fr/acinq/eclair/channel/Channel.scala | 29 ++++- .../eclair/channel/publish/TxPublisher.scala | 2 +- .../channel/publish/TxPublisherSpec.scala | 12 +- .../channel/states/e/NormalStateSpec.scala | 120 ++++++++++++++---- .../channel/states/h/ClosingStateSpec.scala | 28 ++-- 5 files changed, 147 insertions(+), 44 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala index 6bed4afef7..4a3c7daf0e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala @@ -2467,8 +2467,21 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo val redeemableHtlcTxs = htlcTxs.values.flatten.map(tx => PublishFinalTx(tx, tx.fee, Some(commitTx.txid))) List(PublishFinalTx(commitTx, commitInput, "commit-tx", Closing.commitTxFee(commitments.commitInput, commitTx, isFunder), None)) ++ (claimMainDelayedOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)) ++ redeemableHtlcTxs ++ claimHtlcDelayedTxs.map(tx => PublishFinalTx(tx, tx.fee, None))) case _: Transactions.AnchorOutputsCommitmentFormat => - val claimLocalAnchor = claimAnchorTxs.collect { case tx: Transactions.ClaimLocalAnchorOutputTx => PublishReplaceableTx(tx, commitments) } - val redeemableHtlcTxs = htlcTxs.values.collect { case Some(tx) => PublishReplaceableTx(tx, commitments) } + val redeemableHtlcTxs = htlcTxs.values.collect { + case Some(tx) => + val htlc_opt = tx match { + case _: Transactions.HtlcSuccessTx => commitments.localCommit.spec.findIncomingHtlcById(tx.htlcId).map(_.add) + case _: Transactions.HtlcTimeoutTx => commitments.localCommit.spec.findOutgoingHtlcById(tx.htlcId).map(_.add) + } + val deadline = htlc_opt.map(_.cltvExpiry.toLong).getOrElse(nodeParams.currentBlockHeight + nodeParams.onChainFeeConf.feeTargets.commitmentBlockTarget) + PublishReplaceableTx(tx, commitments, deadline) + } + val claimLocalAnchor = claimAnchorTxs.collect { + case tx: Transactions.ClaimLocalAnchorOutputTx => + // NB: if we don't have pending HTLCs, we don't have funds at risk, so we can use a longer deadline. + val deadline = redeemableHtlcTxs.map(_.deadline).minOption.getOrElse(nodeParams.currentBlockHeight + nodeParams.onChainFeeConf.feeTargets.claimMainBlockTarget) + PublishReplaceableTx(tx, commitments, deadline) + } List(PublishFinalTx(commitTx, commitInput, "commit-tx", Closing.commitTxFee(commitments.commitInput, commitTx, isFunder), None)) ++ claimLocalAnchor ++ claimMainDelayedOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)) ++ redeemableHtlcTxs ++ claimHtlcDelayedTxs.map(tx => PublishFinalTx(tx, tx.fee, None)) } publishIfNeeded(publishQueue, irrevocablySpent) @@ -2538,7 +2551,17 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo private def doPublish(remoteCommitPublished: RemoteCommitPublished, commitments: Commitments): Unit = { import remoteCommitPublished._ - val publishQueue = claimMainOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)).toSeq ++ claimHtlcTxs.values.flatten.map(tx => PublishReplaceableTx(tx, commitments)) + val redeemableHtlcTxs = claimHtlcTxs.values.collect { + case Some(tx) => + val htlc_opt = tx match { + case _: Transactions.LegacyClaimHtlcSuccessTx => commitments.remoteNextCommitInfo.left.toOption.flatMap(_.nextRemoteCommit.spec.findOutgoingHtlcById(tx.htlcId)).orElse(commitments.remoteCommit.spec.findOutgoingHtlcById(tx.htlcId)).map(_.add) + case _: Transactions.ClaimHtlcSuccessTx => commitments.remoteNextCommitInfo.left.toOption.flatMap(_.nextRemoteCommit.spec.findOutgoingHtlcById(tx.htlcId)).orElse(commitments.remoteCommit.spec.findOutgoingHtlcById(tx.htlcId)).map(_.add) + case _: Transactions.ClaimHtlcTimeoutTx => commitments.remoteNextCommitInfo.left.toOption.flatMap(_.nextRemoteCommit.spec.findIncomingHtlcById(tx.htlcId)).orElse(commitments.remoteCommit.spec.findIncomingHtlcById(tx.htlcId)).map(_.add) + } + val deadline = htlc_opt.map(_.cltvExpiry.toLong).getOrElse(nodeParams.currentBlockHeight + nodeParams.onChainFeeConf.feeTargets.commitmentBlockTarget) + PublishReplaceableTx(tx, commitments, deadline) + } + val publishQueue = claimMainOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)).toSeq ++ redeemableHtlcTxs publishIfNeeded(publishQueue, irrevocablySpent) // we watch: diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala index 6ae8f23a60..6aaf5360b9 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala @@ -86,7 +86,7 @@ object TxPublisher { def apply(txInfo: TransactionWithInputInfo, fee: Satoshi, parentTx_opt: Option[ByteVector32]): PublishFinalTx = PublishFinalTx(txInfo.tx, txInfo.input.outPoint, txInfo.desc, fee, parentTx_opt) } /** Publish an unsigned transaction that can be RBF-ed. */ - case class PublishReplaceableTx(txInfo: ReplaceableTransactionWithInputInfo, commitments: Commitments) extends PublishTx { + case class PublishReplaceableTx(txInfo: ReplaceableTransactionWithInputInfo, commitments: Commitments, deadline: Long) extends PublishTx { override def input: OutPoint = txInfo.input.outPoint override def desc: String = txInfo.desc } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala index c8fb84e816..c76c39f5ca 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala @@ -109,7 +109,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { f.setFeerate(FeeratePerKw(750 sat)) val input = OutPoint(randomBytes32(), 3) - val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null) + val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null, 0) txPublisher ! cmd val child = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor val p = child.expectMsgType[ReplaceableTxPublisher.Publish] @@ -122,7 +122,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { f.setFeerate(FeeratePerKw(750 sat)) val input = OutPoint(randomBytes32(), 3) - val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null) + val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null, 0) txPublisher ! cmd val child1 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor val p1 = child1.expectMsgType[ReplaceableTxPublisher.Publish] @@ -159,7 +159,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val attempt2 = factory.expectMsgType[FinalTxPublisherSpawned].actor attempt2.expectMsgType[FinalTxPublisher.Publish] - val cmd3 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null) + val cmd3 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null, 0) txPublisher ! cmd3 val attempt3 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor attempt3.expectMsgType[ReplaceableTxPublisher.Publish] @@ -181,7 +181,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val attempt1 = factory.expectMsgType[FinalTxPublisherSpawned] attempt1.actor.expectMsgType[FinalTxPublisher.Publish] - val cmd2 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null) + val cmd2 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null, 0) txPublisher ! cmd2 val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish] @@ -201,13 +201,13 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { f.setFeerate(FeeratePerKw(600 sat)) val input = OutPoint(randomBytes32(), 7) val paymentHash = randomBytes32() - val cmd1 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null) + val cmd1 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, 0) txPublisher ! cmd1 val attempt1 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt1.actor.expectMsgType[ReplaceableTxPublisher.Publish] f.setFeerate(FeeratePerKw(750 sat)) - val cmd2 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null) + val cmd2 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, 0) txPublisher ! cmd2 val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index 36d5469768..90c4c31849 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -19,7 +19,7 @@ package fr.acinq.eclair.channel.states.e import akka.actor.ActorRef import akka.testkit.TestProbe import fr.acinq.bitcoin.Crypto.PrivateKey -import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Crypto, SatoshiLong, Script, ScriptFlags, Transaction} +import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Crypto, OutPoint, SatoshiLong, Script, ScriptFlags, Transaction} import fr.acinq.eclair.Features.StaticRemoteKey import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair.UInt64.Conversions._ @@ -37,8 +37,8 @@ import fr.acinq.eclair.payment.OutgoingPaymentPacket import fr.acinq.eclair.payment.relay.Relayer._ import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.transactions.DirectedHtlc.{incoming, outgoing} -import fr.acinq.eclair.transactions.Transactions.{DefaultCommitmentFormat, HtlcSuccessTx, weight2fee} -import fr.acinq.eclair.transactions.{CommitmentSpec, Transactions} +import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ChannelUpdate, ClosingSigned, CommitSig, Error, FailureMessageCodecs, PermanentChannelFailure, RevokeAndAck, Shutdown, TemporaryNodeFailure, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFee, UpdateFulfillHtlc, Warning} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -2928,14 +2928,14 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv WatchFundingSpentTriggered (their commit w/ htlc)") { f => import f._ - val (ra1, htlca1) = addHtlc(250000000 msat, alice, bob, alice2bob, bob2alice) - val (ra2, htlca2) = addHtlc(100000000 msat, alice, bob, alice2bob, bob2alice) + val (ra1, htlca1) = addHtlc(250000000 msat, CltvExpiryDelta(50), alice, bob, alice2bob, bob2alice) + val (ra2, htlca2) = addHtlc(100000000 msat, CltvExpiryDelta(60), alice, bob, alice2bob, bob2alice) val (ra3, htlca3) = addHtlc(10000 msat, alice, bob, alice2bob, bob2alice) - val (rb1, htlcb1) = addHtlc(50000000 msat, bob, alice, bob2alice, alice2bob) - val (rb2, htlcb2) = addHtlc(55000000 msat, bob, alice, bob2alice, alice2bob) + val (rb1, htlcb1) = addHtlc(50000000 msat, CltvExpiryDelta(55), bob, alice, bob2alice, alice2bob) + val (rb2, htlcb2) = addHtlc(55000000 msat, CltvExpiryDelta(65), bob, alice, bob2alice, alice2bob) crossSign(alice, bob, alice2bob, bob2alice) - fulfillHtlc(1, ra2, bob, alice, bob2alice, alice2bob) - fulfillHtlc(0, rb1, alice, bob, alice2bob, bob2alice) + fulfillHtlc(htlca2.id, ra2, bob, alice, bob2alice, alice2bob) + fulfillHtlc(htlcb1.id, rb1, alice, bob, alice2bob, bob2alice) // at this point here is the situation from alice pov and what she should do when bob publishes his commit tx: // balances : @@ -2956,17 +2956,21 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // in response to that, alice publishes her claim txs val claimMain = alice2blockchain.expectMsgType[PublishFinalTx].tx // in addition to her main output, alice can only claim 3 out of 4 htlcs, she can't do anything regarding the htlc sent by bob for which she does not have the preimage - val claimHtlcTxs = (1 to 3).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx) + val claimHtlcTxs = (1 to 3).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx]) val htlcAmountClaimed = (for (claimHtlcTx <- claimHtlcTxs) yield { - assert(claimHtlcTx.txIn.size == 1) - assert(claimHtlcTx.txOut.size == 1) - Transaction.correctlySpends(claimHtlcTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - claimHtlcTx.txOut.head.amount + assert(claimHtlcTx.txInfo.tx.txIn.size == 1) + assert(claimHtlcTx.txInfo.tx.txOut.size == 1) + Transaction.correctlySpends(claimHtlcTx.txInfo.tx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + claimHtlcTx.txInfo.tx.txOut.head.amount }).sum // at best we have a little less than 450 000 + 250 000 + 100 000 + 50 000 = 850 000 (because fees) val amountClaimed = claimMain.txOut.head.amount + htlcAmountClaimed assert(amountClaimed === 814880.sat) + // alice sets the publication deadlines to the HTLC expiry + assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcSuccessTx, _, deadline) => (tx.htlcId, deadline) }.toMap === Map(htlcb1.id -> htlcb1.cltvExpiry.toLong)) + assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcTimeoutTx, _, deadline) => (tx.htlcId, deadline) }.toMap === Map(htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.txid) alice2blockchain.expectMsgType[WatchOutputSpent] // htlc 1 @@ -3008,14 +3012,14 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv WatchFundingSpentTriggered (their *next* commit w/ htlc)") { f => import f._ - val (ra1, htlca1) = addHtlc(250000000 msat, alice, bob, alice2bob, bob2alice) - val (ra2, htlca2) = addHtlc(100000000 msat, alice, bob, alice2bob, bob2alice) + val (ra1, htlca1) = addHtlc(250000000 msat, CltvExpiryDelta(24), alice, bob, alice2bob, bob2alice) + val (ra2, htlca2) = addHtlc(100000000 msat, CltvExpiryDelta(30), alice, bob, alice2bob, bob2alice) val (ra3, htlca3) = addHtlc(10000 msat, alice, bob, alice2bob, bob2alice) val (rb1, htlcb1) = addHtlc(50000000 msat, bob, alice, bob2alice, alice2bob) val (rb2, htlcb2) = addHtlc(55000000 msat, bob, alice, bob2alice, alice2bob) crossSign(alice, bob, alice2bob, bob2alice) - fulfillHtlc(1, ra2, bob, alice, bob2alice, alice2bob) - fulfillHtlc(0, rb1, alice, bob, alice2bob, bob2alice) + fulfillHtlc(htlca2.id, ra2, bob, alice, bob2alice, alice2bob) + fulfillHtlc(htlcb1.id, rb1, alice, bob, alice2bob, bob2alice) // alice sign but we intercept bob's revocation alice ! CMD_SIGN() alice2bob.expectMsgType[CommitSig] @@ -3043,17 +3047,20 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // in response to that, alice publishes her claim txs val claimMain = alice2blockchain.expectMsgType[PublishFinalTx].tx // in addition to her main output, alice can only claim 2 out of 3 htlcs, she can't do anything regarding the htlc sent by bob for which she does not have the preimage - val claimHtlcTxs = (1 to 2).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx) + val claimHtlcTxs = (1 to 2).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx]) val htlcAmountClaimed = (for (claimHtlcTx <- claimHtlcTxs) yield { - assert(claimHtlcTx.txIn.size == 1) - assert(claimHtlcTx.txOut.size == 1) - Transaction.correctlySpends(claimHtlcTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - claimHtlcTx.txOut.head.amount + assert(claimHtlcTx.txInfo.tx.txIn.size == 1) + assert(claimHtlcTx.txInfo.tx.txOut.size == 1) + Transaction.correctlySpends(claimHtlcTx.txInfo.tx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + claimHtlcTx.txInfo.tx.txOut.head.amount }).sum // at best we have a little less than 500 000 + 250 000 + 100 000 = 850 000 (because fees) val amountClaimed = claimMain.txOut.head.amount + htlcAmountClaimed assert(amountClaimed === 822310.sat) + // alice sets the publication deadlines to the HTLC expiry + assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcTimeoutTx, _, deadline) => (tx.htlcId, deadline) }.toMap === Map(htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.txid) // claim-main alice2blockchain.expectMsgType[WatchOutputSpent] // htlc 1 @@ -3260,7 +3267,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(getHtlcTimeoutTxs(localCommitPublished).length === 2) assert(localCommitPublished.claimHtlcDelayedTxs.isEmpty) - // alice can only claim 3 out of 4 htlcs, she can't do anything regarding the htlc sent by bob for which she does not have the htlc + // alice can only claim 3 out of 4 htlcs, she can't do anything regarding the htlc sent by bob for which she does not have the preimage // so we expect 4 transactions: // - 1 tx to claim the main delayed output // - 3 txs for each htlc @@ -3293,6 +3300,71 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectNoMessage(1 second) } + test("recv Error (anchor outputs zero fee htlc txs)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + import f._ + + val (ra1, htlca1) = addHtlc(250000000 msat, CltvExpiryDelta(20), alice, bob, alice2bob, bob2alice) + val (ra2, htlca2) = addHtlc(100000000 msat, CltvExpiryDelta(25), alice, bob, alice2bob, bob2alice) + val (ra3, htlca3) = addHtlc(10000 msat, alice, bob, alice2bob, bob2alice) + val (rb1, htlcb1) = addHtlc(50000000 msat, CltvExpiryDelta(30), bob, alice, bob2alice, alice2bob) + val (rb2, htlcb2) = addHtlc(55000000 msat, CltvExpiryDelta(35), bob, alice, bob2alice, alice2bob) + crossSign(alice, bob, alice2bob, bob2alice) + fulfillHtlc(htlcb1.id, rb1, alice, bob, alice2bob, bob2alice) + + // an error occurs and alice publishes her commit tx + val aliceCommitTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx + alice ! Error(ByteVector32.Zeroes, "oops") + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid === aliceCommitTx.txid) + assert(aliceCommitTx.txOut.size == 8) // two main outputs, two anchors and 4 pending htlcs + awaitCond(alice.stateName == CLOSING) + + val localAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(localAnchor.deadline === htlca1.cltvExpiry.toLong) // the deadline is set to match the first htlc that expires + val claimMain = alice2blockchain.expectMsgType[PublishFinalTx] + // alice can only claim 3 out of 4 htlcs, she can't do anything regarding the htlc sent by bob for which she does not have the preimage + val htlcDeadlines = Seq( + alice2blockchain.expectMsgType[PublishReplaceableTx], // htlc 1 + alice2blockchain.expectMsgType[PublishReplaceableTx], // htlc 2 + alice2blockchain.expectMsgType[PublishReplaceableTx], // htlc 3 + ).map(p => p.txInfo.asInstanceOf[HtlcTx].htlcId -> p.deadline).toMap + assert(htlcDeadlines === Map(htlcb1.id -> htlcb1.cltvExpiry.toLong, htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) + + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === aliceCommitTx.txid) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.tx.txid) + val watchedOutputs = Seq( + alice2blockchain.expectMsgType[WatchOutputSpent], // htlc 1 + alice2blockchain.expectMsgType[WatchOutputSpent], // htlc 2 + alice2blockchain.expectMsgType[WatchOutputSpent], // htlc 3 + alice2blockchain.expectMsgType[WatchOutputSpent], // htlc 4 + alice2blockchain.expectMsgType[WatchOutputSpent], // local anchor + ).map(w => OutPoint(w.txId.reverse, w.outputIndex)).toSet + val localCommitPublished = alice.stateData.asInstanceOf[DATA_CLOSING].localCommitPublished.get + assert(watchedOutputs === localCommitPublished.htlcTxs.keySet + localAnchor.txInfo.input.outPoint) + alice2blockchain.expectNoMessage(1 second) + } + + test("recv Error (anchor outputs zero fee htlc txs without htlcs)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + import f._ + + // an error occurs and alice publishes her commit tx + val aliceCommitTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx + alice ! Error(ByteVector32.Zeroes, "oops") + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid === aliceCommitTx.txid) + assert(aliceCommitTx.txOut.size == 4) // two main outputs and two anchors + awaitCond(alice.stateName == CLOSING) + + val currentBlockHeight = alice.underlyingActor.nodeParams.currentBlockHeight + val blockTargets = alice.underlyingActor.nodeParams.onChainFeeConf.feeTargets + val localAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] + // When there are no pending HTLCs, there is no rush to get the commit tx confirmed + assert(localAnchor.deadline === currentBlockHeight + blockTargets.claimMainBlockTarget) + val claimMain = alice2blockchain.expectMsgType[PublishFinalTx] + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === aliceCommitTx.txid) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.tx.txid) + assert(alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex === localAnchor.input.index) + alice2blockchain.expectNoMessage(1 second) + } + test("recv Error (nothing at stake)", Tag(ChannelStateTestsTags.NoPushMsat)) { f => import f._ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala index 27b680ce0a..e53bf46978 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala @@ -30,7 +30,7 @@ import fr.acinq.eclair.payment.relay.Relayer._ import fr.acinq.eclair.transactions.Transactions.{AnchorOutputsCommitmentFormat, DefaultCommitmentFormat, HtlcSuccessTx, HtlcTimeoutTx, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat} import fr.acinq.eclair.transactions.{Scripts, Transactions} import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{CltvExpiry, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, TimestampSecond, randomBytes32, randomKey} +import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, TimestampSecond, randomBytes32, randomKey} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} import scodec.bits.ByteVector @@ -752,12 +752,12 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv WatchTxConfirmedTriggered (remote commit) followed by CMD_FULFILL_HTLC") { f => import f._ // An HTLC Bob -> Alice is cross-signed that will be fulfilled later. - val (r1, htlc1) = addHtlc(110000000 msat, bob, alice, bob2alice, alice2bob) + val (r1, htlc1) = addHtlc(110000000 msat, CltvExpiryDelta(48), bob, alice, bob2alice, alice2bob) crossSign(bob, alice, bob2alice, alice2bob) relayerA.expectMsgType[RelayForward] // An HTLC Alice -> Bob is only signed by Alice: Bob has two spendable commit tx. - val (_, htlc2) = addHtlc(95000000 msat, alice, bob, alice2bob, bob2alice) + val (_, htlc2) = addHtlc(95000000 msat, CltvExpiryDelta(144), alice, bob, alice2bob, bob2alice) alice ! CMD_SIGN() alice2bob.expectMsgType[CommitSig] // We stop here: Alice sent her CommitSig, but doesn't hear back from Bob. @@ -775,7 +775,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice2blockchain.expectMsgType[PublishFinalTx].tx === closingState.claimMainOutputTx.get.tx) val claimHtlcSuccessTx = getClaimHtlcSuccessTxs(alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.get).head.tx Transaction.correctlySpends(claimHtlcSuccessTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx === claimHtlcSuccessTx) + val publishHtlcSuccessTx = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(publishHtlcSuccessTx.txInfo.tx === claimHtlcSuccessTx) + assert(publishHtlcSuccessTx.deadline === htlc1.cltvExpiry.toLong) // Alice resets watches on all relevant transactions. assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) @@ -800,7 +802,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with import f._ // alice sends an htlc to bob - addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) + val (_, htlca) = addHtlc(50000000 msat, CltvExpiryDelta(24), alice, bob, alice2bob, bob2alice) crossSign(alice, bob, alice2bob, bob2alice) val bobCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx val closingState = remoteClose(bobCommitTx, alice, alice2blockchain) @@ -816,7 +818,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // we should re-publish unconfirmed transactions closingState.claimMainOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[PublishFinalTx].tx === claimMain.tx)) - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx === htlcTimeoutTx.tx) + val publishHtlcTimeoutTx = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(publishHtlcTimeoutTx.txInfo.tx === htlcTimeoutTx.tx) + assert(publishHtlcTimeoutTx.deadline === htlca.cltvExpiry.toLong) closingState.claimMainOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.tx.txid)) assert(alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex === htlcTimeoutTx.input.outPoint.index) } @@ -919,12 +923,12 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv WatchTxConfirmedTriggered (next remote commit) followed by CMD_FULFILL_HTLC") { f => import f._ // An HTLC Bob -> Alice is cross-signed that will be fulfilled later. - val (r1, htlc1) = addHtlc(110000000 msat, bob, alice, bob2alice, alice2bob) + val (r1, htlc1) = addHtlc(110000000 msat, CltvExpiryDelta(64), bob, alice, bob2alice, alice2bob) crossSign(bob, alice, bob2alice, alice2bob) relayerA.expectMsgType[RelayForward] // An HTLC Alice -> Bob is only signed by Alice: Bob has two spendable commit tx. - val (_, htlc2) = addHtlc(95000000 msat, alice, bob, alice2bob, bob2alice) + val (_, htlc2) = addHtlc(95000000 msat, CltvExpiryDelta(32), alice, bob, alice2bob, bob2alice) alice ! CMD_SIGN() alice2bob.expectMsgType[CommitSig] alice2bob.forward(bob) @@ -946,8 +950,12 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice2blockchain.expectMsgType[PublishFinalTx].tx === closingState.claimMainOutputTx.get.tx) val claimHtlcSuccessTx = getClaimHtlcSuccessTxs(alice.stateData.asInstanceOf[DATA_CLOSING].nextRemoteCommitPublished.get).head.tx Transaction.correctlySpends(claimHtlcSuccessTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx === claimHtlcSuccessTx) - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx === claimHtlcTimeoutTx) + val publishHtlcSuccessTx = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(publishHtlcSuccessTx.txInfo.tx === claimHtlcSuccessTx) + assert(publishHtlcSuccessTx.deadline === htlc1.cltvExpiry.toLong) + val publishHtlcTimeoutTx = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(publishHtlcTimeoutTx.txInfo.tx === claimHtlcTimeoutTx) + assert(publishHtlcTimeoutTx.deadline === htlc2.cltvExpiry.toLong) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === closingState.claimMainOutputTx.get.tx.txid) From 9bfdd90d1fce3d295ffdc6599dbba5737d9ceed9 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 17 Dec 2021 14:42:03 +0100 Subject: [PATCH 02/23] Evaluate feerate at tx broadcast time We previously evaluated the feerate when we issued the command to publish a transaction. However, the transaction may actually be published many blocks later (especially for HTLC-timeout transactions, for which we may issue commands long before the timeout has been reached). We change the command to include the deadline, and evaluate the feerate to meet that deadline when we're ready to broadcast a valid transaction. --- .../fr/acinq/eclair/channel/Helpers.scala | 4 + .../publish/ReplaceableTxPublisher.scala | 110 +++++++------ .../eclair/channel/publish/TxPublisher.scala | 33 ++-- .../scala/fr/acinq/eclair/TestConstants.scala | 13 ++ .../fr/acinq/eclair/channel/HelpersSpec.scala | 13 ++ .../publish/ReplaceableTxPublisherSpec.scala | 144 +++++++++++++----- .../channel/publish/TxPublisherSpec.scala | 45 +++--- 7 files changed, 231 insertions(+), 131 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 486acf0e9c..026059ae1d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -1078,7 +1078,11 @@ object Helpers { log.error(s"some htlcs don't have a corresponding timeout transaction: tx=$tx, htlcs=$matchingHtlcs, timeout-txs=$matchingTxs") } matchingHtlcs.zip(matchingTxs).collectFirst { + // HTLC transactions cannot change when anchor outputs is unused, so we directly check the txid case (add, timeoutTx) if timeoutTx.txid == tx.txid => add + // Claim-HTLC transactions can be updated to pay more or less fees by changing the output amount, so we cannot + // rely on txid equality: we instead check that the input is the same and the output goes to the same address. + case (add, timeoutTx) if timeoutTx.txIn.head.outPoint == tx.txIn.head.outPoint && timeoutTx.txOut.head.publicKeyScript == tx.txOut.head.publicKeyScript => add } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index 7db9def4b7..f19c32b682 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -23,7 +23,7 @@ import fr.acinq.eclair.NodeParams import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.FundTransactionOptions -import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.blockchain.fee.{FeeEstimator, FeeratePerKw} import fr.acinq.eclair.channel.publish.TxPublisher.{TxPublishLogContext, TxRejectedReason} import fr.acinq.eclair.channel.publish.TxTimeLocksMonitor.CheckTx import fr.acinq.eclair.channel.{Commitments, HtlcTxAndRemoteSig} @@ -47,7 +47,7 @@ object ReplaceableTxPublisher { // @formatter:off sealed trait Command - case class Publish(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, targetFeerate: FeeratePerKw) extends Command + case class Publish(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx) extends Command private case object TimeLocksOk extends Command private case object CommitTxAlreadyConfirmed extends RuntimeException with Command private case object RemoteCommitTxPublished extends RuntimeException with Command @@ -73,6 +73,22 @@ object ReplaceableTxPublisher { } } + def getFeerate(feeEstimator: FeeEstimator, deadline: Long, currentBlockHeight: Long): FeeratePerKw = { + val remainingBlocks = deadline - currentBlockHeight + val blockTarget = remainingBlocks match { + // If our target is still very far in the future, no need to rush + case t if t >= 144 => 144 + case t if t >= 72 => 72 + case t if t >= 36 => 36 + // However, if we get closer to the deadline, we start being more aggressive + case t if t >= 18 => 12 + case t if t >= 12 => 6 + case t if t >= 2 => 2 + case _ => 1 + } + feeEstimator.getFeeratePerKw(blockTarget) + } + /** * Adjust the amount of the change output of an anchor tx to match our target feerate. * We need this because fundrawtransaction doesn't allow us to leave non-wallet inputs, so we have to add them @@ -202,17 +218,17 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, def start(): Behavior[Command] = { Behaviors.receiveMessagePartial { - case Publish(replyTo, cmd, targetFeerate) => + case Publish(replyTo, cmd) => cmd.txInfo match { - case _: ClaimLocalAnchorOutputTx => checkAnchorPreconditions(replyTo, cmd, targetFeerate) - case htlcTx: HtlcTx => checkHtlcPreconditions(replyTo, cmd, htlcTx, targetFeerate) - case claimHtlcTx: ClaimHtlcTx => checkClaimHtlcPreconditions(replyTo, cmd, claimHtlcTx, targetFeerate) + case _: ClaimLocalAnchorOutputTx => checkAnchorPreconditions(replyTo, cmd) + case htlcTx: HtlcTx => checkHtlcPreconditions(replyTo, cmd, htlcTx) + case claimHtlcTx: ClaimHtlcTx => checkClaimHtlcPreconditions(replyTo, cmd, claimHtlcTx) } case Stop => Behaviors.stopped } } - def checkAnchorPreconditions(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, targetFeerate: FeeratePerKw): Behavior[Command] = { + def checkAnchorPreconditions(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx): Behavior[Command] = { // We verify that: // - our commit is not confirmed (if it is, no need to claim our anchor) // - their commit is not confirmed (if it is, no need to claim our anchor either) @@ -233,6 +249,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } Behaviors.receiveMessagePartial { case PreconditionsOk => + val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.deadline, nodeParams.currentBlockHeight) val commitFeerate = cmd.commitments.localCommit.spec.commitTxFeerate if (targetFeerate <= commitFeerate) { log.info("skipping {}: commit feerate is high enough (feerate={})", cmd.desc, commitFeerate) @@ -256,7 +273,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } - def checkHtlcPreconditions(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, htlcTx: HtlcTx, targetFeerate: FeeratePerKw): Behavior[Command] = { + def checkHtlcPreconditions(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, htlcTx: HtlcTx): Behavior[Command] = { // We verify that: // - their commit is not confirmed: if it is, there is no need to publish our HTLC transactions context.pipeToSelf(bitcoinClient.getTxConfirmations(cmd.commitments.remoteCommit.txid)) { @@ -267,7 +284,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, Behaviors.receiveMessagePartial { case PreconditionsOk => HtlcTxAndWitnessData(htlcTx, cmd.commitments) match { - case Some(txWithWitnessData) => checkTimeLocks(replyTo, cmd, txWithWitnessData, targetFeerate) + case Some(txWithWitnessData) => checkTimeLocks(replyTo, cmd, txWithWitnessData) case None => log.error("witness data not found for htlcId={}, skipping...", htlcTx.htlcId) sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false))) @@ -279,7 +296,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } - def checkClaimHtlcPreconditions(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, claimHtlcTx: ClaimHtlcTx, targetFeerate: FeeratePerKw): Behavior[Command] = { + def checkClaimHtlcPreconditions(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, claimHtlcTx: ClaimHtlcTx): Behavior[Command] = { // We verify that: // - our commit is not confirmed: if it is, there is no need to publish our claim-HTLC transactions context.pipeToSelf(bitcoinClient.getTxConfirmations(cmd.commitments.localCommit.commitTxAndRemoteSig.commitTx.tx.txid)) { @@ -288,7 +305,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case Failure(_) => PreconditionsOk // if our checks fail, we don't want it to prevent us from publishing claim-HTLC transactions } Behaviors.receiveMessagePartial { - case PreconditionsOk => checkTimeLocks(replyTo, cmd, claimHtlcTx, targetFeerate) + case PreconditionsOk => checkTimeLocks(replyTo, cmd, claimHtlcTx) case LocalCommitTxConfirmed => log.warn("cannot publish {}: local commit has been confirmed", cmd.desc) sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.ConflictingTxConfirmed)) @@ -296,11 +313,12 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } - def checkTimeLocks(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, htlcTxWithWitnessData: HtlcTxAndWitnessData, targetFeerate: FeeratePerKw): Behavior[Command] = { + def checkTimeLocks(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, htlcTxWithWitnessData: HtlcTxAndWitnessData): Behavior[Command] = { val timeLocksChecker = context.spawn(TxTimeLocksMonitor(nodeParams, watcher, loggingInfo), "time-locks-monitor") timeLocksChecker ! CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.txInfo.tx, cmd.desc) Behaviors.receiveMessagePartial { case TimeLocksOk => + val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.deadline, nodeParams.currentBlockHeight) val htlcFeerate = cmd.commitments.localCommit.spec.htlcTxFeerate(cmd.commitments.commitmentFormat) if (targetFeerate <= htlcFeerate) { val channelKeyPath = keyManager.keyPath(cmd.commitments.localParams, cmd.commitments.channelConfig) @@ -319,42 +337,44 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } - def checkTimeLocks(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, claimHtlcTx: ClaimHtlcTx, targetFeerate: FeeratePerKw): Behavior[Command] = { + def checkTimeLocks(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, claimHtlcTx: ClaimHtlcTx): Behavior[Command] = { val timeLocksChecker = context.spawn(TxTimeLocksMonitor(nodeParams, watcher, loggingInfo), "time-locks-monitor") timeLocksChecker ! CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.txInfo.tx, cmd.desc) Behaviors.receiveMessagePartial { - case TimeLocksOk => adjustClaimHtlcTxOutput(claimHtlcTx, targetFeerate, cmd.commitments) match { - case Left(reason) => - // The HTLC isn't economical to claim at the current feerate, but if the feerate goes down, we may want to claim it later. - log.warn("cannot publish {}: {}", cmd.desc, reason) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true))) - case Right((updatedClaimHtlcTx, fee)) => - val channelKeyPath = keyManager.keyPath(cmd.commitments.localParams, cmd.commitments.channelConfig) - val sig = keyManager.sign(updatedClaimHtlcTx, keyManager.htlcPoint(channelKeyPath), cmd.commitments.remoteCommit.remotePerCommitmentPoint, TxOwner.Local, cmd.commitments.commitmentFormat) - updatedClaimHtlcTx match { - case claimHtlcSuccess: LegacyClaimHtlcSuccessTx => - // The payment hash has been added to claim-htlc-success in https://github.com/ACINQ/eclair/pull/2101 - // Some transactions made with older versions of eclair may not set it correctly, in which case we simply - // publish the transaction as initially signed. - log.warn("payment hash not set for htlcId={}, publishing original transaction", claimHtlcSuccess.htlcId) - publish(replyTo, cmd, cmd.txInfo.tx, cmd.txInfo.fee) - case claimHtlcSuccess: ClaimHtlcSuccessTx => - val preimage_opt = cmd.commitments.localChanges.all.collectFirst { - case u: UpdateFulfillHtlc if Crypto.sha256(u.paymentPreimage) == claimHtlcSuccess.paymentHash => u.paymentPreimage - } - preimage_opt match { - case Some(preimage) => - val signedClaimHtlcTx = addSigs(claimHtlcSuccess, sig, preimage) - publish(replyTo, cmd, signedClaimHtlcTx.tx, fee) - case None => - log.error("preimage not found for htlcId={}, skipping...", claimHtlcSuccess.htlcId) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false))) - } - case claimHtlcTimeout: ClaimHtlcTimeoutTx => - val signedClaimHtlcTx = addSigs(claimHtlcTimeout, sig) - publish(replyTo, cmd, signedClaimHtlcTx.tx, fee) - } - } + case TimeLocksOk => + val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.deadline, nodeParams.currentBlockHeight) + adjustClaimHtlcTxOutput(claimHtlcTx, targetFeerate, cmd.commitments) match { + case Left(reason) => + // The HTLC isn't economical to claim at the current feerate, but if the feerate goes down, we may want to claim it later. + log.warn("cannot publish {}: {}", cmd.desc, reason) + sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true))) + case Right((updatedClaimHtlcTx, fee)) => + val channelKeyPath = keyManager.keyPath(cmd.commitments.localParams, cmd.commitments.channelConfig) + val sig = keyManager.sign(updatedClaimHtlcTx, keyManager.htlcPoint(channelKeyPath), cmd.commitments.remoteCommit.remotePerCommitmentPoint, TxOwner.Local, cmd.commitments.commitmentFormat) + updatedClaimHtlcTx match { + case claimHtlcSuccess: LegacyClaimHtlcSuccessTx => + // The payment hash has been added to claim-htlc-success in https://github.com/ACINQ/eclair/pull/2101 + // Some transactions made with older versions of eclair may not set it correctly, in which case we simply + // publish the transaction as initially signed. + log.warn("payment hash not set for htlcId={}, publishing original transaction", claimHtlcSuccess.htlcId) + publish(replyTo, cmd, cmd.txInfo.tx, cmd.txInfo.fee) + case claimHtlcSuccess: ClaimHtlcSuccessTx => + val preimage_opt = cmd.commitments.localChanges.all.collectFirst { + case u: UpdateFulfillHtlc if Crypto.sha256(u.paymentPreimage) == claimHtlcSuccess.paymentHash => u.paymentPreimage + } + preimage_opt match { + case Some(preimage) => + val signedClaimHtlcTx = addSigs(claimHtlcSuccess, sig, preimage) + publish(replyTo, cmd, signedClaimHtlcTx.tx, fee) + case None => + log.error("preimage not found for htlcId={}, skipping...", claimHtlcSuccess.htlcId) + sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false))) + } + case claimHtlcTimeout: ClaimHtlcTimeoutTx => + val signedClaimHtlcTx = addSigs(claimHtlcTimeout, sig) + publish(replyTo, cmd, signedClaimHtlcTx.tx, fee) + } + } case Stop => timeLocksChecker ! TxTimeLocksMonitor.Stop Behaviors.stopped diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala index 6aaf5360b9..e4134ecfa7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala @@ -24,7 +24,6 @@ import fr.acinq.bitcoin.{ByteVector32, OutPoint, Satoshi, Transaction} import fr.acinq.eclair.blockchain.CurrentBlockCount import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient -import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.Commitments import fr.acinq.eclair.transactions.Transactions.{ReplaceableTransactionWithInputInfo, TransactionWithInputInfo} import fr.acinq.eclair.{Logs, NodeParams} @@ -163,7 +162,6 @@ object TxPublisher { private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFactory, context: ActorContext[TxPublisher.Command], timers: TimerScheduler[TxPublisher.Command]) { import TxPublisher._ - import nodeParams.onChainFeeConf.{feeEstimator, feeTargets} private val log = context.log @@ -173,7 +171,7 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact def cmd: PublishTx } private case class FinalAttempt(id: UUID, cmd: PublishFinalTx, actor: ActorRef[FinalTxPublisher.Command]) extends PublishAttempt - private case class ReplaceableAttempt(id: UUID, cmd: PublishReplaceableTx, feerate: FeeratePerKw, actor: ActorRef[ReplaceableTxPublisher.Command]) extends PublishAttempt + private case class ReplaceableAttempt(id: UUID, cmd: PublishReplaceableTx, actor: ActorRef[ReplaceableTxPublisher.Command]) extends PublishAttempt // @formatter:on private def run(pending: Map[OutPoint, Seq[PublishAttempt]], retryNextBlock: Seq[PublishTx], channelInfo: ChannelLogContext): Behavior[Command] = { @@ -196,22 +194,21 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact } case cmd: PublishReplaceableTx => - val targetFeerate = feeEstimator.getFeeratePerKw(feeTargets.commitmentBlockTarget) val attempts = pending.getOrElse(cmd.input, Seq.empty) - val alreadyPublished = attempts.exists { - // If there is already an attempt at spending this outpoint with a higher feerate, there is no point in publishing again. - case a: ReplaceableAttempt => targetFeerate <= a.feerate - case _ => false + val alreadyPublished = attempts.collectFirst { + // If there is already an attempt at spending this outpoint with a more aggressive deadline, there is no point in publishing again. + case a: ReplaceableAttempt if a.cmd.deadline <= cmd.deadline => a.cmd.deadline } - if (alreadyPublished) { - log.info("not publishing replaceable {} spending {}:{} with feerate={}, publishing is already in progress", cmd.desc, cmd.input.txid, cmd.input.index, targetFeerate) - Behaviors.same - } else { - val publishId = UUID.randomUUID() - log.info("publishing replaceable {} spending {}:{} with id={} ({} other attempts)", cmd.desc, cmd.input.txid, cmd.input.index, publishId, attempts.length) - val actor = factory.spawnReplaceableTxPublisher(context, TxPublishLogContext(publishId, channelInfo.remoteNodeId, channelInfo.channelId_opt)) - actor ! ReplaceableTxPublisher.Publish(context.self, cmd, targetFeerate) - run(pending + (cmd.input -> attempts.appended(ReplaceableAttempt(publishId, cmd, targetFeerate, actor))), retryNextBlock, channelInfo) + alreadyPublished match { + case Some(currentDeadline) => + log.info("not publishing replaceable {} spending {}:{} with deadline={}, publishing is already in progress with deadline={}", cmd.desc, cmd.input.txid, cmd.input.index, cmd.deadline, currentDeadline) + Behaviors.same + case None => + val publishId = UUID.randomUUID() + log.info("publishing replaceable {} spending {}:{} with id={} ({} other attempts)", cmd.desc, cmd.input.txid, cmd.input.index, publishId, attempts.length) + val actor = factory.spawnReplaceableTxPublisher(context, TxPublishLogContext(publishId, channelInfo.remoteNodeId, channelInfo.channelId_opt)) + actor ! ReplaceableTxPublisher.Publish(context.self, cmd) + run(pending + (cmd.input -> attempts.appended(ReplaceableAttempt(publishId, cmd, actor))), retryNextBlock, channelInfo) } case result: PublishTxResult => result match { @@ -269,7 +266,7 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact private def stopAttempt(attempt: PublishAttempt): Unit = attempt match { case FinalAttempt(_, _, actor) => actor ! FinalTxPublisher.Stop - case ReplaceableAttempt(_, _, _, actor) => actor ! ReplaceableTxPublisher.Stop + case ReplaceableAttempt(_, _, actor) => actor ! ReplaceableTxPublisher.Stop } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 6aa92e4a75..12f19e7231 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -59,6 +59,19 @@ object TestConstants { override def getMempoolMinFeeratePerKw(): FeeratePerKw = currentFeerates.mempoolMinFee // @formatter:on + def setFeerate(target: Int, feerate: FeeratePerKw): Unit = { + target match { + case 1 => currentFeerates = currentFeerates.copy(block_1 = feerate) + case 2 => currentFeerates = currentFeerates.copy(blocks_2 = feerate) + case t if t <= 6 => currentFeerates = currentFeerates.copy(blocks_6 = feerate) + case t if t <= 12 => currentFeerates = currentFeerates.copy(blocks_12 = feerate) + case t if t <= 36 => currentFeerates = currentFeerates.copy(blocks_36 = feerate) + case t if t <= 72 => currentFeerates = currentFeerates.copy(blocks_72 = feerate) + case t if t <= 144 => currentFeerates = currentFeerates.copy(blocks_144 = feerate) + case _ => currentFeerates = currentFeerates.copy(blocks_1008 = feerate) + } + } + def setFeerate(feeratesPerKw: FeeratesPerKw): Unit = { currentFeerates = feeratesPerKw } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala index d3da3d6c6f..a7d0c444f4 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala @@ -17,6 +17,7 @@ package fr.acinq.eclair.channel import akka.testkit.{TestFSMRef, TestProbe} +import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin._ import fr.acinq.eclair.TestConstants.Alice.nodeParams import fr.acinq.eclair.TestUtils.NoLoggingDiagnostics @@ -187,8 +188,11 @@ class HelpersSpec extends TestKitBaseClass with AnyFunSuiteLike with ChannelStat val htlcTimeoutTxs = getHtlcTimeoutTxs(localCommitPublished) val htlcSuccessTxs = getHtlcSuccessTxs(localCommitPublished) + // Claim-HTLC txs can be modified to pay more (or less) fees by changing the output amount. val claimHtlcTimeoutTxs = getClaimHtlcTimeoutTxs(remoteCommitPublished) + val claimHtlcTimeoutTxsModifiedFees = claimHtlcTimeoutTxs.map(tx => tx.modify(_.tx.txOut).setTo(Seq(tx.tx.txOut.head.copy(amount = 5000 sat)))) val claimHtlcSuccessTxs = getClaimHtlcSuccessTxs(remoteCommitPublished) + val claimHtlcSuccessTxsModifiedFees = claimHtlcSuccessTxs.map(tx => tx.modify(_.tx.txOut).setTo(Seq(tx.tx.txOut.head.copy(amount = 5000 sat)))) val aliceTimedOutHtlcs = htlcTimeoutTxs.map(htlcTimeout => { val timedOutHtlcs = Closing.trimmedOrTimedOutHtlcs(commitmentFormat, localCommit, localCommitPublished, dustLimit, htlcTimeout.tx) @@ -204,10 +208,19 @@ class HelpersSpec extends TestKitBaseClass with AnyFunSuiteLike with ChannelStat }) assert(bobTimedOutHtlcs.toSet === bobHtlcs) + val bobTimedOutHtlcs2 = claimHtlcTimeoutTxsModifiedFees.map(claimHtlcTimeout => { + val timedOutHtlcs = Closing.trimmedOrTimedOutHtlcs(commitmentFormat, remoteCommit, remoteCommitPublished, dustLimit, claimHtlcTimeout.tx) + assert(timedOutHtlcs.size === 1) + timedOutHtlcs.head + }) + assert(bobTimedOutHtlcs2.toSet === bobHtlcs) + htlcSuccessTxs.foreach(htlcSuccess => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, localCommit, localCommitPublished, dustLimit, htlcSuccess.tx).isEmpty)) htlcSuccessTxs.foreach(htlcSuccess => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, remoteCommit, remoteCommitPublished, dustLimit, htlcSuccess.tx).isEmpty)) claimHtlcSuccessTxs.foreach(claimHtlcSuccess => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, localCommit, localCommitPublished, dustLimit, claimHtlcSuccess.tx).isEmpty)) + claimHtlcSuccessTxsModifiedFees.foreach(claimHtlcSuccess => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, localCommit, localCommitPublished, dustLimit, claimHtlcSuccess.tx).isEmpty)) claimHtlcSuccessTxs.foreach(claimHtlcSuccess => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, remoteCommit, remoteCommitPublished, dustLimit, claimHtlcSuccess.tx).isEmpty)) + claimHtlcSuccessTxsModifiedFees.foreach(claimHtlcSuccess => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, remoteCommit, remoteCommitPublished, dustLimit, claimHtlcSuccess.tx).isEmpty)) htlcTimeoutTxs.foreach(htlcTimeout => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, remoteCommit, remoteCommitPublished, dustLimit, htlcTimeout.tx).isEmpty)) claimHtlcTimeoutTxs.foreach(claimHtlcTimeout => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, localCommit, localCommitPublished, dustLimit, claimHtlcTimeout.tx).isEmpty)) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala index 5dfacbc476..c606c1e14d 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala @@ -21,12 +21,13 @@ import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, actorRefAdapter import akka.pattern.pipe import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.{BtcAmount, ByteVector32, MilliBtcDouble, OutPoint, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut} +import fr.acinq.eclair.TestConstants.TestFeeEstimator import fr.acinq.eclair.blockchain.CurrentBlockCount import fr.acinq.eclair.blockchain.bitcoind.BitcoindService import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.MempoolTx import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinCoreClient, BitcoinJsonRPCClient} -import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.publish.ReplaceableTxPublisher.{Publish, Stop} import fr.acinq.eclair.channel.publish.TxPublisher.TxRejectedReason._ @@ -70,6 +71,18 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w system.spawnAnonymous(ReplaceableTxPublisher(alice.underlyingActor.nodeParams, wallet, alice2blockchain.ref, TxPublishLogContext(UUID.randomUUID(), randomKey().publicKey, None))) } + /** Set uniform feerate for all block targets. */ + def setFeerate(feerate: FeeratePerKw): Unit = { + alice.underlyingActor.nodeParams.onChainFeeConf.feeEstimator.asInstanceOf[TestFeeEstimator].setFeerate(FeeratesPerKw.single(feerate)) + bob.underlyingActor.nodeParams.onChainFeeConf.feeEstimator.asInstanceOf[TestFeeEstimator].setFeerate(FeeratesPerKw.single(feerate)) + } + + /** Set feerate for a specific block target. */ + def setFeerate(feerate: FeeratePerKw, blockTarget: Int): Unit = { + alice.underlyingActor.nodeParams.onChainFeeConf.feeEstimator.asInstanceOf[TestFeeEstimator].setFeerate(blockTarget, feerate) + bob.underlyingActor.nodeParams.onChainFeeConf.feeEstimator.asInstanceOf[TestFeeEstimator].setFeerate(blockTarget, feerate) + } + def getMempool: Seq[Transaction] = { wallet.getMempool().pipeTo(probe.ref) probe.expectMsgType[Seq[Transaction]] @@ -151,8 +164,9 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w import f._ val commitFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate + setFeerate(commitFeerate) val (_, anchorTx) = closeChannelWithoutHtlcs(f) - publisher ! Publish(probe.ref, anchorTx, commitFeerate) + publisher ! Publish(probe.ref, anchorTx) val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) @@ -169,7 +183,8 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w probe.expectMsg(commitTx.tx.txid) generateBlocks(1) - publisher ! Publish(probe.ref, anchorTx, FeeratePerKw(10_000 sat)) + setFeerate(FeeratePerKw(10_000 sat)) + publisher ! Publish(probe.ref, anchorTx) val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) assert(result.reason === TxSkipped(retryNextBlock = false)) @@ -181,12 +196,13 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w import f._ val commitFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate + setFeerate(commitFeerate) val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) probe.expectMsg(commitTx.tx.txid) generateBlocks(1) - publisher ! Publish(probe.ref, anchorTx, commitFeerate) + publisher ! Publish(probe.ref, anchorTx) val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) assert(result.reason === TxSkipped(retryNextBlock = false)) @@ -203,7 +219,8 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w probe.expectMsg(remoteCommit.tx.txid) generateBlocks(1) - publisher ! Publish(probe.ref, anchorTx, FeeratePerKw(10_000 sat)) + setFeerate(FeeratePerKw(10_000 sat)) + publisher ! Publish(probe.ref, anchorTx) val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) assert(result.reason === TxSkipped(retryNextBlock = false)) @@ -219,7 +236,8 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w wallet.publishTransaction(remoteCommit.tx).pipeTo(probe.ref) probe.expectMsg(remoteCommit.tx.txid) - publisher ! Publish(probe.ref, anchorTx, FeeratePerKw(10_000 sat)) + setFeerate(FeeratePerKw(10_000 sat)) + publisher ! Publish(probe.ref, anchorTx) val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) // When the remote commit tx is still unconfirmed, we want to retry in case it is evicted from the mempool and our @@ -239,7 +257,9 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val lowFeerate = FeeratePerKw(500 sat) updateFee(lowFeerate, alice, bob, alice2bob, bob2alice) val (localCommit, anchorTx) = closeChannelWithoutHtlcs(f) - publisher ! Publish(probe.ref, anchorTx, FeeratePerKw(600 sat)) + // We set a slightly higher feerate to ensure the local anchor is used. + setFeerate(FeeratePerKw(600 sat)) + publisher ! Publish(probe.ref, anchorTx) val mempoolTxs = getMempoolTxs(2) assert(mempoolTxs.map(_.txid).contains(localCommit.tx.txid)) @@ -264,7 +284,8 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) probe.expectMsg(commitTx.tx.txid) - publisher ! Publish(probe.ref, anchorTx, FeeratePerKw(25_000 sat)) + setFeerate(FeeratePerKw(25_000 sat)) + publisher ! Publish(probe.ref, anchorTx) val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) // When the remote commit tx is still unconfirmed, we want to retry in case it is evicted from the mempool and our @@ -277,13 +298,17 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) + val (commitTx, anchorTx) = { + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) + (commitTx, anchorTx.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight + 1)) + } wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) probe.expectMsg(commitTx.tx.txid) assert(getMempool.length === 1) val targetFeerate = FeeratePerKw(3000 sat) - publisher ! Publish(probe.ref, anchorTx, targetFeerate) + setFeerate(targetFeerate, blockTarget = 1) + publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published val mempoolTxs = getMempoolTxs(2) assert(mempoolTxs.map(_.txid).contains(commitTx.tx.txid)) @@ -305,11 +330,15 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) + val (commitTx, anchorTx) = { + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) + (commitTx, anchorTx.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight + 2)) + } assert(getMempool.isEmpty) val targetFeerate = FeeratePerKw(3000 sat) - publisher ! Publish(probe.ref, anchorTx, targetFeerate) + setFeerate(targetFeerate, blockTarget = 2) + publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published val mempoolTxs = getMempoolTxs(2) assert(mempoolTxs.map(_.txid).contains(commitTx.tx.txid)) @@ -339,9 +368,15 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(utxos, ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ + // NB: when we get close to the deadline, we use aggressive block target: in this case we are 6 blocks away from + // the deadline, and we use a block target of 2 to ensure we confirm before the deadline. val targetFeerate = FeeratePerKw(10_000 sat) - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) - publisher ! Publish(probe.ref, anchorTx, targetFeerate) + setFeerate(targetFeerate, blockTarget = 2) + val (commitTx, anchorTx) = { + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) + (commitTx, anchorTx.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight + 6)) + } + publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published val mempoolTxs = getMempoolTxs(2) @@ -366,8 +401,9 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w import f._ val targetFeerate = FeeratePerKw(3000 sat) + setFeerate(targetFeerate) val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) - publisher ! Publish(probe.ref, anchorTx, targetFeerate) + publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published val mempoolTxs = getMempoolTxs(2) @@ -376,7 +412,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // we try to publish the anchor again (can be caused by a node restart): it will fail to replace the existing one // in the mempool but we must ensure we don't leave some utxos locked. val publisher2 = createPublisher() - publisher2 ! Publish(probe.ref, anchorTx, targetFeerate) + publisher2 ! Publish(probe.ref, anchorTx) val result = probe.expectMsgType[TxRejected] assert(result.reason === ConflictingTxUnconfirmed) getMempoolTxs(2) // the previous anchor tx and the commit tx are still in the mempool @@ -397,8 +433,9 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w import f._ val targetFeerate = FeeratePerKw(3000 sat) + setFeerate(targetFeerate) val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) - publisher ! Publish(probe.ref, anchorTx, targetFeerate) + publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published val mempoolTxs = getMempoolTxs(2) @@ -477,16 +514,16 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(5) // Verify that HTLC transactions immediately fail to publish. - val targetFeerate = FeeratePerKw(15_000 sat) + setFeerate(FeeratePerKw(15_000 sat)) val htlcSuccessPublisher = createPublisher() - htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess, targetFeerate) + htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess) val result1 = probe.expectMsgType[TxRejected] assert(result1.cmd === htlcSuccess) assert(result1.reason === ConflictingTxConfirmed) htlcSuccessPublisher ! Stop val htlcTimeoutPublisher = createPublisher() - htlcTimeoutPublisher ! Publish(probe.ref, htlcTimeout, targetFeerate) + htlcTimeoutPublisher ! Publish(probe.ref, htlcTimeout) val result2 = probe.expectMsgType[TxRejected] assert(result2.cmd === htlcTimeout) assert(result2.reason === ConflictingTxConfirmed) @@ -538,9 +575,13 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(Seq(10.5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ - val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f) + val (commitTx, htlcSuccess) = { + val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f) + (commitTx, htlcSuccess.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight)) + } val htlcSuccessPublisher = createPublisher() - htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess, FeeratePerKw(75_000 sat)) + setFeerate(FeeratePerKw(75_000 sat), blockTarget = 1) + htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess) val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) @@ -555,8 +596,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w import f._ // The HTLC-success tx will be immediately published since the commit tx is confirmed. + // NB: when we get close to the deadline (here, 10 blocks from it) we use an aggressive block target (in this case, 2) + setFeerate(targetFeerate, blockTarget = 2) + val htlcSuccessWithDeadline = htlcSuccess.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight + 10) val htlcSuccessPublisher = createPublisher() - htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess, targetFeerate) + htlcSuccessPublisher ! Publish(probe.ref, htlcSuccessWithDeadline) val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) val htlcSuccessTx = getMempoolTxs(1).head @@ -566,7 +610,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(4) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) val htlcSuccessResult = probe.expectMsgType[TxConfirmed] - assert(htlcSuccessResult.cmd === htlcSuccess) + assert(htlcSuccessResult.cmd === htlcSuccessWithDeadline) assert(htlcSuccessResult.tx.txIn.map(_.outPoint.txid).contains(commitTx.txid)) htlcSuccessPublisher ! Stop htlcSuccessResult.tx @@ -575,12 +619,18 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w private def testPublishHtlcTimeout(f: Fixture, commitTx: Transaction, htlcTimeout: PublishReplaceableTx, targetFeerate: FeeratePerKw): Transaction = { import f._ + // We start with a low feerate, that will then rise during the CLTV period. + // The publisher should use the feerate available when the transaction can be published (after the timeout). + setFeerate(targetFeerate / 2) + // The HTLC-timeout will be published after the timeout. val htlcTimeoutPublisher = createPublisher() - htlcTimeoutPublisher ! Publish(probe.ref, htlcTimeout, targetFeerate) + val htlcTimeoutWithDeadline = htlcTimeout.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight) + htlcTimeoutPublisher ! Publish(probe.ref, htlcTimeoutWithDeadline) alice2blockchain.expectNoMessage(100 millis) generateBlocks(144) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) + setFeerate(targetFeerate, blockTarget = 1) // the feerate is higher than what it was when the channel force-closed val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) val htlcTimeoutTx = getMempoolTxs(1).head @@ -590,7 +640,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(4) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) val htlcTimeoutResult = probe.expectMsgType[TxConfirmed] - assert(htlcTimeoutResult.cmd === htlcTimeout) + assert(htlcTimeoutResult.cmd === htlcTimeoutWithDeadline) assert(htlcTimeoutResult.tx.txIn.map(_.outPoint.txid).contains(commitTx.txid)) htlcTimeoutPublisher ! Stop htlcTimeoutResult.tx @@ -666,9 +716,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w import f._ val targetFeerate = FeeratePerKw(5_000 sat) + setFeerate(targetFeerate) val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f) val publisher1 = createPublisher() - publisher1 ! Publish(probe.ref, htlcSuccess, targetFeerate) + publisher1 ! Publish(probe.ref, htlcSuccess) val w1 = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w1.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) getMempoolTxs(1) @@ -676,7 +727,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // we try to publish the htlc-success again (can be caused by a node restart): it will fail to replace the existing // one in the mempool but we must ensure we don't leave some utxos locked. val publisher2 = createPublisher() - publisher2 ! Publish(probe.ref, htlcSuccess, targetFeerate) + publisher2 ! Publish(probe.ref, htlcSuccess) val w2 = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w2.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) val result = probe.expectMsgType[TxRejected] @@ -699,9 +750,9 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ - val targetFeerate = FeeratePerKw(5_000 sat) + setFeerate(FeeratePerKw(5_000 sat)) val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f) - publisher ! Publish(probe.ref, htlcSuccess, targetFeerate) + publisher ! Publish(probe.ref, htlcSuccess) val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) getMempoolTxs(1) @@ -785,16 +836,16 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(5) // Verify that Claim-HTLC transactions immediately fail to publish. - val targetFeerate = FeeratePerKw(5_000 sat) + setFeerate(FeeratePerKw(5_000 sat)) val claimHtlcSuccessPublisher = createPublisher() - claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess, targetFeerate) + claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess) val result1 = probe.expectMsgType[TxRejected] assert(result1.cmd === claimHtlcSuccess) assert(result1.reason === ConflictingTxConfirmed) claimHtlcSuccessPublisher ! Stop val claimHtlcTimeoutPublisher = createPublisher() - claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout, targetFeerate) + claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout) val result2 = probe.expectMsgType[TxRejected] assert(result2.cmd === claimHtlcTimeout) assert(result2.reason === ConflictingTxConfirmed) @@ -846,8 +897,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w import f._ // The Claim-HTLC-success tx will be immediately published since the commit tx is confirmed. + setFeerate(targetFeerate, blockTarget = 2) val claimHtlcSuccessPublisher = createPublisher() - claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess, targetFeerate) + val claimHtlcSuccessWithDeadline = claimHtlcSuccess.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight + 4) + claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccessWithDeadline) val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, remoteCommitTx) val claimHtlcSuccessTx = getMempoolTxs(1).head @@ -857,7 +910,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(4) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) val claimHtlcSuccessResult = probe.expectMsgType[TxConfirmed] - assert(claimHtlcSuccessResult.cmd === claimHtlcSuccess) + assert(claimHtlcSuccessResult.cmd === claimHtlcSuccessWithDeadline) assert(claimHtlcSuccessResult.tx.txIn.map(_.outPoint.txid).contains(remoteCommitTx.txid)) claimHtlcSuccessPublisher ! Stop claimHtlcSuccessResult.tx @@ -866,12 +919,18 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w private def testPublishClaimHtlcTimeout(f: Fixture, remoteCommitTx: Transaction, claimHtlcTimeout: PublishReplaceableTx, targetFeerate: FeeratePerKw): Transaction = { import f._ + // We start with a low feerate, that will then rise during the CLTV period. + // The publisher should use the feerate available when the transaction can be published (after the timeout). + setFeerate(targetFeerate / 2) + // The Claim-HTLC-timeout will be published after the timeout. val claimHtlcTimeoutPublisher = createPublisher() - claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout, targetFeerate) + val claimHtlcTimeoutWithDeadline = claimHtlcTimeout.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight) + claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeoutWithDeadline) alice2blockchain.expectNoMessage(100 millis) generateBlocks(144) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) + setFeerate(targetFeerate, blockTarget = 1) // the feerate is higher than what it was when the channel force-closed val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, remoteCommitTx) val claimHtlcTimeoutTx = getMempoolTxs(1).head @@ -881,7 +940,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(4) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) val claimHtlcTimeoutResult = probe.expectMsgType[TxConfirmed] - assert(claimHtlcTimeoutResult.cmd === claimHtlcTimeout) + assert(claimHtlcTimeoutResult.cmd === claimHtlcTimeoutWithDeadline) assert(claimHtlcTimeoutResult.tx.txIn.map(_.outPoint.txid).contains(remoteCommitTx.txid)) claimHtlcTimeoutPublisher ! Stop claimHtlcTimeoutResult.tx @@ -925,8 +984,9 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f) // The Claim-HTLC-success tx will be immediately published. + setFeerate(targetFeerate) val claimHtlcSuccessPublisher = createPublisher() - claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess, targetFeerate) + claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess) val claimHtlcSuccessTx = getMempoolTxs(1).head val claimHtlcSuccessTargetFee = Transactions.weight2fee(targetFeerate, claimHtlcSuccessTx.weight.toInt) assert(claimHtlcSuccessTargetFee * 0.9 <= claimHtlcSuccessTx.fees && claimHtlcSuccessTx.fees <= claimHtlcSuccessTargetFee * 1.1, s"actualFee=${claimHtlcSuccessTx.fees} targetFee=$claimHtlcSuccessTargetFee") @@ -939,7 +999,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // The Claim-HTLC-timeout will be published after the timeout. val claimHtlcTimeoutPublisher = createPublisher() - claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout, targetFeerate) + claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout) alice2blockchain.expectNoMessage(100 millis) generateBlocks(144) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) @@ -960,11 +1020,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputs, f => { import f._ - val targetFeerate = FeeratePerKw(50_000 sat) val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f) + setFeerate(FeeratePerKw(50_000 sat)) val claimHtlcSuccessPublisher = createPublisher() - claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess, targetFeerate) + claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess) val w1 = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w1.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, remoteCommitTx) val result1 = probe.expectMsgType[TxRejected] @@ -973,7 +1033,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w claimHtlcSuccessPublisher ! Stop val claimHtlcTimeoutPublisher = createPublisher() - claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout, targetFeerate) + claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout) generateBlocks(144) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) val w2 = alice2blockchain.expectMsgType[WatchParentTxConfirmed] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala index c76c39f5ca..504758a0b2 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala @@ -21,9 +21,7 @@ import akka.actor.typed.scaladsl.ActorContext import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, TypedActorRefOps, actorRefAdapter} import akka.testkit.TestProbe import fr.acinq.bitcoin.{OutPoint, SatoshiLong, Transaction, TxIn, TxOut} -import fr.acinq.eclair.TestConstants.TestFeeEstimator import fr.acinq.eclair.blockchain.CurrentBlockCount -import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.channel.publish import fr.acinq.eclair.channel.publish.TxPublisher.TxRejectedReason._ import fr.acinq.eclair.channel.publish.TxPublisher._ @@ -37,11 +35,7 @@ import scala.concurrent.duration.DurationInt class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { - case class FixtureParam(nodeParams: NodeParams, txPublisher: ActorRef[TxPublisher.Command], factory: TestProbe, probe: TestProbe) { - def setFeerate(feerate: FeeratePerKw): Unit = { - nodeParams.onChainFeeConf.feeEstimator.asInstanceOf[TestFeeEstimator].setFeerate(FeeratesPerKw.single(feerate)) - } - } + case class FixtureParam(nodeParams: NodeParams, txPublisher: ActorRef[TxPublisher.Command], factory: TestProbe, probe: TestProbe) override def withFixture(test: OneArgTest): Outcome = { within(max = 30 seconds) { @@ -107,40 +101,39 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { test("publish replaceable tx") { f => import f._ - f.setFeerate(FeeratePerKw(750 sat)) + val deadline = nodeParams.currentBlockHeight + 12 val input = OutPoint(randomBytes32(), 3) - val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null, 0) + val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null, deadline) txPublisher ! cmd val child = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor val p = child.expectMsgType[ReplaceableTxPublisher.Publish] assert(p.cmd === cmd) - assert(p.targetFeerate === FeeratePerKw(750 sat)) } test("publish replaceable tx duplicate") { f => import f._ - f.setFeerate(FeeratePerKw(750 sat)) + val deadline = nodeParams.currentBlockHeight + 12 val input = OutPoint(randomBytes32(), 3) - val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null, 0) + val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null, deadline) txPublisher ! cmd val child1 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor val p1 = child1.expectMsgType[ReplaceableTxPublisher.Publish] assert(p1.cmd === cmd) - assert(p1.targetFeerate === FeeratePerKw(750 sat)) - // We ignore duplicates that use a lower feerate: - f.setFeerate(FeeratePerKw(700 sat)) + // We ignore duplicates that don't use a more aggressive deadline: txPublisher ! cmd factory.expectNoMessage(100 millis) + val cmdHigherDeadline = cmd.copy(deadline = deadline + 1) + txPublisher ! cmdHigherDeadline + factory.expectNoMessage(100 millis) - // But we retry publishing if the feerate is greater than previous attempts: - f.setFeerate(FeeratePerKw(1000 sat)) - txPublisher ! cmd + // But we retry publishing if the deadline is more aggressive than previous attempts: + val cmdLowerDeadline = cmd.copy(deadline = deadline - 6) + txPublisher ! cmdLowerDeadline val child2 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor val p2 = child2.expectMsgType[ReplaceableTxPublisher.Publish] - assert(p2.cmd === cmd) - assert(p2.targetFeerate === FeeratePerKw(1000 sat)) + assert(p2.cmd === cmdLowerDeadline) } test("stop publishing attempts when transaction confirms") { f => @@ -159,7 +152,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val attempt2 = factory.expectMsgType[FinalTxPublisherSpawned].actor attempt2.expectMsgType[FinalTxPublisher.Publish] - val cmd3 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null, 0) + val cmd3 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null, nodeParams.currentBlockHeight) txPublisher ! cmd3 val attempt3 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor attempt3.expectMsgType[ReplaceableTxPublisher.Publish] @@ -181,7 +174,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val attempt1 = factory.expectMsgType[FinalTxPublisherSpawned] attempt1.actor.expectMsgType[FinalTxPublisher.Publish] - val cmd2 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null, 0) + val cmd2 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null, nodeParams.currentBlockHeight) txPublisher ! cmd2 val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish] @@ -198,16 +191,16 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { test("publishing attempt fails (not enough funds)") { f => import f._ - f.setFeerate(FeeratePerKw(600 sat)) + val deadline1 = nodeParams.currentBlockHeight + 12 val input = OutPoint(randomBytes32(), 7) val paymentHash = randomBytes32() - val cmd1 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, 0) + val cmd1 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, deadline1) txPublisher ! cmd1 val attempt1 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt1.actor.expectMsgType[ReplaceableTxPublisher.Publish] - f.setFeerate(FeeratePerKw(750 sat)) - val cmd2 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, 0) + val deadline2 = nodeParams.currentBlockHeight + 6 + val cmd2 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, deadline2) txPublisher ! cmd2 val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish] From 1e4ecc06e734fd76e27c178cbc1564b96b788bd7 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 17 Dec 2021 16:33:36 +0100 Subject: [PATCH 03/23] Retry conflicting replaceable transactions We should retry replaceable transactions whenever we find a conflict in the mempool: as we get closer to the deadline, we will try higher fees. This ensures we either succeed in replacing the mempool transaction, or it gets confirmed before the deadline, which is good as well. In particular, this handles the case where we restart our node. The mempool conflict can be a transaction that we submitted ourselves, but we've lost the context in the restart. We want to keep increasing the fees of that transaction as we get closer to the deadline, so we need this retry mechanism. --- .../eclair/channel/publish/TxPublisher.scala | 14 +++++++++--- .../channel/publish/TxPublisherSpec.scala | 22 ++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala index e4134ecfa7..0eb362e1c7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala @@ -234,9 +234,17 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact val retryNextBlock2 = if (retry) retryNextBlock ++ rejectedAttempts.map(_.cmd) else retryNextBlock run(pending2, retryNextBlock2, channelInfo) case TxRejectedReason.ConflictingTxUnconfirmed => - // Our transaction was replaced by a transaction that pays more fees, so it doesn't make sense to retry now. - // We will automatically retry with a higher fee if we get close to the deadline. - run(pending2, retryNextBlock, channelInfo) + cmd match { + case _: PublishFinalTx => + // Our transaction is not replaceable, and the mempool contains a transaction that pays more fees, so + // it doesn't make sense to retry, we will keep getting rejected. + run(pending2, retryNextBlock, channelInfo) + case _: PublishReplaceableTx => + // The mempool contains a transaction that pays more fees, but as we get closer to the deadline, we will + // try to publish with higher fees, so if the conflicting transaction doesn't confirm, we should be able + // to replace it before we reach the deadline. + run(pending2, retryNextBlock ++ rejectedAttempts.map(_.cmd), channelInfo) + } case TxRejectedReason.ConflictingTxConfirmed => // Our transaction was double-spent by a competing transaction that has been confirmed, so it doesn't make // sense to retry. diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala index 504758a0b2..8c45900db1 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala @@ -243,7 +243,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { factory.expectNoMessage(100 millis) } - test("publishing attempt fails (unconfirmed conflicting transaction)") { f => + test("publishing attempt fails (unconfirmed conflicting raw transaction)") { f => import f._ val tx = Transaction(2, TxIn(OutPoint(randomBytes32(), 1), Nil, 0) :: Nil, Nil, 0) @@ -260,6 +260,26 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { factory.expectNoMessage(100 millis) } + test("publishing attempt fails (unconfirmed conflicting replaceable transaction)") { f => + import f._ + + val input = OutPoint(randomBytes32(), 7) + val paymentHash = randomBytes32() + val cmd = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, nodeParams.currentBlockHeight) + txPublisher ! cmd + val attempt1 = factory.expectMsgType[ReplaceableTxPublisherSpawned] + attempt1.actor.expectMsgType[ReplaceableTxPublisher.Publish] + + txPublisher ! TxRejected(attempt1.id, cmd, ConflictingTxUnconfirmed) + attempt1.actor.expectMsg(ReplaceableTxPublisher.Stop) + factory.expectNoMessage(100 millis) + + // We retry when a new block is found: + system.eventStream.publish(CurrentBlockCount(nodeParams.currentBlockHeight + 1)) + val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] + assert(attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish].cmd === cmd) + } + test("publishing attempt fails (confirmed conflicting transaction)") { f => import f._ From cc65aeef407ffe046d6064a8b01614d7f35a5545 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 21 Dec 2021 14:04:51 +0100 Subject: [PATCH 04/23] Refactor replaceable tx publication This commit contains very minimal logic changes: it mostly moves code from the ReplaceableTxPublisher to two new actors (the first one to check preconditions, the second one to encapsulate the logic for funding and signing replaceable transactions). This makes it easier to understand the state machine involved in each of these steps, and will make it easier to introduce fee-bumping by reusing these actors with minimal changes. --- .../channel/publish/ReplaceableTxFunder.scala | 392 ++++++++++++++ .../publish/ReplaceableTxPrePublisher.scala | 264 ++++++++++ .../publish/ReplaceableTxPublisher.scala | 481 ++---------------- .../publish/ReplaceableTxPublisherSpec.scala | 43 +- 4 files changed, 725 insertions(+), 455 deletions(-) create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala new file mode 100644 index 0000000000..e070d84edd --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala @@ -0,0 +1,392 @@ +/* + * Copyright 2021 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.channel.publish + +import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler} +import akka.actor.typed.{ActorRef, Behavior} +import com.softwaremill.quicklens.ModifyPimp +import fr.acinq.bitcoin.{ByteVector32, OutPoint, Satoshi, Script, Transaction, TxOut} +import fr.acinq.eclair.NodeParams +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.FundTransactionOptions +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.Commitments +import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher._ +import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishLogContext +import fr.acinq.eclair.transactions.Transactions._ + +import scala.concurrent.duration.DurationInt +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success} + +/** + * Created by t-bast on 20/12/2021. + */ + +/** + * This actor funds a replaceable transaction to reach the requested feerate, signs it, and returns the resulting + * transaction to the caller. Whenever possible, we avoid adding new inputs. + * This actor does not publish the resulting transaction. + */ +object ReplaceableTxFunder { + + // @formatter:off + sealed trait Command + case class FundTransaction(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, txWithWitnessData: ReplaceableTxWithWitnessData, targetFeerate: FeeratePerKw) extends Command + case object Stop extends Command + + private case class AddInputsOk(tx: ReplaceableTxWithWitnessData, fee: Satoshi) extends Command + private case class AddInputsFailed(reason: Throwable) extends Command + private case class SignWalletInputsOk(signedTx: Transaction) extends Command + private case class SignWalletInputsFailed(reason: Throwable) extends Command + private case object UtxosUnlocked extends Command + // @formatter:on + + // @formatter:off + sealed trait FundingResult + case class FundingFailed(reason: TxPublisher.TxRejectedReason) extends FundingResult + case class TransactionReady(tx: Transaction, amountIn: Satoshi) extends FundingResult { + val fee: Satoshi = amountIn - tx.txOut.map(_.amount).sum + } + // @formatter:on + + def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, loggingInfo: TxPublishLogContext): Behavior[Command] = { + Behaviors.setup { context => + Behaviors.withTimers { timers => + Behaviors.withMdc(loggingInfo.mdc()) { + new ReplaceableTxFunder(nodeParams, bitcoinClient, context, timers).start() + } + } + } + } + + /** + * Adjust the amount of the change output of an anchor tx to match our target feerate. + * We need this because fundrawtransaction doesn't allow us to leave non-wallet inputs, so we have to add them + * afterwards which may bring the resulting feerate below our target. + */ + def adjustAnchorOutputChange(unsignedTx: ClaimLocalAnchorWithWitnessData, commitTx: Transaction, amountIn: Satoshi, commitFeerate: FeeratePerKw, targetFeerate: FeeratePerKw, dustLimit: Satoshi): (ClaimLocalAnchorWithWitnessData, Satoshi) = { + require(unsignedTx.txInfo.tx.txOut.size == 1, "funded transaction should have a single change output") + // We take into account witness weight and adjust the fee to match our desired feerate. + val dummySignedClaimAnchorTx = addSigs(unsignedTx.txInfo, PlaceHolderSig) + // NB: we assume that our bitcoind wallet uses only P2WPKH inputs when funding txs. + val estimatedWeight = commitTx.weight() + dummySignedClaimAnchorTx.tx.weight() + claimP2WPKHOutputWitnessWeight * (dummySignedClaimAnchorTx.tx.txIn.size - 1) + val targetFee = weight2fee(targetFeerate, estimatedWeight) - weight2fee(commitFeerate, commitTx.weight()) + val amountOut = dustLimit.max(amountIn - targetFee) + val updatedAnchorTx = unsignedTx.modify(_.txInfo.tx.txOut).setTo(Seq(unsignedTx.txInfo.tx.txOut.head.copy(amount = amountOut))) + val fee = amountIn - updatedAnchorTx.txInfo.tx.txOut.map(_.amount).sum + (updatedAnchorTx, fee) + } + + /** + * Adjust the change output of an htlc tx to match our target feerate. + * We need this because fundrawtransaction doesn't allow us to leave non-wallet inputs, so we have to add them + * afterwards which may bring the resulting feerate below our target. + */ + def adjustHtlcTxChange(unsignedTx: HtlcWithWitnessData, amountIn: Satoshi, targetFeerate: FeeratePerKw, commitments: Commitments): (HtlcWithWitnessData, Satoshi) = { + require(unsignedTx.txInfo.tx.txOut.size <= 2, "funded transaction should have at most one change output") + val dummySignedTx = unsignedTx.txInfo match { + case tx: HtlcSuccessTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, ByteVector32.Zeroes, commitments.commitmentFormat) + case tx: HtlcTimeoutTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, commitments.commitmentFormat) + } + // We adjust the change output to obtain the targeted feerate. + val estimatedWeight = dummySignedTx.tx.weight() + claimP2WPKHOutputWitnessWeight * (dummySignedTx.tx.txIn.size - 1) + val targetFee = weight2fee(targetFeerate, estimatedWeight) + val changeAmount = amountIn - dummySignedTx.tx.txOut.head.amount - targetFee + val updatedHtlcTx = if (dummySignedTx.tx.txOut.length == 2 && changeAmount >= commitments.localParams.dustLimit) { + unsignedTx match { + case htlcSuccess: HtlcSuccessWithWitnessData => htlcSuccess.modify(_.txInfo.tx.txOut).setTo(Seq(htlcSuccess.txInfo.tx.txOut.head, htlcSuccess.txInfo.tx.txOut(1).copy(amount = changeAmount))) + case htlcTimeout: HtlcTimeoutWithWitnessData => htlcTimeout.modify(_.txInfo.tx.txOut).setTo(Seq(htlcTimeout.txInfo.tx.txOut.head, htlcTimeout.txInfo.tx.txOut(1).copy(amount = changeAmount))) + } + } else { + unsignedTx match { + case htlcSuccess: HtlcSuccessWithWitnessData => htlcSuccess.modify(_.txInfo.tx.txOut).setTo(Seq(htlcSuccess.txInfo.tx.txOut.head)) + case htlcTimeout: HtlcTimeoutWithWitnessData => htlcTimeout.modify(_.txInfo.tx.txOut).setTo(Seq(htlcTimeout.txInfo.tx.txOut.head)) + } + } + val fee = amountIn - updatedHtlcTx.txInfo.tx.txOut.map(_.amount).sum + (updatedHtlcTx, fee) + } + + /** + * Adjust the main output of a claim-htlc tx to match our target feerate. + * If the resulting output is too small, we skip the transaction. + */ + def adjustClaimHtlcTxOutput(claimHtlcTx: ClaimHtlcWithWitnessData, targetFeerate: FeeratePerKw, dustLimit: Satoshi): Either[TxGenerationSkipped, ClaimHtlcWithWitnessData] = { + require(claimHtlcTx.txInfo.tx.txIn.size == 1, "claim-htlc transaction should have a single input") + require(claimHtlcTx.txInfo.tx.txOut.size == 1, "claim-htlc transaction should have a single output") + val dummySignedTx = claimHtlcTx.txInfo match { + case tx: ClaimHtlcSuccessTx => addSigs(tx, PlaceHolderSig, ByteVector32.Zeroes) + case tx: ClaimHtlcTimeoutTx => addSigs(tx, PlaceHolderSig) + case tx: LegacyClaimHtlcSuccessTx => tx + } + val targetFee = weight2fee(targetFeerate, dummySignedTx.tx.weight()) + val outputAmount = claimHtlcTx.txInfo.input.txOut.amount - targetFee + if (outputAmount < dustLimit) { + Left(AmountBelowDustLimit) + } else { + val updatedClaimHtlcTx = claimHtlcTx match { + case claimHtlcSuccess: ClaimHtlcSuccessWithWitnessData => claimHtlcSuccess.modify(_.txInfo.tx.txOut).setTo(Seq(claimHtlcSuccess.txInfo.tx.txOut.head.copy(amount = outputAmount))) + case claimHtlcTimeout: ClaimHtlcTimeoutWithWitnessData => claimHtlcTimeout.modify(_.txInfo.tx.txOut).setTo(Seq(claimHtlcTimeout.txInfo.tx.txOut.head.copy(amount = outputAmount))) + // NB: we don't modify legacy claim-htlc-success, it's already signed. + case legacyClaimHtlcSuccess: LegacyClaimHtlcSuccessWithWitnessData => legacyClaimHtlcSuccess + } + Right(updatedClaimHtlcTx) + } + } + +} + +private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, context: ActorContext[ReplaceableTxFunder.Command], timers: TimerScheduler[ReplaceableTxFunder.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { + + import ReplaceableTxFunder._ + import nodeParams.{channelKeyManager => keyManager} + + private val log = context.log + + def start(): Behavior[Command] = { + Behaviors.receiveMessagePartial { + case FundTransaction(replyTo, cmd, txWithWitnessData, targetFeerate) => fund(replyTo, cmd, txWithWitnessData, targetFeerate) + case Stop => Behaviors.stopped + } + } + + def fund(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, txWithWitnessData: ReplaceableTxWithWitnessData, targetFeerate: FeeratePerKw): Behavior[Command] = { + val commitFeerate = cmd.commitments.localCommit.spec.commitTxFeerate + txWithWitnessData match { + case claimLocalAnchor: ReplaceableTxPrePublisher.ClaimLocalAnchorWithWitnessData => + if (targetFeerate <= commitFeerate) { + log.info("skipping {}: commit feerate is high enough (feerate={})", cmd.desc, commitFeerate) + // We set retry = true in case the on-chain feerate rises before the commit tx is confirmed: if that happens + // we'll want to claim our anchor to raise the feerate of the commit tx and get it confirmed faster. + replyTo ! FundingFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) + Behaviors.stopped + } else { + addWalletInputs(replyTo, cmd, claimLocalAnchor, targetFeerate) + } + case htlcTx: ReplaceableTxPrePublisher.HtlcWithWitnessData => + if (targetFeerate <= commitFeerate) { + log.info("publishing {} without adding inputs: txid={}", cmd.desc, htlcTx.txInfo.tx.txid) + sign(replyTo, cmd, txWithWitnessData, htlcTx.txInfo.fee) + } else { + addWalletInputs(replyTo, cmd, htlcTx, targetFeerate) + } + case claimHtlcTx: ReplaceableTxPrePublisher.ClaimHtlcWithWitnessData => + adjustClaimHtlcTxOutput(claimHtlcTx, targetFeerate, cmd.commitments.localParams.dustLimit) match { + case Left(reason) => + // The htlc isn't economical to claim at the current feerate, but if the feerate goes down, we may want to claim it later. + log.warn("skipping {}: {} (feerate={})", cmd.desc, reason, targetFeerate) + replyTo ! FundingFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) + Behaviors.stopped + case Right(updatedClaimHtlcTx) => + sign(replyTo, cmd, updatedClaimHtlcTx, updatedClaimHtlcTx.txInfo.fee) + } + } + } + + def addWalletInputs(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, txWithWitnessData: ReplaceableTxWithWalletInputs, targetFeerate: FeeratePerKw): Behavior[Command] = { + context.pipeToSelf(addInputs(txWithWitnessData, targetFeerate, cmd.commitments)) { + case Success((fundedTx, fee)) => AddInputsOk(fundedTx, fee) + case Failure(reason) => AddInputsFailed(reason) + } + Behaviors.receiveMessagePartial { + case AddInputsOk(fundedTx, fee) => + log.info("added {} wallet input(s) and {} wallet output(s) to {}", fundedTx.txInfo.tx.txIn.length - 1, fundedTx.txInfo.tx.txOut.length - 1, cmd.desc) + sign(replyTo, cmd, fundedTx, fee) + case AddInputsFailed(reason) => + if (reason.getMessage.contains("Insufficient funds")) { + log.warn("cannot add inputs to {}: {}", cmd.desc, reason.getMessage) + } else { + log.error("cannot add inputs to {}: {}", cmd.desc, reason) + } + replyTo ! FundingFailed(TxPublisher.TxRejectedReason.CouldNotFund) + Behaviors.stopped + case Stop => + // We've asked bitcoind to lock utxos, so we can't stop right now without unlocking them. + // Since we don't know yet what utxos have been locked, we defer the message. + timers.startSingleTimer(Stop, 1 second) + Behaviors.same + } + } + + def sign(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, fundedTx: ReplaceableTxWithWitnessData, fee: Satoshi): Behavior[Command] = { + val channelKeyPath = keyManager.keyPath(cmd.commitments.localParams, cmd.commitments.channelConfig) + fundedTx match { + case ClaimLocalAnchorWithWitnessData(anchorTx) => + val localSig = keyManager.sign(anchorTx, keyManager.fundingPublicKey(cmd.commitments.localParams.fundingKeyPath), TxOwner.Local, cmd.commitments.commitmentFormat) + val signedTx = ClaimLocalAnchorWithWitnessData(addSigs(anchorTx, localSig)) + signWalletInputs(replyTo, cmd, signedTx, fee) + case htlcTx: HtlcWithWitnessData => + val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, cmd.commitments.localCommit.index) + val localHtlcBasepoint = keyManager.htlcPoint(channelKeyPath) + val localSig = keyManager.sign(htlcTx.txInfo, localHtlcBasepoint, localPerCommitmentPoint, TxOwner.Local, cmd.commitments.commitmentFormat) + val signedTx = htlcTx match { + case htlcSuccess: HtlcSuccessWithWitnessData => htlcSuccess.copy(txInfo = addSigs(htlcSuccess.txInfo, localSig, htlcSuccess.remoteSig, htlcSuccess.preimage, cmd.commitments.commitmentFormat)) + case htlcTimeout: HtlcTimeoutWithWitnessData => htlcTimeout.copy(txInfo = addSigs(htlcTimeout.txInfo, localSig, htlcTimeout.remoteSig, cmd.commitments.commitmentFormat)) + } + val hasWalletInputs = htlcTx.txInfo.tx.txIn.size > 1 + if (hasWalletInputs) { + signWalletInputs(replyTo, cmd, signedTx, fee) + } else { + replyTo ! TransactionReady(signedTx.txInfo.tx, signedTx.txInfo.input.txOut.amount) + Behaviors.stopped + } + case claimHtlcTx: ClaimHtlcWithWitnessData => + val sig = keyManager.sign(claimHtlcTx.txInfo, keyManager.htlcPoint(channelKeyPath), cmd.commitments.remoteCommit.remotePerCommitmentPoint, TxOwner.Local, cmd.commitments.commitmentFormat) + val signedTx = claimHtlcTx match { + case ClaimHtlcSuccessWithWitnessData(txInfo, preimage) => addSigs(txInfo, sig, preimage) + case LegacyClaimHtlcSuccessWithWitnessData(txInfo, _) => txInfo + case ClaimHtlcTimeoutWithWitnessData(txInfo) => addSigs(txInfo, sig) + } + replyTo ! TransactionReady(signedTx.tx, signedTx.input.txOut.amount) + Behaviors.stopped + } + } + + def signWalletInputs(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, locallySignedTx: ReplaceableTxWithWalletInputs, fee: Satoshi): Behavior[Command] = { + locallySignedTx match { + case ClaimLocalAnchorWithWitnessData(anchorTx) => + val commitInfo = BitcoinCoreClient.PreviousTx(anchorTx.input, anchorTx.tx.txIn.head.witness) + context.pipeToSelf(bitcoinClient.signTransaction(anchorTx.tx, Seq(commitInfo))) { + case Success(signedTx) => SignWalletInputsOk(signedTx.tx) + case Failure(reason) => SignWalletInputsFailed(reason) + } + case htlcTx: HtlcWithWitnessData => + val inputInfo = BitcoinCoreClient.PreviousTx(htlcTx.txInfo.input, htlcTx.txInfo.tx.txIn.head.witness) + context.pipeToSelf(bitcoinClient.signTransaction(htlcTx.txInfo.tx, Seq(inputInfo), allowIncomplete = true).map(signTxResponse => { + // NB: bitcoind versions older than 0.21.1 messes up the witness stack for our htlc input, so we need to restore it. + // See https://github.com/bitcoin/bitcoin/issues/21151 + htlcTx.txInfo.tx.copy(txIn = htlcTx.txInfo.tx.txIn.head +: signTxResponse.tx.txIn.tail) + })) { + case Success(signedTx) => SignWalletInputsOk(signedTx) + case Failure(reason) => SignWalletInputsFailed(reason) + } + } + Behaviors.receiveMessagePartial { + case SignWalletInputsOk(signedTx) => + val amountIn = fee + signedTx.txOut.map(_.amount).sum + replyTo ! TransactionReady(signedTx, amountIn) + Behaviors.stopped + case SignWalletInputsFailed(reason) => + log.error("cannot sign {}: {}", cmd.desc, reason) + // We reply with the failure only once the utxos are unlocked, otherwise there is a risk that our parent stops + // itself, which will automatically stop us before we had a chance to unlock them. + unlockAndStop(replyTo, locallySignedTx.txInfo.input.outPoint, locallySignedTx.txInfo.tx, Some(TxPublisher.TxRejectedReason.UnknownTxFailure)) + case Stop => + // We have added wallet inputs, we need to unlock them before stopping. + unlockAndStop(replyTo, locallySignedTx.txInfo.input.outPoint, locallySignedTx.txInfo.tx, None) + } + } + + def unlockAndStop(replyTo: ActorRef[FundingResult], input: OutPoint, tx: Transaction, failure_opt: Option[TxPublisher.TxRejectedReason]): Behavior[Command] = { + val toUnlock = tx.txIn.filterNot(_.outPoint == input).map(_.outPoint) + log.debug("unlocking utxos={}", toUnlock.mkString(", ")) + context.pipeToSelf(bitcoinClient.unlockOutpoints(toUnlock))(_ => UtxosUnlocked) + Behaviors.receiveMessagePartial { + case UtxosUnlocked => + log.debug("utxos unlocked") + failure_opt.foreach(failure => replyTo ! FundingFailed(failure)) + Behaviors.stopped + case Stop => + log.debug("waiting for utxos to be unlocked before stopping") + Behaviors.same + } + } + + private def addInputs(tx: ReplaceableTxWithWalletInputs, targetFeerate: FeeratePerKw, commitments: Commitments): Future[(ReplaceableTxWithWalletInputs, Satoshi)] = { + tx match { + case anchorTx: ClaimLocalAnchorWithWitnessData => addInputs(anchorTx, targetFeerate, commitments) + case htlcTx: HtlcWithWitnessData => addInputs(htlcTx, targetFeerate, commitments) + } + } + + private def addInputs(anchorTx: ClaimLocalAnchorWithWitnessData, targetFeerate: FeeratePerKw, commitments: Commitments): Future[(ClaimLocalAnchorWithWitnessData, Satoshi)] = { + val dustLimit = commitments.localParams.dustLimit + val commitFeerate = commitments.localCommit.spec.commitTxFeerate + val commitTx = commitments.fullySignedLocalCommitTx(nodeParams.channelKeyManager).tx + // We want the feerate of the package (commit tx + tx spending anchor) to equal targetFeerate. + // Thus we have: anchorFeerate = targetFeerate + (weight-commit-tx / weight-anchor-tx) * (targetFeerate - commitTxFeerate) + // If we use the smallest weight possible for the anchor tx, the feerate we use will thus be greater than what we want, + // and we can adjust it afterwards by raising the change output amount. + val anchorFeerate = targetFeerate + FeeratePerKw(targetFeerate.feerate - commitFeerate.feerate) * commitTx.weight() / claimAnchorOutputMinWeight + // NB: fundrawtransaction requires at least one output, and may add at most one additional change output. + // Since the purpose of this transaction is just to do a CPFP, the resulting tx should have a single change output + // (note that bitcoind doesn't let us publish a transaction with no outputs). + // To work around these limitations, we start with a dummy output and later merge that dummy output with the optional + // change output added by bitcoind. + // NB: fundrawtransaction doesn't support non-wallet inputs, so we have to remove our anchor input and re-add it later. + // That means bitcoind will not take our anchor input's weight into account when adding inputs to set the fee. + // That's ok, we can increase the fee later by decreasing the output amount. But we need to ensure we'll have enough + // to cover the weight of our anchor input, which is why we set it to the following value. + val dummyChangeAmount = weight2fee(anchorFeerate, claimAnchorOutputMinWeight) + dustLimit + val txNotFunded = Transaction(2, Nil, TxOut(dummyChangeAmount, Script.pay2wpkh(PlaceHolderPubKey)) :: Nil, 0) + bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(anchorFeerate, lockUtxos = true)).flatMap(fundTxResponse => { + // We merge the outputs if there's more than one. + fundTxResponse.changePosition match { + case Some(changePos) => + val changeOutput = fundTxResponse.tx.txOut(changePos) + val txSingleOutput = fundTxResponse.tx.copy(txOut = Seq(changeOutput.copy(amount = changeOutput.amount + dummyChangeAmount))) + Future.successful(fundTxResponse.copy(tx = txSingleOutput)) + case None => + bitcoinClient.getChangeAddress().map(pubkeyHash => { + val txSingleOutput = fundTxResponse.tx.copy(txOut = Seq(TxOut(dummyChangeAmount, Script.pay2wpkh(pubkeyHash)))) + fundTxResponse.copy(tx = txSingleOutput) + }) + } + }).map(fundTxResponse => { + require(fundTxResponse.tx.txOut.size == 1, "funded transaction should have a single change output") + // NB: we insert the anchor input in the *first* position because our signing helpers only sign input #0. + val unsignedTx = anchorTx.modify(_.txInfo.tx).setTo(fundTxResponse.tx.copy(txIn = anchorTx.txInfo.tx.txIn.head +: fundTxResponse.tx.txIn)) + adjustAnchorOutputChange(unsignedTx, commitTx, fundTxResponse.amountIn + AnchorOutputsCommitmentFormat.anchorAmount, commitFeerate, targetFeerate, dustLimit) + }) + } + + private def addInputs(htlcTx: HtlcWithWitnessData, targetFeerate: FeeratePerKw, commitments: Commitments): Future[(HtlcWithWitnessData, Satoshi)] = { + // NB: fundrawtransaction doesn't support non-wallet inputs, so we clear the input and re-add it later. + val txNotFunded = htlcTx.txInfo.tx.copy(txIn = Nil, txOut = htlcTx.txInfo.tx.txOut.head.copy(amount = commitments.localParams.dustLimit) :: Nil) + val htlcTxWeight = htlcTx.txInfo match { + case _: HtlcSuccessTx => commitments.commitmentFormat.htlcSuccessWeight + case _: HtlcTimeoutTx => commitments.commitmentFormat.htlcTimeoutWeight + } + // We want the feerate of our final HTLC tx to equal targetFeerate. However, we removed the HTLC input from what we + // send to fundrawtransaction, so bitcoind will not know the total weight of the final tx. In order to make up for + // this difference, we need to tell bitcoind to target a higher feerate that takes into account the weight of the + // input we removed. + // That feerate will satisfy the following equality: + // feerate * weight_seen_by_bitcoind = target_feerate * (weight_seen_by_bitcoind + htlc_input_weight) + // So: feerate = target_feerate * (1 + htlc_input_weight / weight_seen_by_bitcoind) + // Because bitcoind will add at least one P2WPKH input, weight_seen_by_bitcoind >= htlc_tx_weight + p2wpkh_weight + // Thus: feerate <= target_feerate * (1 + htlc_input_weight / (htlc_tx_weight + p2wpkh_weight)) + // NB: we don't take into account the fee paid by our HTLC input: we will take it into account when we adjust the + // change output amount (unless bitcoind didn't add any change output, in that case we will overpay the fee slightly). + val weightRatio = 1.0 + (htlcInputMaxWeight.toDouble / (htlcTxWeight + claimP2WPKHOutputWeight)) + bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(targetFeerate * weightRatio, lockUtxos = true, changePosition = Some(1))).map(fundTxResponse => { + // We add the HTLC input (from the commit tx) and restore the HTLC output. + // NB: we can't modify them because they are signed by our peer (with SIGHASH_SINGLE | SIGHASH_ANYONECANPAY). + val txWithHtlcInput = fundTxResponse.tx.copy( + txIn = htlcTx.txInfo.tx.txIn ++ fundTxResponse.tx.txIn, + txOut = htlcTx.txInfo.tx.txOut ++ fundTxResponse.tx.txOut.tail + ) + val unsignedTx = htlcTx match { + case htlcSuccess: HtlcSuccessWithWitnessData => htlcSuccess.modify(_.txInfo.tx).setTo(txWithHtlcInput) + case htlcTimeout: HtlcTimeoutWithWitnessData => htlcTimeout.modify(_.txInfo.tx).setTo(txWithHtlcInput) + } + adjustHtlcTxChange(unsignedTx, fundTxResponse.amountIn + unsignedTx.txInfo.input.txOut.amount, targetFeerate, commitments) + }) + } + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala new file mode 100644 index 0000000000..ab307ec2a9 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala @@ -0,0 +1,264 @@ +/* + * Copyright 2021 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.channel.publish + +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.{ActorRef, Behavior} +import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Crypto} +import fr.acinq.eclair.NodeParams +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient +import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishLogContext +import fr.acinq.eclair.channel.{Commitments, HtlcTxAndRemoteSig} +import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.transactions.Transactions._ +import fr.acinq.eclair.wire.protocol.UpdateFulfillHtlc + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success} + +/** + * Created by t-bast on 20/12/2021. + */ + +/** + * This actor verifies that preconditions are met before attempting to publish a replaceable transaction. + * It verifies for example that we're not trying to publish htlc transactions while the remote commitment has already + * been confirmed, or that we have all the data necessary to sign transactions. + */ +object ReplaceableTxPrePublisher { + + // @formatter:off + sealed trait Command + case class CheckPreconditions(replyTo: ActorRef[PreconditionsResult], cmd: TxPublisher.PublishReplaceableTx) extends Command + case object Stop extends Command + + private case object ParentTxOk extends Command + private case object CommitTxAlreadyConfirmed extends RuntimeException with Command + private case object LocalCommitTxConfirmed extends Command + private case object RemoteCommitTxConfirmed extends Command + private case object RemoteCommitTxPublished extends Command + private case class UnknownFailure(reason: Throwable) extends Command + // @formatter:on + + // @formatter:off + sealed trait PreconditionsResult + case class PreconditionsOk(txWithWitnessData: ReplaceableTxWithWitnessData) extends PreconditionsResult + case class PreconditionsFailed(reason: TxPublisher.TxRejectedReason) extends PreconditionsResult + + /** Replaceable transaction with all the witness data necessary to finalize. */ + sealed trait ReplaceableTxWithWitnessData { def txInfo: ReplaceableTransactionWithInputInfo } + /** Replaceable transaction for which we may need to add wallet inputs. */ + sealed trait ReplaceableTxWithWalletInputs extends ReplaceableTxWithWitnessData + case class ClaimLocalAnchorWithWitnessData(txInfo: ClaimLocalAnchorOutputTx) extends ReplaceableTxWithWalletInputs + sealed trait HtlcWithWitnessData extends ReplaceableTxWithWalletInputs { override def txInfo: HtlcTx } + case class HtlcSuccessWithWitnessData(txInfo: HtlcSuccessTx, remoteSig: ByteVector64, preimage: ByteVector32) extends HtlcWithWitnessData + case class HtlcTimeoutWithWitnessData(txInfo: HtlcTimeoutTx, remoteSig: ByteVector64) extends HtlcWithWitnessData + sealed trait ClaimHtlcWithWitnessData extends ReplaceableTxWithWitnessData { override def txInfo: ClaimHtlcTx } + case class ClaimHtlcSuccessWithWitnessData(txInfo: ClaimHtlcSuccessTx, preimage: ByteVector32) extends ClaimHtlcWithWitnessData + case class LegacyClaimHtlcSuccessWithWitnessData(txInfo: LegacyClaimHtlcSuccessTx, preimage: ByteVector32) extends ClaimHtlcWithWitnessData + case class ClaimHtlcTimeoutWithWitnessData(txInfo: ClaimHtlcTimeoutTx) extends ClaimHtlcWithWitnessData + // @formatter:on + + def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, loggingInfo: TxPublishLogContext): Behavior[Command] = { + Behaviors.setup { context => + Behaviors.withMdc(loggingInfo.mdc()) { + new ReplaceableTxPrePublisher(nodeParams, bitcoinClient, context).start() + } + } + } + +} + +private class ReplaceableTxPrePublisher(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, context: ActorContext[ReplaceableTxPrePublisher.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { + + import ReplaceableTxPrePublisher._ + + private val log = context.log + + def start(): Behavior[Command] = { + Behaviors.receiveMessagePartial { + case CheckPreconditions(replyTo, cmd) => + cmd.txInfo match { + case localAnchorTx: Transactions.ClaimLocalAnchorOutputTx => checkAnchorPreconditions(replyTo, cmd, localAnchorTx) + case htlcTx: Transactions.HtlcTx => checkHtlcPreconditions(replyTo, cmd, htlcTx) + case claimHtlcTx: Transactions.ClaimHtlcTx => checkClaimHtlcPreconditions(replyTo, cmd, claimHtlcTx) + } + case Stop => Behaviors.stopped + } + } + + def checkAnchorPreconditions(replyTo: ActorRef[PreconditionsResult], cmd: TxPublisher.PublishReplaceableTx, localAnchorTx: ClaimLocalAnchorOutputTx): Behavior[Command] = { + // We verify that: + // - our commit is not confirmed (if it is, no need to claim our anchor) + // - their commit is not confirmed (if it is, no need to claim our anchor either) + // - our commit tx is in the mempool (otherwise we can't claim our anchor) + val commitTx = cmd.commitments.fullySignedLocalCommitTx(nodeParams.channelKeyManager).tx + val fundingOutpoint = cmd.commitments.commitInput.outPoint + context.pipeToSelf(bitcoinClient.isTransactionOutputSpendable(fundingOutpoint.txid, fundingOutpoint.index.toInt, includeMempool = false).flatMap { + case false => Future.failed(CommitTxAlreadyConfirmed) + case true => + // We must ensure our local commit tx is in the mempool before publishing the anchor transaction. + // If it's already published, this call will be a no-op. + bitcoinClient.publishTransaction(commitTx) + }) { + case Success(_) => ParentTxOk + case Failure(CommitTxAlreadyConfirmed) => CommitTxAlreadyConfirmed + case Failure(reason) if reason.getMessage.contains("rejecting replacement") => RemoteCommitTxPublished + case Failure(reason) => UnknownFailure(reason) + } + Behaviors.receiveMessagePartial { + case ParentTxOk => + replyTo ! PreconditionsOk(ClaimLocalAnchorWithWitnessData(localAnchorTx)) + Behaviors.stopped + case CommitTxAlreadyConfirmed => + log.debug("commit tx is already confirmed, no need to claim our anchor") + replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) + Behaviors.stopped + case RemoteCommitTxPublished => + log.warn("cannot publish commit tx: there is a conflicting tx in the mempool") + // We retry until that conflicting commit tx is confirmed or we're able to publish our local commit tx. + replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) + Behaviors.stopped + case UnknownFailure(reason) => + log.error(s"could not check ${cmd.desc} preconditions, proceeding anyway", reason) + // If our checks fail, we don't want it to prevent us from trying to publish our commit tx. + replyTo ! PreconditionsOk(ClaimLocalAnchorWithWitnessData(localAnchorTx)) + Behaviors.stopped + case Stop => Behaviors.stopped + } + } + + def checkHtlcPreconditions(replyTo: ActorRef[PreconditionsResult], cmd: TxPublisher.PublishReplaceableTx, htlcTx: HtlcTx): Behavior[Command] = { + // We verify that: + // - their commit is not confirmed: if it is, there is no need to publish our htlc transactions + // - if this is an htlc-success transaction, we have the preimage + context.pipeToSelf(bitcoinClient.getTxConfirmations(cmd.commitments.remoteCommit.txid)) { + case Success(Some(depth)) if depth >= nodeParams.minDepthBlocks => RemoteCommitTxConfirmed + case Success(_) => ParentTxOk + case Failure(reason) => UnknownFailure(reason) + } + Behaviors.receiveMessagePartial { + case ParentTxOk => + extractHtlcWitnessData(htlcTx, cmd.commitments) match { + case Some(txWithWitnessData) => replyTo ! PreconditionsOk(txWithWitnessData) + case None => replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) + } + Behaviors.stopped + case RemoteCommitTxConfirmed => + log.warn("cannot publish {}: remote commit has been confirmed", cmd.desc) + replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.ConflictingTxConfirmed) + Behaviors.stopped + case UnknownFailure(reason) => + log.error(s"could not check ${cmd.desc} preconditions, proceeding anyway", reason) + // If our checks fail, we don't want it to prevent us from trying to publish our htlc transactions. + extractHtlcWitnessData(htlcTx, cmd.commitments) match { + case Some(txWithWitnessData) => replyTo ! PreconditionsOk(txWithWitnessData) + case None => replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) + } + Behaviors.stopped + case Stop => Behaviors.stopped + } + } + + private def extractHtlcWitnessData(htlcTx: HtlcTx, commitments: Commitments): Option[ReplaceableTxWithWitnessData] = { + htlcTx match { + case tx: HtlcSuccessTx => + commitments.localCommit.htlcTxsAndRemoteSigs.collectFirst { + case HtlcTxAndRemoteSig(HtlcSuccessTx(input, _, _, _), remoteSig) if input.outPoint == tx.input.outPoint => remoteSig + } match { + case Some(remoteSig) => + commitments.localChanges.all.collectFirst { + case u: UpdateFulfillHtlc if Crypto.sha256(u.paymentPreimage) == tx.paymentHash => u.paymentPreimage + } match { + case Some(preimage) => Some(HtlcSuccessWithWitnessData(tx, remoteSig, preimage)) + case None => + log.error("preimage not found for htlcId={}, skipping...", tx.htlcId) + None + } + case None => + log.error("remote signature not found for htlcId={}, skipping...", tx.htlcId) + None + } + case tx: HtlcTimeoutTx => + commitments.localCommit.htlcTxsAndRemoteSigs.collectFirst { + case HtlcTxAndRemoteSig(HtlcTimeoutTx(input, _, _), remoteSig) if input.outPoint == tx.input.outPoint => remoteSig + } match { + case Some(remoteSig) => Some(HtlcTimeoutWithWitnessData(tx, remoteSig)) + case None => + log.error("remote signature not found for htlcId={}, skipping...", tx.htlcId) + None + } + } + } + + def checkClaimHtlcPreconditions(replyTo: ActorRef[PreconditionsResult], cmd: TxPublisher.PublishReplaceableTx, claimHtlcTx: ClaimHtlcTx): Behavior[Command] = { + // We verify that: + // - our commit is not confirmed: if it is, there is no need to publish our claim-htlc transactions + // - if this is a claim-htlc-success transaction, we have the preimage + context.pipeToSelf(bitcoinClient.getTxConfirmations(cmd.commitments.localCommit.commitTxAndRemoteSig.commitTx.tx.txid)) { + case Success(Some(depth)) if depth >= nodeParams.minDepthBlocks => LocalCommitTxConfirmed + case Success(_) => ParentTxOk + case Failure(reason) => UnknownFailure(reason) + } + Behaviors.receiveMessagePartial { + case ParentTxOk => + extractClaimHtlcWitnessData(claimHtlcTx, cmd.commitments) match { + case Some(txWithWitnessData) => replyTo ! PreconditionsOk(txWithWitnessData) + case None => replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) + } + Behaviors.stopped + case LocalCommitTxConfirmed => + log.warn("cannot publish {}: local commit has been confirmed", cmd.desc) + replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.ConflictingTxConfirmed) + Behaviors.stopped + case UnknownFailure(reason) => + log.error(s"could not check ${cmd.desc} preconditions, proceeding anyway", reason) + // If our checks fail, we don't want it to prevent us from trying to publish our htlc transactions. + extractClaimHtlcWitnessData(claimHtlcTx, cmd.commitments) match { + case Some(txWithWitnessData) => replyTo ! PreconditionsOk(txWithWitnessData) + case None => replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) + } + Behaviors.stopped + case Stop => Behaviors.stopped + } + } + + private def extractClaimHtlcWitnessData(claimHtlcTx: ClaimHtlcTx, commitments: Commitments): Option[ReplaceableTxWithWitnessData] = { + claimHtlcTx match { + case tx: LegacyClaimHtlcSuccessTx => + commitments.localChanges.all.collectFirst { + case u: UpdateFulfillHtlc if u.id == tx.htlcId => u.paymentPreimage + } match { + case Some(preimage) => Some(LegacyClaimHtlcSuccessWithWitnessData(tx, preimage)) + case None => + log.error("preimage not found for legacy htlcId={}, skipping...", tx.htlcId) + None + } + case tx: ClaimHtlcSuccessTx => + commitments.localChanges.all.collectFirst { + case u: UpdateFulfillHtlc if Crypto.sha256(u.paymentPreimage) == tx.paymentHash => u.paymentPreimage + } match { + case Some(preimage) => Some(ClaimHtlcSuccessWithWitnessData(tx, preimage)) + case None => + log.error("preimage not found for htlcId={}, skipping...", tx.htlcId) + None + } + case tx: ClaimHtlcTimeoutTx => Some(ClaimHtlcTimeoutWithWitnessData(tx)) + } + } + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index f19c32b682..00ae4c54ec 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -16,24 +16,17 @@ package fr.acinq.eclair.channel.publish -import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler} +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} import akka.actor.typed.{ActorRef, Behavior} -import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Crypto, OutPoint, Satoshi, Script, Transaction, TxOut} +import fr.acinq.bitcoin.{OutPoint, Satoshi, Transaction} import fr.acinq.eclair.NodeParams import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient -import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.FundTransactionOptions import fr.acinq.eclair.blockchain.fee.{FeeEstimator, FeeratePerKw} +import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher.{ClaimLocalAnchorWithWitnessData, ReplaceableTxWithWitnessData} import fr.acinq.eclair.channel.publish.TxPublisher.{TxPublishLogContext, TxRejectedReason} -import fr.acinq.eclair.channel.publish.TxTimeLocksMonitor.CheckTx -import fr.acinq.eclair.channel.{Commitments, HtlcTxAndRemoteSig} -import fr.acinq.eclair.transactions.Transactions -import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.wire.protocol.UpdateFulfillHtlc -import scala.concurrent.duration.DurationInt -import scala.concurrent.{ExecutionContext, Future} -import scala.util.{Failure, Success} +import scala.concurrent.ExecutionContext /** * Created by t-bast on 10/06/2021. @@ -48,27 +41,19 @@ object ReplaceableTxPublisher { // @formatter:off sealed trait Command case class Publish(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx) extends Command + case object Stop extends Command + + private case class WrappedPreconditionsResult(result: ReplaceableTxPrePublisher.PreconditionsResult) extends Command private case object TimeLocksOk extends Command - private case object CommitTxAlreadyConfirmed extends RuntimeException with Command - private case object RemoteCommitTxPublished extends RuntimeException with Command - private case object LocalCommitTxConfirmed extends Command - private case object RemoteCommitTxConfirmed extends Command - private case object PreconditionsOk extends Command - private case class FundingFailed(reason: Throwable) extends Command - private case class SignFundedTx(tx: ReplaceableTransactionWithInputInfo, fee: Satoshi) extends Command - private case class PublishSignedTx(tx: Transaction) extends Command + private case class WrappedFundingResult(result: ReplaceableTxFunder.FundingResult) extends Command private case class WrappedTxResult(result: MempoolTxMonitor.TxResult) extends Command - private case class UnknownFailure(reason: Throwable) extends Command private case object UtxosUnlocked extends Command - case object Stop extends Command // @formatter:on def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, watcher: ActorRef[ZmqWatcher.Command], loggingInfo: TxPublishLogContext): Behavior[Command] = { Behaviors.setup { context => - Behaviors.withTimers { timers => - Behaviors.withMdc(loggingInfo.mdc()) { - new ReplaceableTxPublisher(nodeParams, bitcoinClient, watcher, context, timers, loggingInfo).start() - } + Behaviors.withMdc(loggingInfo.mdc()) { + new ReplaceableTxPublisher(nodeParams, bitcoinClient, watcher, context, loggingInfo).start() } } } @@ -89,360 +74,70 @@ object ReplaceableTxPublisher { feeEstimator.getFeeratePerKw(blockTarget) } - /** - * Adjust the amount of the change output of an anchor tx to match our target feerate. - * We need this because fundrawtransaction doesn't allow us to leave non-wallet inputs, so we have to add them - * afterwards which may bring the resulting feerate below our target. - */ - def adjustAnchorOutputChange(unsignedTx: ClaimLocalAnchorOutputTx, commitTx: Transaction, amountIn: Satoshi, currentFeerate: FeeratePerKw, targetFeerate: FeeratePerKw, dustLimit: Satoshi): (ClaimLocalAnchorOutputTx, Satoshi) = { - require(unsignedTx.tx.txOut.size == 1, "funded transaction should have a single change output") - // We take into account witness weight and adjust the fee to match our desired feerate. - val dummySignedClaimAnchorTx = addSigs(unsignedTx, PlaceHolderSig) - // NB: we assume that our bitcoind wallet uses only P2WPKH inputs when funding txs. - val estimatedWeight = commitTx.weight() + dummySignedClaimAnchorTx.tx.weight() + claimP2WPKHOutputWitnessWeight * (dummySignedClaimAnchorTx.tx.txIn.size - 1) - val targetFee = weight2fee(targetFeerate, estimatedWeight) - weight2fee(currentFeerate, commitTx.weight()) - val amountOut = dustLimit.max(amountIn - targetFee) - val updatedAnchorTx = unsignedTx.copy(tx = unsignedTx.tx.copy(txOut = unsignedTx.tx.txOut.head.copy(amount = amountOut) :: Nil)) - val fee = amountIn - updatedAnchorTx.tx.txOut.map(_.amount).sum - (updatedAnchorTx, fee) - } - - /** - * Adjust the change output of an htlc tx to match our target feerate. - * We need this because fundrawtransaction doesn't allow us to leave non-wallet inputs, so we have to add them - * afterwards which may bring the resulting feerate below our target. - */ - def adjustHtlcTxChange(unsignedTx: HtlcTx, amountIn: Satoshi, targetFeerate: FeeratePerKw, commitments: Commitments): (HtlcTx, Satoshi) = { - require(unsignedTx.tx.txOut.size <= 2, "funded transaction should have at most one change output") - val dummySignedTx = unsignedTx match { - case tx: HtlcSuccessTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, ByteVector32.Zeroes, commitments.commitmentFormat) - case tx: HtlcTimeoutTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, commitments.commitmentFormat) - } - // We adjust the change output to obtain the targeted feerate. - val estimatedWeight = dummySignedTx.tx.weight() + claimP2WPKHOutputWitnessWeight * (dummySignedTx.tx.txIn.size - 1) - val targetFee = weight2fee(targetFeerate, estimatedWeight) - val changeAmount = amountIn - dummySignedTx.tx.txOut.head.amount - targetFee - val updatedHtlcTx = if (dummySignedTx.tx.txOut.length == 2 && changeAmount >= commitments.localParams.dustLimit) { - unsignedTx match { - case htlcSuccess: HtlcSuccessTx => htlcSuccess.copy(tx = htlcSuccess.tx.copy(txOut = Seq(htlcSuccess.tx.txOut.head, htlcSuccess.tx.txOut(1).copy(amount = changeAmount)))) - case htlcTimeout: HtlcTimeoutTx => htlcTimeout.copy(tx = htlcTimeout.tx.copy(txOut = Seq(htlcTimeout.tx.txOut.head, htlcTimeout.tx.txOut(1).copy(amount = changeAmount)))) - } - } else { - unsignedTx match { - case htlcSuccess: HtlcSuccessTx => htlcSuccess.copy(tx = htlcSuccess.tx.copy(txOut = Seq(htlcSuccess.tx.txOut.head))) - case htlcTimeout: HtlcTimeoutTx => htlcTimeout.copy(tx = htlcTimeout.tx.copy(txOut = Seq(htlcTimeout.tx.txOut.head))) - } - } - val fee = amountIn - updatedHtlcTx.tx.txOut.map(_.amount).sum - (updatedHtlcTx, fee) - } - - def adjustClaimHtlcTxOutput(unsignedTx: ClaimHtlcTx, targetFeerate: FeeratePerKw, commitments: Commitments): Either[TxGenerationSkipped, (ClaimHtlcTx, Satoshi)] = { - require(unsignedTx.tx.txIn.size == 1, "claim-htlc transaction should have a single input") - require(unsignedTx.tx.txOut.size == 1, "claim-htlc transaction should have a single output") - val dummySignedTx = unsignedTx match { - case tx: ClaimHtlcSuccessTx => addSigs(tx, PlaceHolderSig, ByteVector32.Zeroes) - case tx: ClaimHtlcTimeoutTx => addSigs(tx, PlaceHolderSig) - case tx: LegacyClaimHtlcSuccessTx => tx - } - val targetFee = weight2fee(targetFeerate, dummySignedTx.tx.weight()) - val outputAmount = unsignedTx.input.txOut.amount - targetFee - if (outputAmount < commitments.localParams.dustLimit) { - Left(AmountBelowDustLimit) - } else { - val updatedClaimHtlcTx = unsignedTx match { - case claimHtlcSuccess: ClaimHtlcSuccessTx => claimHtlcSuccess.copy(tx = claimHtlcSuccess.tx.copy(txOut = Seq(claimHtlcSuccess.tx.txOut.head.copy(amount = outputAmount)))) - case claimHtlcTimeout: ClaimHtlcTimeoutTx => claimHtlcTimeout.copy(tx = claimHtlcTimeout.tx.copy(txOut = Seq(claimHtlcTimeout.tx.txOut.head.copy(amount = outputAmount)))) - case legacyClaimHtlcSuccess: LegacyClaimHtlcSuccessTx => legacyClaimHtlcSuccess - } - Right(updatedClaimHtlcTx, targetFee) - } - } - - sealed trait HtlcTxAndWitnessData { - // @formatter:off - def txInfo: HtlcTx - def updateTx(tx: Transaction): HtlcTxAndWitnessData - def addSigs(localSig: ByteVector64, commitmentFormat: CommitmentFormat): HtlcTx - // @formatter:on - } - - object HtlcTxAndWitnessData { - - case class HtlcSuccess(txInfo: HtlcSuccessTx, remoteSig: ByteVector64, preimage: ByteVector32) extends HtlcTxAndWitnessData { - // @formatter:off - override def updateTx(tx: Transaction): HtlcTxAndWitnessData = copy(txInfo = txInfo.copy(tx = tx)) - override def addSigs(localSig: ByteVector64, commitmentFormat: CommitmentFormat): HtlcTx = Transactions.addSigs(txInfo, localSig, remoteSig, preimage, commitmentFormat) - // @formatter:on - } - - case class HtlcTimeout(txInfo: HtlcTimeoutTx, remoteSig: ByteVector64) extends HtlcTxAndWitnessData { - // @formatter:off - override def updateTx(tx: Transaction): HtlcTxAndWitnessData = copy(txInfo = txInfo.copy(tx = tx)) - override def addSigs(localSig: ByteVector64, commitmentFormat: CommitmentFormat): HtlcTx = Transactions.addSigs(txInfo, localSig, remoteSig, commitmentFormat) - // @formatter:on - } - - def apply(txInfo: HtlcTx, commitments: Commitments): Option[HtlcTxAndWitnessData] = { - txInfo match { - case tx: HtlcSuccessTx => - commitments.localChanges.all.collectFirst { - case u: UpdateFulfillHtlc if Crypto.sha256(u.paymentPreimage) == tx.paymentHash => u.paymentPreimage - }.flatMap(preimage => { - commitments.localCommit.htlcTxsAndRemoteSigs.collectFirst { - case HtlcTxAndRemoteSig(HtlcSuccessTx(input, _, _, _), remoteSig) if input.outPoint == tx.input.outPoint => HtlcSuccess(tx, remoteSig, preimage) - } - }) - case tx: HtlcTimeoutTx => - commitments.localCommit.htlcTxsAndRemoteSigs.collectFirst { - case HtlcTxAndRemoteSig(HtlcTimeoutTx(input, _, _), remoteSig) if input.outPoint == tx.input.outPoint => HtlcTimeout(tx, remoteSig) - } - } - } - - } - } -private class ReplaceableTxPublisher(nodeParams: NodeParams, - bitcoinClient: BitcoinCoreClient, - watcher: ActorRef[ZmqWatcher.Command], - context: ActorContext[ReplaceableTxPublisher.Command], - timers: TimerScheduler[ReplaceableTxPublisher.Command], - loggingInfo: TxPublishLogContext)(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { +private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, watcher: ActorRef[ZmqWatcher.Command], context: ActorContext[ReplaceableTxPublisher.Command], loggingInfo: TxPublishLogContext)(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { import ReplaceableTxPublisher._ - import nodeParams.{channelKeyManager => keyManager} private val log = context.log def start(): Behavior[Command] = { Behaviors.receiveMessagePartial { - case Publish(replyTo, cmd) => - cmd.txInfo match { - case _: ClaimLocalAnchorOutputTx => checkAnchorPreconditions(replyTo, cmd) - case htlcTx: HtlcTx => checkHtlcPreconditions(replyTo, cmd, htlcTx) - case claimHtlcTx: ClaimHtlcTx => checkClaimHtlcPreconditions(replyTo, cmd, claimHtlcTx) - } + case Publish(replyTo, cmd) => checkPreconditions(replyTo, cmd) case Stop => Behaviors.stopped } } - def checkAnchorPreconditions(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx): Behavior[Command] = { - // We verify that: - // - our commit is not confirmed (if it is, no need to claim our anchor) - // - their commit is not confirmed (if it is, no need to claim our anchor either) - // - our commit tx is in the mempool (otherwise we can't claim our anchor) - val commitTx = cmd.commitments.fullySignedLocalCommitTx(nodeParams.channelKeyManager).tx - val fundingOutpoint = cmd.commitments.commitInput.outPoint - context.pipeToSelf(bitcoinClient.isTransactionOutputSpendable(fundingOutpoint.txid, fundingOutpoint.index.toInt, includeMempool = false).flatMap { - case false => Future.failed(CommitTxAlreadyConfirmed) - case true => - // We must ensure our local commit tx is in the mempool before publishing the anchor transaction. - // If it's already published, this call will be a no-op. - bitcoinClient.publishTransaction(commitTx) - }) { - case Success(_) => PreconditionsOk - case Failure(CommitTxAlreadyConfirmed) => CommitTxAlreadyConfirmed - case Failure(reason) if reason.getMessage.contains("rejecting replacement") => RemoteCommitTxPublished - case Failure(reason) => UnknownFailure(reason) - } - Behaviors.receiveMessagePartial { - case PreconditionsOk => - val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.deadline, nodeParams.currentBlockHeight) - val commitFeerate = cmd.commitments.localCommit.spec.commitTxFeerate - if (targetFeerate <= commitFeerate) { - log.info("skipping {}: commit feerate is high enough (feerate={})", cmd.desc, commitFeerate) - // We set retry = true in case the on-chain feerate rises before the commit tx is confirmed: if that happens we'll - // want to claim our anchor to raise the feerate of the commit tx and get it confirmed faster. - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true))) - } else { - fund(replyTo, cmd, targetFeerate) - } - case CommitTxAlreadyConfirmed => - log.debug("commit tx is already confirmed, no need to claim our anchor") - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false))) - case RemoteCommitTxPublished => - log.warn("cannot publish commit tx: there is a conflicting tx in the mempool") - // We retry until that conflicting commit tx is confirmed or we're able to publish our local commit tx. - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true))) - case UnknownFailure(reason) => - log.error(s"could not check ${cmd.desc} preconditions", reason) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.UnknownTxFailure)) - case Stop => Behaviors.stopped - } - } - - def checkHtlcPreconditions(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, htlcTx: HtlcTx): Behavior[Command] = { - // We verify that: - // - their commit is not confirmed: if it is, there is no need to publish our HTLC transactions - context.pipeToSelf(bitcoinClient.getTxConfirmations(cmd.commitments.remoteCommit.txid)) { - case Success(Some(depth)) if depth >= nodeParams.minDepthBlocks => RemoteCommitTxConfirmed - case Success(_) => PreconditionsOk - case Failure(_) => PreconditionsOk // if our checks fail, we don't want it to prevent us from publishing HTLC transactions - } + def checkPreconditions(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx): Behavior[Command] = { + val prePublisher = context.spawn(ReplaceableTxPrePublisher(nodeParams, bitcoinClient, loggingInfo), "pre-publisher") + prePublisher ! ReplaceableTxPrePublisher.CheckPreconditions(context.messageAdapter[ReplaceableTxPrePublisher.PreconditionsResult](WrappedPreconditionsResult), cmd) Behaviors.receiveMessagePartial { - case PreconditionsOk => - HtlcTxAndWitnessData(htlcTx, cmd.commitments) match { - case Some(txWithWitnessData) => checkTimeLocks(replyTo, cmd, txWithWitnessData) - case None => - log.error("witness data not found for htlcId={}, skipping...", htlcTx.htlcId) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false))) - } - case RemoteCommitTxConfirmed => - log.warn("cannot publish {}: remote commit has been confirmed", cmd.desc) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.ConflictingTxConfirmed)) - case Stop => Behaviors.stopped - } - } - - def checkClaimHtlcPreconditions(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, claimHtlcTx: ClaimHtlcTx): Behavior[Command] = { - // We verify that: - // - our commit is not confirmed: if it is, there is no need to publish our claim-HTLC transactions - context.pipeToSelf(bitcoinClient.getTxConfirmations(cmd.commitments.localCommit.commitTxAndRemoteSig.commitTx.tx.txid)) { - case Success(Some(depth)) if depth >= nodeParams.minDepthBlocks => LocalCommitTxConfirmed - case Success(_) => PreconditionsOk - case Failure(_) => PreconditionsOk // if our checks fail, we don't want it to prevent us from publishing claim-HTLC transactions - } - Behaviors.receiveMessagePartial { - case PreconditionsOk => checkTimeLocks(replyTo, cmd, claimHtlcTx) - case LocalCommitTxConfirmed => - log.warn("cannot publish {}: local commit has been confirmed", cmd.desc) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.ConflictingTxConfirmed)) - case Stop => Behaviors.stopped - } - } - - def checkTimeLocks(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, htlcTxWithWitnessData: HtlcTxAndWitnessData): Behavior[Command] = { - val timeLocksChecker = context.spawn(TxTimeLocksMonitor(nodeParams, watcher, loggingInfo), "time-locks-monitor") - timeLocksChecker ! CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.txInfo.tx, cmd.desc) - Behaviors.receiveMessagePartial { - case TimeLocksOk => - val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.deadline, nodeParams.currentBlockHeight) - val htlcFeerate = cmd.commitments.localCommit.spec.htlcTxFeerate(cmd.commitments.commitmentFormat) - if (targetFeerate <= htlcFeerate) { - val channelKeyPath = keyManager.keyPath(cmd.commitments.localParams, cmd.commitments.channelConfig) - val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, cmd.commitments.localCommit.index) - val localHtlcBasepoint = keyManager.htlcPoint(channelKeyPath) - val localSig = keyManager.sign(htlcTxWithWitnessData.txInfo, localHtlcBasepoint, localPerCommitmentPoint, TxOwner.Local, cmd.commitments.commitmentFormat) - val signedHtlcTx = htlcTxWithWitnessData.addSigs(localSig, cmd.commitments.commitmentFormat) - log.info("publishing {} without adding inputs: txid={}", cmd.desc, signedHtlcTx.tx.txid) - publish(replyTo, cmd, signedHtlcTx.tx, signedHtlcTx.fee) - } else { - fund(replyTo, cmd, targetFeerate) + case WrappedPreconditionsResult(result) => + result match { + case ReplaceableTxPrePublisher.PreconditionsOk(txWithWitnessData) => checkTimeLocks(replyTo, cmd, txWithWitnessData) + case ReplaceableTxPrePublisher.PreconditionsFailed(reason) => sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) } case Stop => - timeLocksChecker ! TxTimeLocksMonitor.Stop + prePublisher ! ReplaceableTxPrePublisher.Stop Behaviors.stopped } } - def checkTimeLocks(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, claimHtlcTx: ClaimHtlcTx): Behavior[Command] = { - val timeLocksChecker = context.spawn(TxTimeLocksMonitor(nodeParams, watcher, loggingInfo), "time-locks-monitor") - timeLocksChecker ! CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.txInfo.tx, cmd.desc) - Behaviors.receiveMessagePartial { - case TimeLocksOk => - val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.deadline, nodeParams.currentBlockHeight) - adjustClaimHtlcTxOutput(claimHtlcTx, targetFeerate, cmd.commitments) match { - case Left(reason) => - // The HTLC isn't economical to claim at the current feerate, but if the feerate goes down, we may want to claim it later. - log.warn("cannot publish {}: {}", cmd.desc, reason) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true))) - case Right((updatedClaimHtlcTx, fee)) => - val channelKeyPath = keyManager.keyPath(cmd.commitments.localParams, cmd.commitments.channelConfig) - val sig = keyManager.sign(updatedClaimHtlcTx, keyManager.htlcPoint(channelKeyPath), cmd.commitments.remoteCommit.remotePerCommitmentPoint, TxOwner.Local, cmd.commitments.commitmentFormat) - updatedClaimHtlcTx match { - case claimHtlcSuccess: LegacyClaimHtlcSuccessTx => - // The payment hash has been added to claim-htlc-success in https://github.com/ACINQ/eclair/pull/2101 - // Some transactions made with older versions of eclair may not set it correctly, in which case we simply - // publish the transaction as initially signed. - log.warn("payment hash not set for htlcId={}, publishing original transaction", claimHtlcSuccess.htlcId) - publish(replyTo, cmd, cmd.txInfo.tx, cmd.txInfo.fee) - case claimHtlcSuccess: ClaimHtlcSuccessTx => - val preimage_opt = cmd.commitments.localChanges.all.collectFirst { - case u: UpdateFulfillHtlc if Crypto.sha256(u.paymentPreimage) == claimHtlcSuccess.paymentHash => u.paymentPreimage - } - preimage_opt match { - case Some(preimage) => - val signedClaimHtlcTx = addSigs(claimHtlcSuccess, sig, preimage) - publish(replyTo, cmd, signedClaimHtlcTx.tx, fee) - case None => - log.error("preimage not found for htlcId={}, skipping...", claimHtlcSuccess.htlcId) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false))) - } - case claimHtlcTimeout: ClaimHtlcTimeoutTx => - val signedClaimHtlcTx = addSigs(claimHtlcTimeout, sig) - publish(replyTo, cmd, signedClaimHtlcTx.tx, fee) - } + def checkTimeLocks(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = { + txWithWitnessData match { + // There are no time locks on anchor transactions, we can claim them right away. + case _: ClaimLocalAnchorWithWitnessData => fund(replyTo, cmd, txWithWitnessData) + case _ => + val timeLocksChecker = context.spawn(TxTimeLocksMonitor(nodeParams, watcher, loggingInfo), "time-locks-monitor") + timeLocksChecker ! TxTimeLocksMonitor.CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.txInfo.tx, cmd.desc) + Behaviors.receiveMessagePartial { + case TimeLocksOk => fund(replyTo, cmd, txWithWitnessData) + case Stop => + timeLocksChecker ! TxTimeLocksMonitor.Stop + Behaviors.stopped } - case Stop => - timeLocksChecker ! TxTimeLocksMonitor.Stop - Behaviors.stopped } } - def fund(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, targetFeerate: FeeratePerKw): Behavior[Command] = { - context.pipeToSelf(addInputs(cmd.txInfo, targetFeerate, cmd.commitments)) { - case Success((fundedTx, fee)) => SignFundedTx(fundedTx, fee) - case Failure(reason) => FundingFailed(reason) - } + def fund(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = { + val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.deadline, nodeParams.currentBlockHeight) + val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, loggingInfo), "tx-funder") + txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, txWithWitnessData, targetFeerate) Behaviors.receiveMessagePartial { - case SignFundedTx(fundedTx, fee) => - log.info("added {} wallet input(s) and {} wallet output(s) to {}", fundedTx.tx.txIn.length - 1, fundedTx.tx.txOut.length - 1, cmd.desc) - sign(replyTo, cmd, fundedTx, fee) - case FundingFailed(reason) => - if (reason.getMessage.contains("Insufficient funds")) { - log.warn("cannot add inputs to {}: {}", cmd.desc, reason.getMessage) - } else { - log.error("cannot add inputs to {}: {}", cmd.desc, reason) + case WrappedFundingResult(result) => + result match { + case success: ReplaceableTxFunder.TransactionReady => publish(replyTo, cmd, success.tx, success.fee) + case ReplaceableTxFunder.FundingFailed(reason) => sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) } - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.CouldNotFund)) case Stop => - // We've asked bitcoind to lock utxos, so we can't stop right now without unlocking them. - // Since we don't know yet what utxos have been locked, we defer the message. - timers.startSingleTimer(Stop, 1 second) + // We don't stop right away, because the child actor may need to unlock utxos first. + // If the child actor has already been terminated, this will emit the event instantly. + context.watchWith(txFunder, UtxosUnlocked) + txFunder ! ReplaceableTxFunder.Stop Behaviors.same - } - } - - def sign(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, fundedTx: ReplaceableTransactionWithInputInfo, fee: Satoshi): Behavior[Command] = { - fundedTx match { - case claimAnchorTx: ClaimLocalAnchorOutputTx => - val claimAnchorSig = keyManager.sign(claimAnchorTx, keyManager.fundingPublicKey(cmd.commitments.localParams.fundingKeyPath), TxOwner.Local, cmd.commitments.commitmentFormat) - val signedClaimAnchorTx = addSigs(claimAnchorTx, claimAnchorSig) - val commitInfo = BitcoinCoreClient.PreviousTx(signedClaimAnchorTx.input, signedClaimAnchorTx.tx.txIn.head.witness) - context.pipeToSelf(bitcoinClient.signTransaction(signedClaimAnchorTx.tx, Seq(commitInfo))) { - case Success(signedTx) => PublishSignedTx(signedTx.tx) - case Failure(reason) => UnknownFailure(reason) - } - case htlcTx: HtlcTx => - // NB: we've already checked witness data in the precondition phase. Witness data extraction should be done - // earlier by the channel to remove this duplication. - val txWithWitnessData = HtlcTxAndWitnessData(htlcTx, cmd.commitments).get - val channelKeyPath = keyManager.keyPath(cmd.commitments.localParams, cmd.commitments.channelConfig) - val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, cmd.commitments.localCommit.index) - val localHtlcBasepoint = keyManager.htlcPoint(channelKeyPath) - val localSig = keyManager.sign(htlcTx, localHtlcBasepoint, localPerCommitmentPoint, TxOwner.Local, cmd.commitments.commitmentFormat) - val signedHtlcTx = txWithWitnessData.addSigs(localSig, cmd.commitments.commitmentFormat) - val inputInfo = BitcoinCoreClient.PreviousTx(signedHtlcTx.input, signedHtlcTx.tx.txIn.head.witness) - context.pipeToSelf(bitcoinClient.signTransaction(signedHtlcTx.tx, Seq(inputInfo), allowIncomplete = true).map(signTxResponse => { - // NB: bitcoind versions older than 0.21.1 messes up the witness stack for our htlc input, so we need to restore it. - // See https://github.com/bitcoin/bitcoin/issues/21151 - signedHtlcTx.tx.copy(txIn = signedHtlcTx.tx.txIn.head +: signTxResponse.tx.txIn.tail) - })) { - case Success(signedTx) => PublishSignedTx(signedTx) - case Failure(reason) => UnknownFailure(reason) - } - case _: ClaimHtlcTx => log.error("claim-htlc-tx should not use external inputs") - } - Behaviors.receiveMessagePartial { - case PublishSignedTx(signedTx) => publish(replyTo, cmd, signedTx, fee) - case UnknownFailure(reason) => - log.error("cannot sign {}: {}", cmd.desc, reason) - replyTo ! TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.UnknownTxFailure) - // We wait for our parent to stop us: when that happens we will unlock utxos. - Behaviors.same - case Stop => unlockAndStop(cmd.input, fundedTx.tx) + case UtxosUnlocked => + Behaviors.stopped } } @@ -483,6 +178,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } + /** Use this function to send the result upstream and stop without stopping child actors. */ def sendResult(replyTo: ActorRef[TxPublisher.PublishTxResult], result: TxPublisher.PublishTxResult): Behavior[Command] = { replyTo ! result Behaviors.receiveMessagePartial { @@ -490,88 +186,5 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } - private def addInputs(txInfo: ReplaceableTransactionWithInputInfo, targetFeerate: FeeratePerKw, commitments: Commitments): Future[(ReplaceableTransactionWithInputInfo, Satoshi)] = { - txInfo match { - case anchorTx: ClaimLocalAnchorOutputTx => addInputs(anchorTx, targetFeerate, commitments) - case htlcTx: HtlcTx => addInputs(htlcTx, targetFeerate, commitments) - case _: ClaimHtlcTx => Future.failed(new RuntimeException("claim-htlc-tx should not use external inputs")) - } - } - - private def addInputs(txInfo: ClaimLocalAnchorOutputTx, targetFeerate: FeeratePerKw, commitments: Commitments): Future[(ClaimLocalAnchorOutputTx, Satoshi)] = { - val dustLimit = commitments.localParams.dustLimit - val commitFeerate = commitments.localCommit.spec.commitTxFeerate - val commitTx = commitments.fullySignedLocalCommitTx(nodeParams.channelKeyManager).tx - // We want the feerate of the package (commit tx + tx spending anchor) to equal targetFeerate. - // Thus we have: anchorFeerate = targetFeerate + (weight-commit-tx / weight-anchor-tx) * (targetFeerate - commitTxFeerate) - // If we use the smallest weight possible for the anchor tx, the feerate we use will thus be greater than what we want, - // and we can adjust it afterwards by raising the change output amount. - val anchorFeerate = targetFeerate + FeeratePerKw(targetFeerate.feerate - commitFeerate.feerate) * commitTx.weight() / claimAnchorOutputMinWeight - // NB: fundrawtransaction requires at least one output, and may add at most one additional change output. - // Since the purpose of this transaction is just to do a CPFP, the resulting tx should have a single change output - // (note that bitcoind doesn't let us publish a transaction with no outputs). - // To work around these limitations, we start with a dummy output and later merge that dummy output with the optional - // change output added by bitcoind. - // NB: fundrawtransaction doesn't support non-wallet inputs, so we have to remove our anchor input and re-add it later. - // That means bitcoind will not take our anchor input's weight into account when adding inputs to set the fee. - // That's ok, we can increase the fee later by decreasing the output amount. But we need to ensure we'll have enough - // to cover the weight of our anchor input, which is why we set it to the following value. - val dummyChangeAmount = weight2fee(anchorFeerate, claimAnchorOutputMinWeight) + dustLimit - val txNotFunded = Transaction(2, Nil, TxOut(dummyChangeAmount, Script.pay2wpkh(PlaceHolderPubKey)) :: Nil, 0) - bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(anchorFeerate, lockUtxos = true)).flatMap(fundTxResponse => { - // We merge the outputs if there's more than one. - fundTxResponse.changePosition match { - case Some(changePos) => - val changeOutput = fundTxResponse.tx.txOut(changePos) - val txSingleOutput = fundTxResponse.tx.copy(txOut = Seq(changeOutput.copy(amount = changeOutput.amount + dummyChangeAmount))) - Future.successful(fundTxResponse.copy(tx = txSingleOutput)) - case None => - bitcoinClient.getChangeAddress().map(pubkeyHash => { - val txSingleOutput = fundTxResponse.tx.copy(txOut = Seq(TxOut(dummyChangeAmount, Script.pay2wpkh(pubkeyHash)))) - fundTxResponse.copy(tx = txSingleOutput) - }) - } - }).map(fundTxResponse => { - require(fundTxResponse.tx.txOut.size == 1, "funded transaction should have a single change output") - // NB: we insert the anchor input in the *first* position because our signing helpers only sign input #0. - val unsignedTx = txInfo.copy(tx = fundTxResponse.tx.copy(txIn = txInfo.tx.txIn.head +: fundTxResponse.tx.txIn)) - adjustAnchorOutputChange(unsignedTx, commitTx, fundTxResponse.amountIn + AnchorOutputsCommitmentFormat.anchorAmount, commitFeerate, targetFeerate, dustLimit) - }) - } - - private def addInputs(txInfo: HtlcTx, targetFeerate: FeeratePerKw, commitments: Commitments): Future[(HtlcTx, Satoshi)] = { - // NB: fundrawtransaction doesn't support non-wallet inputs, so we clear the input and re-add it later. - val txNotFunded = txInfo.tx.copy(txIn = Nil, txOut = txInfo.tx.txOut.head.copy(amount = commitments.localParams.dustLimit) :: Nil) - val htlcTxWeight = txInfo match { - case _: HtlcSuccessTx => commitments.commitmentFormat.htlcSuccessWeight - case _: HtlcTimeoutTx => commitments.commitmentFormat.htlcTimeoutWeight - } - // We want the feerate of our final HTLC tx to equal targetFeerate. However, we removed the HTLC input from what we - // send to fundrawtransaction, so bitcoind will not know the total weight of the final tx. In order to make up for - // this difference, we need to tell bitcoind to target a higher feerate that takes into account the weight of the - // input we removed. - // That feerate will satisfy the following equality: - // feerate * weight_seen_by_bitcoind = target_feerate * (weight_seen_by_bitcoind + htlc_input_weight) - // So: feerate = target_feerate * (1 + htlc_input_weight / weight_seen_by_bitcoind) - // Because bitcoind will add at least one P2WPKH input, weight_seen_by_bitcoind >= htlc_tx_weight + p2wpkh_weight - // Thus: feerate <= target_feerate * (1 + htlc_input_weight / (htlc_tx_weight + p2wpkh_weight)) - // NB: we don't take into account the fee paid by our HTLC input: we will take it into account when we adjust the - // change output amount (unless bitcoind didn't add any change output, in that case we will overpay the fee slightly). - val weightRatio = 1.0 + (htlcInputMaxWeight.toDouble / (htlcTxWeight + claimP2WPKHOutputWeight)) - bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(targetFeerate * weightRatio, lockUtxos = true, changePosition = Some(1))).map(fundTxResponse => { - // We add the HTLC input (from the commit tx) and restore the HTLC output. - // NB: we can't modify them because they are signed by our peer (with SIGHASH_SINGLE | SIGHASH_ANYONECANPAY). - val txWithHtlcInput = fundTxResponse.tx.copy( - txIn = txInfo.tx.txIn ++ fundTxResponse.tx.txIn, - txOut = txInfo.tx.txOut ++ fundTxResponse.tx.txOut.tail - ) - val unsignedTx = txInfo match { - case htlcSuccess: HtlcSuccessTx => htlcSuccess.copy(tx = txWithHtlcInput) - case htlcTimeout: HtlcTimeoutTx => htlcTimeout.copy(tx = txWithHtlcInput) - } - adjustHtlcTxChange(unsignedTx, fundTxResponse.amountIn + unsignedTx.input.txOut.amount, targetFeerate, commitments) - }) - } - } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala index c606c1e14d..223e399e1b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala @@ -20,7 +20,7 @@ import akka.actor.typed.ActorRef import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, actorRefAdapter} import akka.pattern.pipe import akka.testkit.{TestFSMRef, TestProbe} -import fr.acinq.bitcoin.{BtcAmount, ByteVector32, MilliBtcDouble, OutPoint, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut} +import fr.acinq.bitcoin.{BtcAmount, ByteVector32, ByteVector64, MilliBtcDouble, OutPoint, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut} import fr.acinq.eclair.TestConstants.TestFeeEstimator import fr.acinq.eclair.blockchain.CurrentBlockCount import fr.acinq.eclair.blockchain.bitcoind.BitcoindService @@ -29,6 +29,7 @@ import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.MempoolTx import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinCoreClient, BitcoinJsonRPCClient} import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.channel._ +import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher.{ClaimLocalAnchorWithWitnessData, HtlcSuccessWithWitnessData, HtlcTimeoutWithWitnessData} import fr.acinq.eclair.channel.publish.ReplaceableTxPublisher.{Publish, Stop} import fr.acinq.eclair.channel.publish.TxPublisher.TxRejectedReason._ import fr.acinq.eclair.channel.publish.TxPublisher._ @@ -459,19 +460,19 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val walletInputs = (1 to walletInputsCount).map(_ => TxIn(OutPoint(randomBytes32(), 0), Nil, 0)) val amountIn = dustLimit * walletInputsCount + Random.nextInt(25_000_000).sat val amountOut = dustLimit + Random.nextLong(amountIn.toLong).sat - val unsignedTx = anchorTxInfo.copy(tx = anchorTxInfo.tx.copy( + val unsignedTx = ClaimLocalAnchorWithWitnessData(anchorTxInfo.copy(tx = anchorTxInfo.tx.copy( txIn = anchorTxInfo.tx.txIn ++ walletInputs, txOut = TxOut(amountOut, Script.pay2wpkh(randomKey().publicKey)) :: Nil, - )) - val (adjustedTx, fee) = ReplaceableTxPublisher.adjustAnchorOutputChange(unsignedTx, commitTx.tx, amountIn, commitFeerate, TestConstants.feeratePerKw, dustLimit) - assert(fee === amountIn - adjustedTx.tx.txOut.map(_.amount).sum) - assert(adjustedTx.tx.txIn.size === unsignedTx.tx.txIn.size) - assert(adjustedTx.tx.txOut.size === 1) - assert(adjustedTx.tx.txOut.head.amount >= dustLimit) - if (adjustedTx.tx.txOut.head.amount > dustLimit) { + ))) + val (adjustedTx, fee) = ReplaceableTxFunder.adjustAnchorOutputChange(unsignedTx, commitTx.tx, amountIn, commitFeerate, TestConstants.feeratePerKw, dustLimit) + assert(fee === amountIn - adjustedTx.txInfo.tx.txOut.map(_.amount).sum) + assert(adjustedTx.txInfo.tx.txIn.size === unsignedTx.txInfo.tx.txIn.size) + assert(adjustedTx.txInfo.tx.txOut.size === 1) + assert(adjustedTx.txInfo.tx.txOut.head.amount >= dustLimit) + if (adjustedTx.txInfo.tx.txOut.head.amount > dustLimit) { // Simulate tx signing to check final feerate. val signedTx = { - val anchorSigned = Transactions.addSigs(adjustedTx, Transactions.PlaceHolderSig) + val anchorSigned = Transactions.addSigs(adjustedTx.txInfo, Transactions.PlaceHolderSig) val signedWalletInputs = anchorSigned.tx.txIn.tail.map(txIn => txIn.copy(witness = ScriptWitness(Seq(Scripts.der(Transactions.PlaceHolderSig), Transactions.PlaceHolderPubKey.value)))) anchorSigned.tx.copy(txIn = anchorSigned.tx.txIn.head +: signedWalletInputs) } @@ -774,24 +775,24 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val walletInputs = (1 to walletInputsCount).map(_ => TxIn(OutPoint(randomBytes32(), 0), Nil, 0)) val walletAmountIn = dustLimit * walletInputsCount + Random.nextInt(25_000_000).sat val changeOutput = TxOut(Random.nextLong(walletAmountIn.toLong).sat, Script.pay2wpkh(randomKey().publicKey)) - val unsignedHtlcSuccessTx = htlcSuccess.txInfo.asInstanceOf[HtlcSuccessTx].copy(tx = htlcSuccess.txInfo.tx.copy( + val unsignedHtlcSuccessTx = HtlcSuccessWithWitnessData(htlcSuccess.txInfo.asInstanceOf[HtlcSuccessTx].copy(tx = htlcSuccess.txInfo.tx.copy( txIn = htlcSuccess.txInfo.tx.txIn ++ walletInputs, txOut = htlcSuccess.txInfo.tx.txOut ++ Seq(changeOutput) - )) - val unsignedHtlcTimeoutTx = htlcTimeout.txInfo.asInstanceOf[HtlcTimeoutTx].copy(tx = htlcTimeout.txInfo.tx.copy( + )), ByteVector64.Zeroes, ByteVector32.Zeroes) + val unsignedHtlcTimeoutTx = HtlcTimeoutWithWitnessData(htlcTimeout.txInfo.asInstanceOf[HtlcTimeoutTx].copy(tx = htlcTimeout.txInfo.tx.copy( txIn = htlcTimeout.txInfo.tx.txIn ++ walletInputs, txOut = htlcTimeout.txInfo.tx.txOut ++ Seq(changeOutput) - )) + )), ByteVector64.Zeroes) for (unsignedTx <- Seq(unsignedHtlcSuccessTx, unsignedHtlcTimeoutTx)) { - val totalAmountIn = unsignedTx.input.txOut.amount + walletAmountIn - val (adjustedTx, fee) = ReplaceableTxPublisher.adjustHtlcTxChange(unsignedTx, totalAmountIn, targetFeerate, commitments) - assert(fee === totalAmountIn - adjustedTx.tx.txOut.map(_.amount).sum) - assert(adjustedTx.tx.txIn.size === unsignedTx.tx.txIn.size) - assert(adjustedTx.tx.txOut.size === 1 || adjustedTx.tx.txOut.size === 2) - if (adjustedTx.tx.txOut.size == 2) { + val totalAmountIn = unsignedTx.txInfo.input.txOut.amount + walletAmountIn + val (adjustedTx, fee) = ReplaceableTxFunder.adjustHtlcTxChange(unsignedTx, totalAmountIn, targetFeerate, commitments) + assert(fee === totalAmountIn - adjustedTx.txInfo.tx.txOut.map(_.amount).sum) + assert(adjustedTx.txInfo.tx.txIn.size === unsignedTx.txInfo.tx.txIn.size) + assert(adjustedTx.txInfo.tx.txOut.size === 1 || adjustedTx.txInfo.tx.txOut.size === 2) + if (adjustedTx.txInfo.tx.txOut.size == 2) { // Simulate tx signing to check final feerate. val signedTx = { - val htlcSigned = adjustedTx match { + val htlcSigned = adjustedTx.txInfo match { case tx: HtlcSuccessTx => Transactions.addSigs(tx, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig, ByteVector32.Zeroes, commitments.commitmentFormat) case tx: HtlcTimeoutTx => Transactions.addSigs(tx, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig, commitments.commitmentFormat) } From d1209ad865b96ca6c6017d1c2da4905efb9984ad Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 23 Dec 2021 16:55:25 +0100 Subject: [PATCH 05/23] Regularly bump transaction fees Once we've published a first version of a replaceable transaction, at every new block we evaluate whether we should RBF it. When we choose to RBF, we try to reuse the previous wallet inputs and simply lower the change amount. When that's not possible, we ask bitcoind to add more wallet inputs. --- .../fr/acinq/eclair/channel/ChannelData.scala | 56 +- .../channel/publish/FinalTxPublisher.scala | 4 +- .../channel/publish/MempoolTxMonitor.scala | 26 +- .../channel/publish/ReplaceableTxFunder.scala | 247 +++++--- .../publish/ReplaceableTxPrePublisher.scala | 45 +- .../publish/ReplaceableTxPublisher.scala | 218 ++++++- .../eclair/transactions/Transactions.scala | 10 +- .../src/test/resources/logback-test.xml | 1 + .../scala/fr/acinq/eclair/TestConstants.scala | 5 +- .../publish/MempoolTxMonitorSpec.scala | 20 +- .../publish/ReplaceableTxFunderSpec.scala | 315 ++++++++++ .../publish/ReplaceableTxPublisherSpec.scala | 587 +++++++++++++----- .../states/g/NegotiatingStateSpec.scala | 10 +- 13 files changed, 1194 insertions(+), 350 deletions(-) create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala index 6f8a56874d..6997fc9768 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala @@ -455,39 +455,39 @@ final case class DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT(commitments: Com /** * @param initFeatures current connection features, or last features used if the channel is disconnected. Note that these - * features are updated at each reconnection and may be different from the channel permanent features - * (see [[ChannelFeatures]]). + * features are updated at each reconnection and may be different from the channel permanent features + * (see [[ChannelFeatures]]). */ -final case class LocalParams(nodeId: PublicKey, - fundingKeyPath: DeterministicWallet.KeyPath, - dustLimit: Satoshi, - maxHtlcValueInFlightMsat: UInt64, // this is not MilliSatoshi because it can exceed the total amount of MilliSatoshi - channelReserve: Satoshi, - htlcMinimum: MilliSatoshi, - toSelfDelay: CltvExpiryDelta, - maxAcceptedHtlcs: Int, - isFunder: Boolean, - defaultFinalScriptPubKey: ByteVector, - walletStaticPaymentBasepoint: Option[PublicKey], - initFeatures: Features) +case class LocalParams(nodeId: PublicKey, + fundingKeyPath: DeterministicWallet.KeyPath, + dustLimit: Satoshi, + maxHtlcValueInFlightMsat: UInt64, // this is not MilliSatoshi because it can exceed the total amount of MilliSatoshi + channelReserve: Satoshi, + htlcMinimum: MilliSatoshi, + toSelfDelay: CltvExpiryDelta, + maxAcceptedHtlcs: Int, + isFunder: Boolean, + defaultFinalScriptPubKey: ByteVector, + walletStaticPaymentBasepoint: Option[PublicKey], + initFeatures: Features) /** * @param initFeatures see [[LocalParams.initFeatures]] */ -final case class RemoteParams(nodeId: PublicKey, - dustLimit: Satoshi, - maxHtlcValueInFlightMsat: UInt64, // this is not MilliSatoshi because it can exceed the total amount of MilliSatoshi - channelReserve: Satoshi, - htlcMinimum: MilliSatoshi, - toSelfDelay: CltvExpiryDelta, - maxAcceptedHtlcs: Int, - fundingPubKey: PublicKey, - revocationBasepoint: PublicKey, - paymentBasepoint: PublicKey, - delayedPaymentBasepoint: PublicKey, - htlcBasepoint: PublicKey, - initFeatures: Features, - shutdownScript: Option[ByteVector]) +case class RemoteParams(nodeId: PublicKey, + dustLimit: Satoshi, + maxHtlcValueInFlightMsat: UInt64, // this is not MilliSatoshi because it can exceed the total amount of MilliSatoshi + channelReserve: Satoshi, + htlcMinimum: MilliSatoshi, + toSelfDelay: CltvExpiryDelta, + maxAcceptedHtlcs: Int, + fundingPubKey: PublicKey, + revocationBasepoint: PublicKey, + paymentBasepoint: PublicKey, + delayedPaymentBasepoint: PublicKey, + htlcBasepoint: PublicKey, + initFeatures: Features, + shutdownScript: Option[ByteVector]) object ChannelFlags { val AnnounceChannel = 0x01.toByte diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala index 8dc1c8ac65..0613d0c3c3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala @@ -121,8 +121,8 @@ private class FinalTxPublisher(nodeParams: NodeParams, val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), "mempool-tx-monitor") txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), cmd.tx, cmd.input, cmd.desc, cmd.fee) Behaviors.receiveMessagePartial { - case WrappedTxResult(MempoolTxMonitor.TxConfirmed) => sendResult(replyTo, TxPublisher.TxConfirmed(cmd, cmd.tx)) - case WrappedTxResult(MempoolTxMonitor.TxRejected(reason)) => sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) + case WrappedTxResult(MempoolTxMonitor.TxConfirmed(tx)) => sendResult(replyTo, TxPublisher.TxConfirmed(cmd, tx)) + case WrappedTxResult(MempoolTxMonitor.TxRejected(_, reason)) => sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) case Stop => txMonitor ! MempoolTxMonitor.Stop Behaviors.stopped diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala index 7268fa2709..834de8d48d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala @@ -51,8 +51,8 @@ object MempoolTxMonitor { // @formatter:off sealed trait TxResult - case object TxConfirmed extends TxResult - case class TxRejected(reason: TxPublisher.TxRejectedReason) extends TxResult + case class TxConfirmed(tx: Transaction) extends TxResult + case class TxRejected(txid: ByteVector32, reason: TxPublisher.TxRejectedReason) extends TxResult // @formatter:on def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, loggingInfo: TxPublishLogContext): Behavior[Command] = { @@ -90,7 +90,7 @@ private class MempoolTxMonitor(nodeParams: NodeParams, bitcoinClient: BitcoinCor waitForConfirmation(replyTo, tx, input) case PublishFailed(reason) if reason.getMessage.contains("rejecting replacement") => log.info("could not publish tx: a conflicting mempool transaction is already in the mempool") - sendResult(replyTo, TxRejected(TxRejectedReason.ConflictingTxUnconfirmed)) + sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) case PublishFailed(reason) if reason.getMessage.contains("bad-txns-inputs-missingorspent") => // This can only happen if one of our inputs is already spent by a confirmed transaction or doesn't exist (e.g. // unconfirmed wallet input that has been replaced). @@ -98,21 +98,21 @@ private class MempoolTxMonitor(nodeParams: NodeParams, bitcoinClient: BitcoinCor Behaviors.same case PublishFailed(reason) => log.error("could not publish transaction", reason) - sendResult(replyTo, TxRejected(TxRejectedReason.UnknownTxFailure)) + sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.UnknownTxFailure)) case status: InputStatus => if (status.spentConfirmed) { log.info("could not publish tx: a conflicting transaction is already confirmed") - sendResult(replyTo, TxRejected(TxRejectedReason.ConflictingTxConfirmed)) + sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.ConflictingTxConfirmed)) } else if (status.spentUnconfirmed) { log.info("could not publish tx: a conflicting mempool transaction is already in the mempool") - sendResult(replyTo, TxRejected(TxRejectedReason.ConflictingTxUnconfirmed)) + sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) } else { log.info("could not publish tx: one of our wallet inputs is not available") - sendResult(replyTo, TxRejected(TxRejectedReason.WalletInputGone)) + sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.WalletInputGone)) } case CheckInputFailed(reason) => log.error("could not check input status", reason) - sendResult(replyTo, TxRejected(TxRejectedReason.TxSkipped(retryNextBlock = true))) // we act as if the input is potentially still spendable + sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true))) // we act as if the input is potentially still spendable case Stop => Behaviors.stopped } @@ -136,7 +136,7 @@ private class MempoolTxMonitor(nodeParams: NodeParams, bitcoinClient: BitcoinCor if (nodeParams.minDepthBlocks <= confirmations) { log.info("txid={} has reached min depth", tx.txid) context.system.eventStream ! EventStream.Publish(TransactionConfirmed(loggingInfo.channelId_opt.getOrElse(ByteVector32.Zeroes), loggingInfo.remoteNodeId, tx)) - sendResult(replyTo, TxConfirmed, Some(messageAdapter)) + sendResult(replyTo, TxConfirmed(tx), Some(messageAdapter)) } else { Behaviors.same } @@ -151,17 +151,17 @@ private class MempoolTxMonitor(nodeParams: NodeParams, bitcoinClient: BitcoinCor case status: InputStatus => if (status.spentConfirmed) { log.info("tx was evicted from the mempool: a conflicting transaction has been confirmed") - sendResult(replyTo, TxRejected(TxRejectedReason.ConflictingTxConfirmed)) + sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.ConflictingTxConfirmed)) } else if (status.spentUnconfirmed) { log.info("tx was evicted from the mempool: a conflicting transaction replaced it") - sendResult(replyTo, TxRejected(TxRejectedReason.ConflictingTxUnconfirmed)) + sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) } else { log.info("tx was evicted from the mempool: one of our wallet inputs disappeared") - sendResult(replyTo, TxRejected(TxRejectedReason.WalletInputGone)) + sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.WalletInputGone)) } case CheckInputFailed(reason) => log.error("could not check input status", reason) - sendResult(replyTo, TxRejected(TxRejectedReason.TxSkipped(retryNextBlock = true)), Some(messageAdapter)) + sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true)), Some(messageAdapter)) case Stop => context.system.eventStream ! EventStream.Unsubscribe(messageAdapter) Behaviors.stopped diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala index e070d84edd..76a24575c6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala @@ -16,11 +16,11 @@ package fr.acinq.eclair.channel.publish -import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler} +import akka.actor.typed.eventstream.EventStream +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} import akka.actor.typed.{ActorRef, Behavior} -import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.{ByteVector32, OutPoint, Satoshi, Script, Transaction, TxOut} -import fr.acinq.eclair.NodeParams +import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.FundTransactionOptions import fr.acinq.eclair.blockchain.fee.FeeratePerKw @@ -28,8 +28,8 @@ import fr.acinq.eclair.channel.Commitments import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher._ import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishLogContext import fr.acinq.eclair.transactions.Transactions._ +import fr.acinq.eclair.{NodeParams, NotificationsLogger} -import scala.concurrent.duration.DurationInt import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success} @@ -46,30 +46,32 @@ object ReplaceableTxFunder { // @formatter:off sealed trait Command - case class FundTransaction(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, txWithWitnessData: ReplaceableTxWithWitnessData, targetFeerate: FeeratePerKw) extends Command - case object Stop extends Command + case class FundTransaction(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, tx: Either[FundedTx, ReplaceableTxWithWitnessData], targetFeerate: FeeratePerKw) extends Command - private case class AddInputsOk(tx: ReplaceableTxWithWitnessData, fee: Satoshi) extends Command + private case class AddInputsOk(tx: ReplaceableTxWithWitnessData, totalAmountIn: Satoshi) extends Command private case class AddInputsFailed(reason: Throwable) extends Command private case class SignWalletInputsOk(signedTx: Transaction) extends Command private case class SignWalletInputsFailed(reason: Throwable) extends Command private case object UtxosUnlocked extends Command // @formatter:on + case class FundedTx(signedTxWithWitnessData: ReplaceableTxWithWitnessData, totalAmountIn: Satoshi, feerate: FeeratePerKw) { + require(signedTxWithWitnessData.txInfo.tx.txIn.nonEmpty, "funded transaction must have inputs") + require(signedTxWithWitnessData.txInfo.tx.txOut.nonEmpty, "funded transaction must have outputs") + val signedTx: Transaction = signedTxWithWitnessData.txInfo.tx + val fee: Satoshi = totalAmountIn - signedTx.txOut.map(_.amount).sum + } + // @formatter:off sealed trait FundingResult + case class TransactionReady(fundedTx: FundedTx) extends FundingResult case class FundingFailed(reason: TxPublisher.TxRejectedReason) extends FundingResult - case class TransactionReady(tx: Transaction, amountIn: Satoshi) extends FundingResult { - val fee: Satoshi = amountIn - tx.txOut.map(_.amount).sum - } // @formatter:on def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, loggingInfo: TxPublishLogContext): Behavior[Command] = { Behaviors.setup { context => - Behaviors.withTimers { timers => - Behaviors.withMdc(loggingInfo.mdc()) { - new ReplaceableTxFunder(nodeParams, bitcoinClient, context, timers).start() - } + Behaviors.withMdc(loggingInfo.mdc()) { + new ReplaceableTxFunder(nodeParams, bitcoinClient, context).start() } } } @@ -79,7 +81,7 @@ object ReplaceableTxFunder { * We need this because fundrawtransaction doesn't allow us to leave non-wallet inputs, so we have to add them * afterwards which may bring the resulting feerate below our target. */ - def adjustAnchorOutputChange(unsignedTx: ClaimLocalAnchorWithWitnessData, commitTx: Transaction, amountIn: Satoshi, commitFeerate: FeeratePerKw, targetFeerate: FeeratePerKw, dustLimit: Satoshi): (ClaimLocalAnchorWithWitnessData, Satoshi) = { + def adjustAnchorOutputChange(unsignedTx: ClaimLocalAnchorWithWitnessData, commitTx: Transaction, amountIn: Satoshi, commitFeerate: FeeratePerKw, targetFeerate: FeeratePerKw, dustLimit: Satoshi): ClaimLocalAnchorWithWitnessData = { require(unsignedTx.txInfo.tx.txOut.size == 1, "funded transaction should have a single change output") // We take into account witness weight and adjust the fee to match our desired feerate. val dummySignedClaimAnchorTx = addSigs(unsignedTx.txInfo, PlaceHolderSig) @@ -87,9 +89,13 @@ object ReplaceableTxFunder { val estimatedWeight = commitTx.weight() + dummySignedClaimAnchorTx.tx.weight() + claimP2WPKHOutputWitnessWeight * (dummySignedClaimAnchorTx.tx.txIn.size - 1) val targetFee = weight2fee(targetFeerate, estimatedWeight) - weight2fee(commitFeerate, commitTx.weight()) val amountOut = dustLimit.max(amountIn - targetFee) - val updatedAnchorTx = unsignedTx.modify(_.txInfo.tx.txOut).setTo(Seq(unsignedTx.txInfo.tx.txOut.head.copy(amount = amountOut))) - val fee = amountIn - updatedAnchorTx.txInfo.tx.txOut.map(_.amount).sum - (updatedAnchorTx, fee) + val updatedAnchorTx = unsignedTx.updateTx(unsignedTx.txInfo.tx.copy(txOut = Seq(unsignedTx.txInfo.tx.txOut.head.copy(amount = amountOut)))) + updatedAnchorTx + } + + private def dummySignedCommitTx(commitments: Commitments): CommitTx = { + val unsignedCommitTx = commitments.localCommit.commitTxAndRemoteSig.commitTx + addSigs(unsignedCommitTx, PlaceHolderPubKey, PlaceHolderPubKey, PlaceHolderSig, PlaceHolderSig) } /** @@ -97,29 +103,22 @@ object ReplaceableTxFunder { * We need this because fundrawtransaction doesn't allow us to leave non-wallet inputs, so we have to add them * afterwards which may bring the resulting feerate below our target. */ - def adjustHtlcTxChange(unsignedTx: HtlcWithWitnessData, amountIn: Satoshi, targetFeerate: FeeratePerKw, commitments: Commitments): (HtlcWithWitnessData, Satoshi) = { + def adjustHtlcTxChange(unsignedTx: HtlcWithWitnessData, amountIn: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): HtlcWithWitnessData = { require(unsignedTx.txInfo.tx.txOut.size <= 2, "funded transaction should have at most one change output") val dummySignedTx = unsignedTx.txInfo match { - case tx: HtlcSuccessTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, ByteVector32.Zeroes, commitments.commitmentFormat) - case tx: HtlcTimeoutTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, commitments.commitmentFormat) + case tx: HtlcSuccessTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, ByteVector32.Zeroes, commitmentFormat) + case tx: HtlcTimeoutTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, commitmentFormat) } // We adjust the change output to obtain the targeted feerate. val estimatedWeight = dummySignedTx.tx.weight() + claimP2WPKHOutputWitnessWeight * (dummySignedTx.tx.txIn.size - 1) val targetFee = weight2fee(targetFeerate, estimatedWeight) val changeAmount = amountIn - dummySignedTx.tx.txOut.head.amount - targetFee - val updatedHtlcTx = if (dummySignedTx.tx.txOut.length == 2 && changeAmount >= commitments.localParams.dustLimit) { - unsignedTx match { - case htlcSuccess: HtlcSuccessWithWitnessData => htlcSuccess.modify(_.txInfo.tx.txOut).setTo(Seq(htlcSuccess.txInfo.tx.txOut.head, htlcSuccess.txInfo.tx.txOut(1).copy(amount = changeAmount))) - case htlcTimeout: HtlcTimeoutWithWitnessData => htlcTimeout.modify(_.txInfo.tx.txOut).setTo(Seq(htlcTimeout.txInfo.tx.txOut.head, htlcTimeout.txInfo.tx.txOut(1).copy(amount = changeAmount))) - } + val updatedHtlcTx = if (dummySignedTx.tx.txOut.length == 2 && changeAmount >= dustLimit) { + unsignedTx.updateTx(unsignedTx.txInfo.tx.copy(txOut = Seq(unsignedTx.txInfo.tx.txOut.head, unsignedTx.txInfo.tx.txOut.last.copy(amount = changeAmount)))) } else { - unsignedTx match { - case htlcSuccess: HtlcSuccessWithWitnessData => htlcSuccess.modify(_.txInfo.tx.txOut).setTo(Seq(htlcSuccess.txInfo.tx.txOut.head)) - case htlcTimeout: HtlcTimeoutWithWitnessData => htlcTimeout.modify(_.txInfo.tx.txOut).setTo(Seq(htlcTimeout.txInfo.tx.txOut.head)) - } + unsignedTx.updateTx(unsignedTx.txInfo.tx.copy(txOut = Seq(unsignedTx.txInfo.tx.txOut.head))) } - val fee = amountIn - updatedHtlcTx.txInfo.tx.txOut.map(_.amount).sum - (updatedHtlcTx, fee) + updatedHtlcTx } /** @@ -135,23 +134,96 @@ object ReplaceableTxFunder { case tx: LegacyClaimHtlcSuccessTx => tx } val targetFee = weight2fee(targetFeerate, dummySignedTx.tx.weight()) - val outputAmount = claimHtlcTx.txInfo.input.txOut.amount - targetFee + val outputAmount = claimHtlcTx.txInfo.amountIn - targetFee if (outputAmount < dustLimit) { Left(AmountBelowDustLimit) } else { val updatedClaimHtlcTx = claimHtlcTx match { - case claimHtlcSuccess: ClaimHtlcSuccessWithWitnessData => claimHtlcSuccess.modify(_.txInfo.tx.txOut).setTo(Seq(claimHtlcSuccess.txInfo.tx.txOut.head.copy(amount = outputAmount))) - case claimHtlcTimeout: ClaimHtlcTimeoutWithWitnessData => claimHtlcTimeout.modify(_.txInfo.tx.txOut).setTo(Seq(claimHtlcTimeout.txInfo.tx.txOut.head.copy(amount = outputAmount))) // NB: we don't modify legacy claim-htlc-success, it's already signed. case legacyClaimHtlcSuccess: LegacyClaimHtlcSuccessWithWitnessData => legacyClaimHtlcSuccess + case _ => claimHtlcTx.updateTx(claimHtlcTx.txInfo.tx.copy(txOut = Seq(claimHtlcTx.txInfo.tx.txOut.head.copy(amount = outputAmount)))) } Right(updatedClaimHtlcTx) } } + // @formatter:off + sealed trait AdjustPreviousTxOutputResult + object AdjustPreviousTxOutputResult { + case class Skip(reason: String) extends AdjustPreviousTxOutputResult + case class AddWalletInputs(previousTx: ReplaceableTxWithWalletInputs) extends AdjustPreviousTxOutputResult + case class TxOutputAdjusted(updatedTx: ReplaceableTxWithWitnessData) extends AdjustPreviousTxOutputResult + } + // @formatter:on + + /** + * Adjust the outputs of a transaction that was previously published at a lower feerate. + * If the current set of inputs doesn't let us to reach the target feerate, we should request new wallet inputs from bitcoind. + */ + def adjustPreviousTxOutput(previousTx: FundedTx, targetFeerate: FeeratePerKw, commitments: Commitments): AdjustPreviousTxOutputResult = { + val dustLimit = commitments.localParams.dustLimit + val targetFee = previousTx.signedTxWithWitnessData match { + case _: ClaimLocalAnchorWithWitnessData => + val commitTx = dummySignedCommitTx(commitments) + val totalWeight = previousTx.signedTx.weight() + commitTx.tx.weight() + weight2fee(targetFeerate, totalWeight) - commitTx.fee + case _ => weight2fee(targetFeerate, previousTx.signedTx.weight()) + } + previousTx.signedTxWithWitnessData match { + case claimLocalAnchor: ClaimLocalAnchorWithWitnessData => + val changeAmount = previousTx.totalAmountIn - targetFee + if (changeAmount < dustLimit) { + AdjustPreviousTxOutputResult.AddWalletInputs(claimLocalAnchor) + } else { + val updatedTxOut = Seq(claimLocalAnchor.txInfo.tx.txOut.head.copy(amount = changeAmount)) + AdjustPreviousTxOutputResult.TxOutputAdjusted(claimLocalAnchor.updateTx(claimLocalAnchor.txInfo.tx.copy(txOut = updatedTxOut))) + } + case htlcTx: HtlcWithWitnessData => + if (htlcTx.txInfo.tx.txOut.length <= 1) { + // There is no change output, so we can't increase the fees without adding new inputs. + AdjustPreviousTxOutputResult.AddWalletInputs(htlcTx) + } else { + val htlcAmount = htlcTx.txInfo.tx.txOut.head.amount + val changeAmount = previousTx.totalAmountIn - targetFee - htlcAmount + if (dustLimit <= changeAmount) { + val updatedTxOut = Seq(htlcTx.txInfo.tx.txOut.head, htlcTx.txInfo.tx.txOut.last.copy(amount = changeAmount)) + AdjustPreviousTxOutputResult.TxOutputAdjusted(htlcTx.updateTx(htlcTx.txInfo.tx.copy(txOut = updatedTxOut))) + } else { + // We try removing the change output to see if it provides a high enough feerate. + val htlcTxNoChange = htlcTx.updateTx(htlcTx.txInfo.tx.copy(txOut = Seq(htlcTx.txInfo.tx.txOut.head))) + val fee = previousTx.totalAmountIn - htlcAmount + if (fee <= htlcAmount) { + val feerate = fee2rate(fee, htlcTxNoChange.txInfo.tx.weight()) + if (targetFeerate <= feerate) { + // Without the change output, we're able to reach our desired feerate. + AdjustPreviousTxOutputResult.TxOutputAdjusted(htlcTxNoChange) + } else { + // Even without the change output, the feerate is too low: we must add new wallet inputs. + AdjustPreviousTxOutputResult.AddWalletInputs(htlcTx) + } + } else { + AdjustPreviousTxOutputResult.Skip("fee higher than htlc amount") + } + } + } + case claimHtlcTx: ClaimHtlcWithWitnessData => + val updatedAmount = previousTx.totalAmountIn - targetFee + if (updatedAmount < dustLimit) { + AdjustPreviousTxOutputResult.Skip("fee higher than htlc amount") + } else { + val updatedTxOut = Seq(claimHtlcTx.txInfo.tx.txOut.head.copy(amount = updatedAmount)) + claimHtlcTx match { + // NB: we don't modify legacy claim-htlc-success, it's already signed. + case _: LegacyClaimHtlcSuccessWithWitnessData => AdjustPreviousTxOutputResult.Skip("legacy claim-htlc-success should not be updated") + case _ => AdjustPreviousTxOutputResult.TxOutputAdjusted(claimHtlcTx.updateTx(claimHtlcTx.txInfo.tx.copy(txOut = updatedTxOut))) + } + } + } + } + } -private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, context: ActorContext[ReplaceableTxFunder.Command], timers: TimerScheduler[ReplaceableTxFunder.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { +private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, context: ActorContext[ReplaceableTxFunder.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { import ReplaceableTxFunder._ import nodeParams.{channelKeyManager => keyManager} @@ -160,15 +232,17 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin def start(): Behavior[Command] = { Behaviors.receiveMessagePartial { - case FundTransaction(replyTo, cmd, txWithWitnessData, targetFeerate) => fund(replyTo, cmd, txWithWitnessData, targetFeerate) - case Stop => Behaviors.stopped + case FundTransaction(replyTo, cmd, tx, targetFeerate) => tx match { + case Right(txWithWitnessData) => fund(replyTo, cmd, txWithWitnessData, targetFeerate) + case Left(previousTx) => bump(replyTo, cmd, previousTx, targetFeerate) + } } } def fund(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, txWithWitnessData: ReplaceableTxWithWitnessData, targetFeerate: FeeratePerKw): Behavior[Command] = { - val commitFeerate = cmd.commitments.localCommit.spec.commitTxFeerate txWithWitnessData match { - case claimLocalAnchor: ReplaceableTxPrePublisher.ClaimLocalAnchorWithWitnessData => + case claimLocalAnchor: ClaimLocalAnchorWithWitnessData => + val commitFeerate = cmd.commitments.localCommit.spec.commitTxFeerate if (targetFeerate <= commitFeerate) { log.info("skipping {}: commit feerate is high enough (feerate={})", cmd.desc, commitFeerate) // We set retry = true in case the on-chain feerate rises before the commit tx is confirmed: if that happens @@ -178,14 +252,15 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin } else { addWalletInputs(replyTo, cmd, claimLocalAnchor, targetFeerate) } - case htlcTx: ReplaceableTxPrePublisher.HtlcWithWitnessData => - if (targetFeerate <= commitFeerate) { + case htlcTx: HtlcWithWitnessData => + val htlcFeerate = cmd.commitments.localCommit.spec.htlcTxFeerate(cmd.commitments.commitmentFormat) + if (targetFeerate <= htlcFeerate) { log.info("publishing {} without adding inputs: txid={}", cmd.desc, htlcTx.txInfo.tx.txid) - sign(replyTo, cmd, txWithWitnessData, htlcTx.txInfo.fee) + sign(replyTo, cmd, txWithWitnessData, htlcFeerate, htlcTx.txInfo.amountIn) } else { addWalletInputs(replyTo, cmd, htlcTx, targetFeerate) } - case claimHtlcTx: ReplaceableTxPrePublisher.ClaimHtlcWithWitnessData => + case claimHtlcTx: ClaimHtlcWithWitnessData => adjustClaimHtlcTxOutput(claimHtlcTx, targetFeerate, cmd.commitments.localParams.dustLimit) match { case Left(reason) => // The htlc isn't economical to claim at the current feerate, but if the feerate goes down, we may want to claim it later. @@ -193,43 +268,60 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin replyTo ! FundingFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) Behaviors.stopped case Right(updatedClaimHtlcTx) => - sign(replyTo, cmd, updatedClaimHtlcTx, updatedClaimHtlcTx.txInfo.fee) + sign(replyTo, cmd, updatedClaimHtlcTx, targetFeerate, updatedClaimHtlcTx.txInfo.amountIn) } } } + def bump(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, previousTx: FundedTx, targetFeerate: FeeratePerKw): Behavior[Command] = { + adjustPreviousTxOutput(previousTx, targetFeerate, cmd.commitments) match { + case AdjustPreviousTxOutputResult.Skip(reason) => + log.warn("skipping {} fee bumping: {} (feerate={})", cmd.desc, reason, targetFeerate) + replyTo ! FundingFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) + Behaviors.stopped + case AdjustPreviousTxOutputResult.TxOutputAdjusted(updatedTx) => + log.debug("bumping {} fees without adding new inputs: txid={}", cmd.desc, updatedTx.txInfo.tx.txid) + sign(replyTo, cmd, updatedTx, targetFeerate, previousTx.totalAmountIn) + case AdjustPreviousTxOutputResult.AddWalletInputs(tx) => + log.debug("bumping {} fees requires adding new inputs (feerate={})", cmd.desc, targetFeerate) + // We restore the original transaction (remove previous attempt's wallet inputs). + val resetTx = tx.updateTx(cmd.txInfo.tx) + addWalletInputs(replyTo, cmd, resetTx, targetFeerate) + } + } + def addWalletInputs(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, txWithWitnessData: ReplaceableTxWithWalletInputs, targetFeerate: FeeratePerKw): Behavior[Command] = { context.pipeToSelf(addInputs(txWithWitnessData, targetFeerate, cmd.commitments)) { - case Success((fundedTx, fee)) => AddInputsOk(fundedTx, fee) + case Success((fundedTx, totalAmountIn)) => AddInputsOk(fundedTx, totalAmountIn) case Failure(reason) => AddInputsFailed(reason) } Behaviors.receiveMessagePartial { - case AddInputsOk(fundedTx, fee) => + case AddInputsOk(fundedTx, totalAmountIn) => log.info("added {} wallet input(s) and {} wallet output(s) to {}", fundedTx.txInfo.tx.txIn.length - 1, fundedTx.txInfo.tx.txOut.length - 1, cmd.desc) - sign(replyTo, cmd, fundedTx, fee) + sign(replyTo, cmd, fundedTx, targetFeerate, totalAmountIn) case AddInputsFailed(reason) => if (reason.getMessage.contains("Insufficient funds")) { + val nodeOperatorMessage = + s"""Insufficient funds in bitcoin wallet to set feerate=$targetFeerate for ${cmd.desc}. + |You should add more utxos to your bitcoin wallet to guarantee funds safety. + |""".stripMargin + context.system.eventStream ! EventStream.Publish(NotifyNodeOperator(NotificationsLogger.Warning, nodeOperatorMessage)) log.warn("cannot add inputs to {}: {}", cmd.desc, reason.getMessage) } else { log.error("cannot add inputs to {}: {}", cmd.desc, reason) } replyTo ! FundingFailed(TxPublisher.TxRejectedReason.CouldNotFund) Behaviors.stopped - case Stop => - // We've asked bitcoind to lock utxos, so we can't stop right now without unlocking them. - // Since we don't know yet what utxos have been locked, we defer the message. - timers.startSingleTimer(Stop, 1 second) - Behaviors.same } } - def sign(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, fundedTx: ReplaceableTxWithWitnessData, fee: Satoshi): Behavior[Command] = { + def sign(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, fundedTx: ReplaceableTxWithWitnessData, txFeerate: FeeratePerKw, amountIn: Satoshi): Behavior[Command] = { val channelKeyPath = keyManager.keyPath(cmd.commitments.localParams, cmd.commitments.channelConfig) fundedTx match { case ClaimLocalAnchorWithWitnessData(anchorTx) => val localSig = keyManager.sign(anchorTx, keyManager.fundingPublicKey(cmd.commitments.localParams.fundingKeyPath), TxOwner.Local, cmd.commitments.commitmentFormat) val signedTx = ClaimLocalAnchorWithWitnessData(addSigs(anchorTx, localSig)) - signWalletInputs(replyTo, cmd, signedTx, fee) + signWalletInputs(replyTo, cmd, signedTx, txFeerate, amountIn) case htlcTx: HtlcWithWitnessData => val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, cmd.commitments.localCommit.index) val localHtlcBasepoint = keyManager.htlcPoint(channelKeyPath) @@ -240,24 +332,24 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin } val hasWalletInputs = htlcTx.txInfo.tx.txIn.size > 1 if (hasWalletInputs) { - signWalletInputs(replyTo, cmd, signedTx, fee) + signWalletInputs(replyTo, cmd, signedTx, txFeerate, amountIn) } else { - replyTo ! TransactionReady(signedTx.txInfo.tx, signedTx.txInfo.input.txOut.amount) + replyTo ! TransactionReady(FundedTx(signedTx, amountIn, txFeerate)) Behaviors.stopped } case claimHtlcTx: ClaimHtlcWithWitnessData => val sig = keyManager.sign(claimHtlcTx.txInfo, keyManager.htlcPoint(channelKeyPath), cmd.commitments.remoteCommit.remotePerCommitmentPoint, TxOwner.Local, cmd.commitments.commitmentFormat) val signedTx = claimHtlcTx match { - case ClaimHtlcSuccessWithWitnessData(txInfo, preimage) => addSigs(txInfo, sig, preimage) - case LegacyClaimHtlcSuccessWithWitnessData(txInfo, _) => txInfo - case ClaimHtlcTimeoutWithWitnessData(txInfo) => addSigs(txInfo, sig) + case ClaimHtlcSuccessWithWitnessData(txInfo, preimage) => ClaimHtlcSuccessWithWitnessData(addSigs(txInfo, sig, preimage), preimage) + case legacyClaimHtlcSuccess: LegacyClaimHtlcSuccessWithWitnessData => legacyClaimHtlcSuccess + case ClaimHtlcTimeoutWithWitnessData(txInfo) => ClaimHtlcTimeoutWithWitnessData(addSigs(txInfo, sig)) } - replyTo ! TransactionReady(signedTx.tx, signedTx.input.txOut.amount) + replyTo ! TransactionReady(FundedTx(signedTx, amountIn, txFeerate)) Behaviors.stopped } } - def signWalletInputs(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, locallySignedTx: ReplaceableTxWithWalletInputs, fee: Satoshi): Behavior[Command] = { + def signWalletInputs(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, locallySignedTx: ReplaceableTxWithWalletInputs, txFeerate: FeeratePerKw, amountIn: Satoshi): Behavior[Command] = { locallySignedTx match { case ClaimLocalAnchorWithWitnessData(anchorTx) => val commitInfo = BitcoinCoreClient.PreviousTx(anchorTx.input, anchorTx.tx.txIn.head.witness) @@ -278,32 +370,26 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin } Behaviors.receiveMessagePartial { case SignWalletInputsOk(signedTx) => - val amountIn = fee + signedTx.txOut.map(_.amount).sum - replyTo ! TransactionReady(signedTx, amountIn) + val fullySignedTx = locallySignedTx.updateTx(signedTx) + replyTo ! TransactionReady(FundedTx(fullySignedTx, amountIn, txFeerate)) Behaviors.stopped case SignWalletInputsFailed(reason) => log.error("cannot sign {}: {}", cmd.desc, reason) // We reply with the failure only once the utxos are unlocked, otherwise there is a risk that our parent stops // itself, which will automatically stop us before we had a chance to unlock them. - unlockAndStop(replyTo, locallySignedTx.txInfo.input.outPoint, locallySignedTx.txInfo.tx, Some(TxPublisher.TxRejectedReason.UnknownTxFailure)) - case Stop => - // We have added wallet inputs, we need to unlock them before stopping. - unlockAndStop(replyTo, locallySignedTx.txInfo.input.outPoint, locallySignedTx.txInfo.tx, None) + unlockAndStop(replyTo, locallySignedTx.txInfo.input.outPoint, locallySignedTx.txInfo.tx, TxPublisher.TxRejectedReason.UnknownTxFailure) } } - def unlockAndStop(replyTo: ActorRef[FundingResult], input: OutPoint, tx: Transaction, failure_opt: Option[TxPublisher.TxRejectedReason]): Behavior[Command] = { + def unlockAndStop(replyTo: ActorRef[FundingResult], input: OutPoint, tx: Transaction, failure: TxPublisher.TxRejectedReason): Behavior[Command] = { val toUnlock = tx.txIn.filterNot(_.outPoint == input).map(_.outPoint) log.debug("unlocking utxos={}", toUnlock.mkString(", ")) context.pipeToSelf(bitcoinClient.unlockOutpoints(toUnlock))(_ => UtxosUnlocked) Behaviors.receiveMessagePartial { case UtxosUnlocked => log.debug("utxos unlocked") - failure_opt.foreach(failure => replyTo ! FundingFailed(failure)) + replyTo ! FundingFailed(failure) Behaviors.stopped - case Stop => - log.debug("waiting for utxos to be unlocked before stopping") - Behaviors.same } } @@ -317,7 +403,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin private def addInputs(anchorTx: ClaimLocalAnchorWithWitnessData, targetFeerate: FeeratePerKw, commitments: Commitments): Future[(ClaimLocalAnchorWithWitnessData, Satoshi)] = { val dustLimit = commitments.localParams.dustLimit val commitFeerate = commitments.localCommit.spec.commitTxFeerate - val commitTx = commitments.fullySignedLocalCommitTx(nodeParams.channelKeyManager).tx + val commitTx = dummySignedCommitTx(commitments).tx // We want the feerate of the package (commit tx + tx spending anchor) to equal targetFeerate. // Thus we have: anchorFeerate = targetFeerate + (weight-commit-tx / weight-anchor-tx) * (targetFeerate - commitTxFeerate) // If we use the smallest weight possible for the anchor tx, the feerate we use will thus be greater than what we want, @@ -350,8 +436,9 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin }).map(fundTxResponse => { require(fundTxResponse.tx.txOut.size == 1, "funded transaction should have a single change output") // NB: we insert the anchor input in the *first* position because our signing helpers only sign input #0. - val unsignedTx = anchorTx.modify(_.txInfo.tx).setTo(fundTxResponse.tx.copy(txIn = anchorTx.txInfo.tx.txIn.head +: fundTxResponse.tx.txIn)) - adjustAnchorOutputChange(unsignedTx, commitTx, fundTxResponse.amountIn + AnchorOutputsCommitmentFormat.anchorAmount, commitFeerate, targetFeerate, dustLimit) + val unsignedTx = anchorTx.updateTx(fundTxResponse.tx.copy(txIn = anchorTx.txInfo.tx.txIn.head +: fundTxResponse.tx.txIn)) + val totalAmountIn = fundTxResponse.amountIn + AnchorOutputsCommitmentFormat.anchorAmount + (adjustAnchorOutputChange(unsignedTx, commitTx, totalAmountIn, commitFeerate, targetFeerate, dustLimit), totalAmountIn) }) } @@ -381,11 +468,9 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin txIn = htlcTx.txInfo.tx.txIn ++ fundTxResponse.tx.txIn, txOut = htlcTx.txInfo.tx.txOut ++ fundTxResponse.tx.txOut.tail ) - val unsignedTx = htlcTx match { - case htlcSuccess: HtlcSuccessWithWitnessData => htlcSuccess.modify(_.txInfo.tx).setTo(txWithHtlcInput) - case htlcTimeout: HtlcTimeoutWithWitnessData => htlcTimeout.modify(_.txInfo.tx).setTo(txWithHtlcInput) - } - adjustHtlcTxChange(unsignedTx, fundTxResponse.amountIn + unsignedTx.txInfo.input.txOut.amount, targetFeerate, commitments) + val unsignedTx = htlcTx.updateTx(txWithHtlcInput) + val totalAmountIn = fundTxResponse.amountIn + unsignedTx.txInfo.amountIn + (adjustHtlcTxChange(unsignedTx, totalAmountIn, targetFeerate, commitments.localParams.dustLimit, commitments.commitmentFormat), totalAmountIn) }) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala index ab307ec2a9..4d588aade4 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala @@ -18,7 +18,7 @@ package fr.acinq.eclair.channel.publish import akka.actor.typed.scaladsl.{ActorContext, Behaviors} import akka.actor.typed.{ActorRef, Behavior} -import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Crypto} +import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Crypto, Transaction} import fr.acinq.eclair.NodeParams import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishLogContext @@ -60,17 +60,40 @@ object ReplaceableTxPrePublisher { case class PreconditionsFailed(reason: TxPublisher.TxRejectedReason) extends PreconditionsResult /** Replaceable transaction with all the witness data necessary to finalize. */ - sealed trait ReplaceableTxWithWitnessData { def txInfo: ReplaceableTransactionWithInputInfo } + sealed trait ReplaceableTxWithWitnessData { + def txInfo: ReplaceableTransactionWithInputInfo + def updateTx(tx: Transaction): ReplaceableTxWithWitnessData + } /** Replaceable transaction for which we may need to add wallet inputs. */ - sealed trait ReplaceableTxWithWalletInputs extends ReplaceableTxWithWitnessData - case class ClaimLocalAnchorWithWitnessData(txInfo: ClaimLocalAnchorOutputTx) extends ReplaceableTxWithWalletInputs - sealed trait HtlcWithWitnessData extends ReplaceableTxWithWalletInputs { override def txInfo: HtlcTx } - case class HtlcSuccessWithWitnessData(txInfo: HtlcSuccessTx, remoteSig: ByteVector64, preimage: ByteVector32) extends HtlcWithWitnessData - case class HtlcTimeoutWithWitnessData(txInfo: HtlcTimeoutTx, remoteSig: ByteVector64) extends HtlcWithWitnessData - sealed trait ClaimHtlcWithWitnessData extends ReplaceableTxWithWitnessData { override def txInfo: ClaimHtlcTx } - case class ClaimHtlcSuccessWithWitnessData(txInfo: ClaimHtlcSuccessTx, preimage: ByteVector32) extends ClaimHtlcWithWitnessData - case class LegacyClaimHtlcSuccessWithWitnessData(txInfo: LegacyClaimHtlcSuccessTx, preimage: ByteVector32) extends ClaimHtlcWithWitnessData - case class ClaimHtlcTimeoutWithWitnessData(txInfo: ClaimHtlcTimeoutTx) extends ClaimHtlcWithWitnessData + sealed trait ReplaceableTxWithWalletInputs extends ReplaceableTxWithWitnessData { + override def updateTx(tx: Transaction): ReplaceableTxWithWalletInputs + } + case class ClaimLocalAnchorWithWitnessData(txInfo: ClaimLocalAnchorOutputTx) extends ReplaceableTxWithWalletInputs { + override def updateTx(tx: Transaction): ClaimLocalAnchorWithWitnessData = this.copy(txInfo = this.txInfo.copy(tx = tx)) + } + sealed trait HtlcWithWitnessData extends ReplaceableTxWithWalletInputs { + override def txInfo: HtlcTx + override def updateTx(tx: Transaction): HtlcWithWitnessData + } + case class HtlcSuccessWithWitnessData(txInfo: HtlcSuccessTx, remoteSig: ByteVector64, preimage: ByteVector32) extends HtlcWithWitnessData { + override def updateTx(tx: Transaction): HtlcSuccessWithWitnessData = this.copy(txInfo = this.txInfo.copy(tx = tx)) + } + case class HtlcTimeoutWithWitnessData(txInfo: HtlcTimeoutTx, remoteSig: ByteVector64) extends HtlcWithWitnessData { + override def updateTx(tx: Transaction): HtlcTimeoutWithWitnessData = this.copy(txInfo = this.txInfo.copy(tx = tx)) + } + sealed trait ClaimHtlcWithWitnessData extends ReplaceableTxWithWitnessData { + override def txInfo: ClaimHtlcTx + override def updateTx(tx: Transaction): ClaimHtlcWithWitnessData + } + case class ClaimHtlcSuccessWithWitnessData(txInfo: ClaimHtlcSuccessTx, preimage: ByteVector32) extends ClaimHtlcWithWitnessData { + override def updateTx(tx: Transaction): ClaimHtlcSuccessWithWitnessData = this.copy(txInfo = this.txInfo.copy(tx = tx)) + } + case class LegacyClaimHtlcSuccessWithWitnessData(txInfo: LegacyClaimHtlcSuccessTx, preimage: ByteVector32) extends ClaimHtlcWithWitnessData { + override def updateTx(tx: Transaction): LegacyClaimHtlcSuccessWithWitnessData = this.copy(txInfo = this.txInfo.copy(tx = tx)) + } + case class ClaimHtlcTimeoutWithWitnessData(txInfo: ClaimHtlcTimeoutTx) extends ClaimHtlcWithWitnessData { + override def updateTx(tx: Transaction): ClaimHtlcTimeoutWithWitnessData = this.copy(txInfo = this.txInfo.copy(tx = tx)) + } // @formatter:on def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, loggingInfo: TxPublishLogContext): Behavior[Command] = { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index 00ae4c54ec..12e08a36af 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -16,17 +16,22 @@ package fr.acinq.eclair.channel.publish -import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.eventstream.EventStream +import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler} import akka.actor.typed.{ActorRef, Behavior} -import fr.acinq.bitcoin.{OutPoint, Satoshi, Transaction} +import fr.acinq.bitcoin.{OutPoint, Transaction} import fr.acinq.eclair.NodeParams +import fr.acinq.eclair.blockchain.CurrentBlockCount import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.blockchain.fee.{FeeEstimator, FeeratePerKw} +import fr.acinq.eclair.channel.publish.ReplaceableTxFunder.FundedTx import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher.{ClaimLocalAnchorWithWitnessData, ReplaceableTxWithWitnessData} -import fr.acinq.eclair.channel.publish.TxPublisher.{TxPublishLogContext, TxRejectedReason} +import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishLogContext -import scala.concurrent.ExecutionContext +import scala.concurrent.duration.{DurationInt, DurationLong} +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Random, Success} /** * Created by t-bast on 10/06/2021. @@ -34,6 +39,7 @@ import scala.concurrent.ExecutionContext /** * This actor sets the fees, signs and publishes a transaction that can be RBF-ed. + * It regularly RBFs the transaction as we get closer to its deadline. * It waits for confirmation or failure before reporting back to the requesting actor. */ object ReplaceableTxPublisher { @@ -47,13 +53,24 @@ object ReplaceableTxPublisher { private case object TimeLocksOk extends Command private case class WrappedFundingResult(result: ReplaceableTxFunder.FundingResult) extends Command private case class WrappedTxResult(result: MempoolTxMonitor.TxResult) extends Command + private case class WrappedCurrentBlockCount(currentBlockCount: Long) extends Command + private case class CheckFee(currentBlockCount: Long) extends Command + private case class BumpFee(targetFeerate: FeeratePerKw) extends Command + private case object Stay extends Command + private case object UnlockUtxos extends Command private case object UtxosUnlocked extends Command + + // Keys to ensure we don't have multiple concurrent timers running. + private case object CheckFeeKey + private case object CurrentBlockCountKey // @formatter:on def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, watcher: ActorRef[ZmqWatcher.Command], loggingInfo: TxPublishLogContext): Behavior[Command] = { Behaviors.setup { context => - Behaviors.withMdc(loggingInfo.mdc()) { - new ReplaceableTxPublisher(nodeParams, bitcoinClient, watcher, context, loggingInfo).start() + Behaviors.withTimers { timers => + Behaviors.withMdc(loggingInfo.mdc()) { + new ReplaceableTxPublisher(nodeParams, bitcoinClient, watcher, context, timers, loggingInfo).start() + } } } } @@ -76,7 +93,7 @@ object ReplaceableTxPublisher { } -private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, watcher: ActorRef[ZmqWatcher.Command], context: ActorContext[ReplaceableTxPublisher.Command], loggingInfo: TxPublishLogContext)(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { +private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, watcher: ActorRef[ZmqWatcher.Command], context: ActorContext[ReplaceableTxPublisher.Command], timers: TimerScheduler[ReplaceableTxPublisher.Command], loggingInfo: TxPublishLogContext)(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { import ReplaceableTxPublisher._ @@ -123,55 +140,189 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc def fund(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = { val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.deadline, nodeParams.currentBlockHeight) val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, loggingInfo), "tx-funder") - txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, txWithWitnessData, targetFeerate) + txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, Right(txWithWitnessData), targetFeerate) Behaviors.receiveMessagePartial { case WrappedFundingResult(result) => result match { - case success: ReplaceableTxFunder.TransactionReady => publish(replyTo, cmd, success.tx, success.fee) + case success: ReplaceableTxFunder.TransactionReady => publish(replyTo, cmd, success.fundedTx) case ReplaceableTxFunder.FundingFailed(reason) => sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) } case Stop => - // We don't stop right away, because the child actor may need to unlock utxos first. - // If the child actor has already been terminated, this will emit the event instantly. - context.watchWith(txFunder, UtxosUnlocked) - txFunder ! ReplaceableTxFunder.Stop + // We can't stop right now, the child actor is currently funding the transaction and will send its result soon. + // We just wait for the funding process to finish before stopping (in the next state). + timers.startSingleTimer(Stop, 1 second) Behaviors.same - case UtxosUnlocked => - Behaviors.stopped } } - def publish(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, tx: Transaction, fee: Satoshi): Behavior[Command] = { + def publish(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, tx: FundedTx): Behavior[Command] = { val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), "mempool-tx-monitor") - txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), tx, cmd.input, cmd.desc, fee) + txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), tx.signedTx, cmd.input, cmd.desc, tx.fee) + // We register to new blocks: if the transaction doesn't confirm, we will replace it with one that pays more fees. + context.system.eventStream ! EventStream.Subscribe(context.messageAdapter[CurrentBlockCount](cbc => WrappedCurrentBlockCount(cbc.blockCount))) + wait(replyTo, cmd, txMonitor, tx) + } + + // Wait for our transaction to be confirmed or rejected from the mempool. + // If we get close to the deadline and our transaction is stuck in the mempool, we will initiate an RBF attempt. + def wait(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, txMonitor: ActorRef[MempoolTxMonitor.Command], tx: FundedTx): Behavior[Command] = { Behaviors.receiveMessagePartial { - case WrappedTxResult(MempoolTxMonitor.TxConfirmed) => sendResult(replyTo, TxPublisher.TxConfirmed(cmd, tx)) - case WrappedTxResult(MempoolTxMonitor.TxRejected(reason)) => - reason match { - case TxRejectedReason.WalletInputGone => - // The transaction now has an unknown input from bitcoind's point of view, so it will keep it in the wallet in - // case that input appears later in the mempool or the blockchain. In our case, we know it won't happen so we - // abandon that transaction and will retry with a different set of inputs (if it still makes sense to publish). - bitcoinClient.abandonTransaction(tx.txid) - case _ => // nothing to do - } + case WrappedTxResult(MempoolTxMonitor.TxConfirmed(confirmedTx)) => sendResult(replyTo, TxPublisher.TxConfirmed(cmd, confirmedTx)) + case WrappedTxResult(MempoolTxMonitor.TxRejected(_, reason)) => replyTo ! TxPublisher.TxRejected(loggingInfo.id, cmd, reason) // We wait for our parent to stop us: when that happens we will unlock utxos. Behaviors.same + case WrappedCurrentBlockCount(currentBlockCount) => + // We avoid a herd effect whenever a new block is found. + timers.startSingleTimer(CheckFeeKey, CheckFee(currentBlockCount), (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis) + Behaviors.same + case CheckFee(currentBlockCount) => + val currentFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.deadline, currentBlockCount) + val targetFeerate_opt = if (cmd.deadline <= currentBlockCount + 6) { + log.debug("{} deadline is close (in {} blocks): bumping fees", cmd.desc, cmd.deadline - currentBlockCount) + // We make sure we increase the fees by at least 20% as we get close to the deadline. + Some(currentFeerate.max(tx.feerate * 1.2)) + } else if (tx.feerate * 1.2 <= currentFeerate) { + log.debug("{} deadline is in {} blocks: bumping fees", cmd.desc, cmd.deadline - currentBlockCount) + Some(currentFeerate) + } else { + log.debug("{} deadline is in {} blocks: no need to bump fees", cmd.desc, cmd.deadline - currentBlockCount) + None + } + targetFeerate_opt.foreach(targetFeerate => { + // We check whether our currently published transaction is confirmed: if it is, it doesn't make sense to bump + // the fee, we're just waiting for enough confirmations to report the result back. + context.pipeToSelf(bitcoinClient.getTxConfirmations(tx.signedTx.txid)) { + case Success(Some(confirmations)) if confirmations > 0 => Stay + case _ => BumpFee(targetFeerate) + } + }) + Behaviors.same + case BumpFee(targetFeerate) => fundReplacement(replyTo, cmd, targetFeerate, txMonitor, tx) + case Stay => Behaviors.same + case Stop => + txMonitor ! MempoolTxMonitor.Stop + unlockAndStop(cmd.input, Seq(tx.signedTx)) + } + } + + // Fund a replacement transaction because our previous attempt seems to be stuck in the mempool. + def fundReplacement(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, targetFeerate: FeeratePerKw, previousTxMonitor: ActorRef[MempoolTxMonitor.Command], previousTx: FundedTx): Behavior[Command] = { + log.info("bumping {} fees: previous feerate={}, next feerate={}", cmd.desc, previousTx.feerate, targetFeerate) + val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, loggingInfo), "tx-funder-rbf") + txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, Left(previousTx), targetFeerate) + Behaviors.receiveMessagePartial { + case WrappedFundingResult(result) => + result match { + case success: ReplaceableTxFunder.TransactionReady => publishReplacement(replyTo, cmd, previousTx, previousTxMonitor, success.fundedTx) + case ReplaceableTxFunder.FundingFailed(_) => + log.warn("could not fund {} replacement transaction (target feerate={})", cmd.desc, targetFeerate) + wait(replyTo, cmd, previousTxMonitor, previousTx) + } + case txResult: WrappedTxResult => + // This is the result of the previous publishing attempt. + // We don't need to handle it now that we're in the middle of funding, we can defer it to the next state. + timers.startSingleTimer(txResult, 1 second) + Behaviors.same + case cbc: WrappedCurrentBlockCount => + timers.startSingleTimer(CurrentBlockCountKey, cbc, 1 second) + Behaviors.same + case Stop => + // We can't stop right away, because the child actor may need to unlock utxos first. + // We just wait for the funding process to finish before stopping. + timers.startSingleTimer(Stop, 1 second) + Behaviors.same + } + } + + // Publish an RBF attempt. We then have two concurrent transactions: the previous one and the updated one. + // Only one of them can be in the mempool, so we wait for the other to be rejected. Once that's done, we're back to a + // situation where we have one transaction in the mempool and wait for it to confirm. + def publishReplacement(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, previousTx: FundedTx, previousTxMonitor: ActorRef[MempoolTxMonitor.Command], bumpedTx: FundedTx): Behavior[Command] = { + val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), s"mempool-tx-monitor-rbf-${bumpedTx.signedTx.txid}") + txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), bumpedTx.signedTx, cmd.input, cmd.desc, bumpedTx.fee) + Behaviors.receiveMessagePartial { + case WrappedTxResult(MempoolTxMonitor.TxConfirmed(confirmedTx)) => + // Since our transactions conflict, we should always receive a failure from the evicted transaction before one + // of them confirms: this case should not happen, so we don't bother unlocking utxos. + log.warn("{} was confirmed while we're publishing an RBF attempt", cmd.desc) + sendResult(replyTo, TxPublisher.TxConfirmed(cmd, confirmedTx)) + case WrappedTxResult(MempoolTxMonitor.TxRejected(txid, _)) => + if (txid == bumpedTx.signedTx.txid) { + log.warn("{} transaction paying more fees (txid={}) failed to replace previous transaction", cmd.desc, txid) + cleanUpFailedTxAndWait(replyTo, cmd, bumpedTx.signedTx, previousTxMonitor, previousTx) + } else { + log.info("previous {} replaced by new transaction paying more fees (txid={})", cmd.desc, bumpedTx.signedTx.txid) + cleanUpFailedTxAndWait(replyTo, cmd, previousTx.signedTx, txMonitor, bumpedTx) + } + case cbc: WrappedCurrentBlockCount => + timers.startSingleTimer(CurrentBlockCountKey, cbc, 1 second) + Behaviors.same case Stop => + previousTxMonitor ! MempoolTxMonitor.Stop txMonitor ! MempoolTxMonitor.Stop - unlockAndStop(cmd.input, tx) + // We don't know yet which transaction won, so we try abandoning both and unlocking their utxos. + // One of the calls will fail (for the transaction that is in the mempool), but we will simply ignore that failure. + unlockAndStop(cmd.input, Seq(previousTx.signedTx, bumpedTx.signedTx)) + } + } + + // Clean up the failed transaction attempt. Once that's done, go back to the waiting state with the new transaction. + def cleanUpFailedTxAndWait(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, failedTx: Transaction, txMonitor: ActorRef[MempoolTxMonitor.Command], mempoolTx: FundedTx): Behavior[Command] = { + context.pipeToSelf(bitcoinClient.abandonTransaction(failedTx.txid))(_ => UnlockUtxos) + Behaviors.receiveMessagePartial { + case UnlockUtxos => + val toUnlock = failedTx.txIn.map(_.outPoint).toSet -- mempoolTx.signedTx.txIn.map(_.outPoint).toSet + if (toUnlock.isEmpty) { + context.self ! UtxosUnlocked + } else { + log.debug("unlocking utxos={}", toUnlock.mkString(", ")) + context.pipeToSelf(bitcoinClient.unlockOutpoints(toUnlock.toSeq))(_ => UtxosUnlocked) + } + Behaviors.same + case UtxosUnlocked => + // Now that we've cleaned up the failed transaction, we can go back to waiting for the current mempool transaction + // or bump it if it doesn't confirm fast enough either. + wait(replyTo, cmd, txMonitor, mempoolTx) + case txResult: WrappedTxResult => + // This is the result of the current mempool tx: we will handle this command once we're back in the waiting + // state for this transaction. + timers.startSingleTimer(txResult, 1 second) + Behaviors.same + case cbc: WrappedCurrentBlockCount => + timers.startSingleTimer(CurrentBlockCountKey, cbc, 1 second) + Behaviors.same + case Stop => + // We don't stop right away, because we're cleaning up the failed transaction. + // This shouldn't take long so we'll handle this command once we're back in the waiting state. + timers.startSingleTimer(Stop, 1 second) + Behaviors.same } } - def unlockAndStop(input: OutPoint, tx: Transaction): Behavior[Command] = { - val toUnlock = tx.txIn.filterNot(_.outPoint == input).map(_.outPoint) - log.debug("unlocking utxos={}", toUnlock.mkString(", ")) - context.pipeToSelf(bitcoinClient.unlockOutpoints(toUnlock))(_ => UtxosUnlocked) + def unlockAndStop(input: OutPoint, txs: Seq[Transaction]): Behavior[Command] = { + // The bitcoind wallet will keep transactions around even when they can't be published (e.g. one of their inputs has + // disappeared but bitcoind thinks it may reappear later), hoping that it will be able to automatically republish + // them later. In our case this is unnecessary, we will publish ourselves, and we don't want to pollute the wallet + // state with transactions that will never be valid, so we eagerly abandon every time. + // If the transaction is in the mempool or confirmed, it will be a no-op. + context.pipeToSelf(Future.traverse(txs)(tx => bitcoinClient.abandonTransaction(tx.txid)))(_ => UnlockUtxos) Behaviors.receiveMessagePartial { + case UnlockUtxos => + val toUnlock = txs.flatMap(_.txIn).filterNot(_.outPoint == input).map(_.outPoint).toSet + if (toUnlock.isEmpty) { + context.self ! UtxosUnlocked + } else { + log.debug("unlocking utxos={}", toUnlock.mkString(", ")) + context.pipeToSelf(bitcoinClient.unlockOutpoints(toUnlock.toSeq))(_ => UtxosUnlocked) + } + Behaviors.same case UtxosUnlocked => log.debug("utxos unlocked") Behaviors.stopped + case WrappedCurrentBlockCount(_) => + log.debug("ignoring new block while stopping") + Behaviors.same case Stop => log.debug("waiting for utxos to be unlocked before stopping") Behaviors.same @@ -182,6 +333,9 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc def sendResult(replyTo: ActorRef[TxPublisher.PublishTxResult], result: TxPublisher.PublishTxResult): Behavior[Command] = { replyTo ! result Behaviors.receiveMessagePartial { + case WrappedCurrentBlockCount(_) => + log.debug("ignoring new block while stopping") + Behaviors.same case Stop => Behaviors.stopped } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 2346c23616..8938f8f58b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -100,7 +100,8 @@ object Transactions { def input: InputInfo def desc: String def tx: Transaction - def fee: Satoshi = input.txOut.amount - tx.txOut.map(_.amount).sum + def amountIn: Satoshi = input.txOut.amount + def fee: Satoshi = amountIn - tx.txOut.map(_.amount).sum def minRelayFee: Satoshi = { val vsize = (tx.weight() + 3) / 4 Satoshi(FeeratePerKw.MinimumRelayFeeRate * vsize / 1000) @@ -520,13 +521,10 @@ object Transactions { } match { case Some(outputIndex) => val input = InputInfo(OutPoint(commitTx, outputIndex), commitTx.txOut(outputIndex), write(redeemScript)) - val sequence = commitmentFormat match { - case DefaultCommitmentFormat => 0xffffffffL // RBF disabled - case _: AnchorOutputsCommitmentFormat => 1 // txs have a 1-block delay to allow CPFP carve-out on anchors - } + // unsigned tx val tx = Transaction( version = 2, - txIn = TxIn(input.outPoint, ByteVector.empty, sequence) :: Nil, + txIn = TxIn(input.outPoint, ByteVector.empty, getHtlcTxInputSequence(commitmentFormat)) :: Nil, txOut = TxOut(Satoshi(0), localFinalScriptPubKey) :: Nil, lockTime = 0) val weight = addSigs(ClaimHtlcSuccessTx(input, tx, htlc.paymentHash, htlc.id), PlaceHolderSig, ByteVector32.Zeroes).tx.weight() diff --git a/eclair-core/src/test/resources/logback-test.xml b/eclair-core/src/test/resources/logback-test.xml index 02005a0eea..21441ee3ad 100644 --- a/eclair-core/src/test/resources/logback-test.xml +++ b/eclair-core/src/test/resources/logback-test.xml @@ -53,6 +53,7 @@ + diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 12f19e7231..fee2bbbc94 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -97,7 +97,6 @@ object TestConstants { "mempool.space" ) - object Alice { val seed: ByteVector32 = ByteVector32(ByteVector.fill(32)(1)) val nodeKeyManager = new LocalNodeKeyManager(seed, Block.RegtestGenesisBlock.hash) @@ -129,7 +128,7 @@ object TestConstants { dustLimit = 1100 sat, maxRemoteDustLimit = 1500 sat, onChainFeeConf = OnChainFeeConf( - feeTargets = FeeTargets(6, 2, 2, 6), + feeTargets = FeeTargets(6, 2, 12, 18), feeEstimator = new TestFeeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, @@ -261,7 +260,7 @@ object TestConstants { dustLimit = 1000 sat, maxRemoteDustLimit = 1500 sat, onChainFeeConf = OnChainFeeConf( - feeTargets = FeeTargets(6, 2, 2, 6), + feeTargets = FeeTargets(6, 2, 12, 18), feeEstimator = new TestFeeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala index 33cce3ca04..bd71870dc2 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala @@ -88,7 +88,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi generateBlocks(TestConstants.Alice.nodeParams.minDepthBlocks - 1) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) - probe.expectMsg(TxConfirmed) + probe.expectMsg(TxConfirmed(tx)) } test("transaction confirmed after replacing existing mempool transaction") { @@ -105,7 +105,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi generateBlocks(TestConstants.Alice.nodeParams.minDepthBlocks) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) - probe.expectMsg(TxConfirmed) + probe.expectMsg(TxConfirmed(tx2)) } test("publish failed (conflicting mempool transaction)") { @@ -118,7 +118,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi val tx2 = createSpendP2WPKH(parentTx, priv, priv.publicKey, 7_500 sat, 0, 0) monitor ! Publish(probe.ref, tx2, tx2.txIn.head.outPoint, "test-tx", 25 sat) - probe.expectMsg(TxRejected(ConflictingTxUnconfirmed)) + probe.expectMsg(TxRejected(tx2.txid, ConflictingTxUnconfirmed)) } test("publish failed (conflicting confirmed transaction)") { @@ -132,7 +132,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi val tx2 = createSpendP2WPKH(parentTx, priv, priv.publicKey, 15_000 sat, 0, 0) monitor ! Publish(probe.ref, tx2, tx2.txIn.head.outPoint, "test-tx", 10 sat) - probe.expectMsg(TxRejected(ConflictingTxConfirmed)) + probe.expectMsg(TxRejected(tx2.txid, ConflictingTxConfirmed)) } test("publish failed (unconfirmed parent, wallet input doesn't exist)") { @@ -142,7 +142,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi val tx = createSpendP2WPKH(parentTx, priv, priv.publicKey, 5_000 sat, 0, 0) val txUnknownInput = tx.copy(txIn = tx.txIn ++ Seq(TxIn(OutPoint(randomBytes32(), 13), Nil, 0))) monitor ! Publish(probe.ref, txUnknownInput, txUnknownInput.txIn.head.outPoint, "test-tx", 10 sat) - probe.expectMsg(TxRejected(WalletInputGone)) + probe.expectMsg(TxRejected(txUnknownInput.txid, WalletInputGone)) } test("publish failed (confirmed parent, wallet input doesn't exist)") { @@ -155,7 +155,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi val tx = createSpendP2WPKH(parentTx, priv, priv.publicKey, 5_000 sat, 0, 0) val txUnknownInput = tx.copy(txIn = tx.txIn ++ Seq(TxIn(OutPoint(randomBytes32(), 13), Nil, 0))) monitor ! Publish(probe.ref, txUnknownInput, txUnknownInput.txIn.head.outPoint, "test-tx", 10 sat) - probe.expectMsg(TxRejected(WalletInputGone)) + probe.expectMsg(TxRejected(txUnknownInput.txid, WalletInputGone)) } test("publish failed (wallet input spent by conflicting confirmed transaction)") { @@ -170,7 +170,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi val tx = createSpendManyP2WPKH(Seq(parentTx, walletTx), priv, priv.publicKey, 5_000 sat, 0, 0) monitor ! Publish(probe.ref, tx, tx.txIn.head.outPoint, "test-tx", 10 sat) - probe.expectMsg(TxRejected(WalletInputGone)) + probe.expectMsg(TxRejected(tx.txid, WalletInputGone)) } test("publish succeeds then transaction is replaced by an unconfirmed tx") { @@ -187,7 +187,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi // When a new block is found, we detect that the transaction has been replaced. system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) - probe.expectMsg(TxRejected(ConflictingTxUnconfirmed)) + probe.expectMsg(TxRejected(tx1.txid, ConflictingTxUnconfirmed)) } test("publish succeeds then transaction is replaced by a confirmed tx") { @@ -205,7 +205,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi // When a new block is found, we detect that the transaction has been replaced. generateBlocks(1) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) - probe.expectMsg(TxRejected(ConflictingTxConfirmed)) + probe.expectMsg(TxRejected(tx1.txid, ConflictingTxConfirmed)) } test("publish succeeds then wallet input disappears") { @@ -229,7 +229,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi // When a new block is found, we detect that the transaction has been evicted. generateBlocks(1) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) - probe.expectMsg(TxRejected(WalletInputGone)) + probe.expectMsg(TxRejected(tx.txid, WalletInputGone)) } test("emit transaction events") { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala new file mode 100644 index 0000000000..182f5abdd8 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala @@ -0,0 +1,315 @@ +/* + * Copyright 2021 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.channel.publish + +import fr.acinq.bitcoin.{ByteVector32, Crypto, OutPoint, SatoshiLong, Script, Transaction, TxIn, TxOut} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.Helpers.Funding +import fr.acinq.eclair.channel.publish.ReplaceableTxFunder.AdjustPreviousTxOutputResult.{AddWalletInputs, TxOutputAdjusted} +import fr.acinq.eclair.channel.publish.ReplaceableTxFunder._ +import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher._ +import fr.acinq.eclair.channel.{CommitTxAndRemoteSig, Commitments, LocalCommit, LocalParams} +import fr.acinq.eclair.transactions.Scripts +import fr.acinq.eclair.transactions.Transactions._ +import fr.acinq.eclair.{CltvExpiry, TestKitBaseClass, randomBytes32} +import org.mockito.IdiomaticMockito.StubbingOps +import org.mockito.MockitoSugar.mock +import org.scalatest.Tag +import org.scalatest.funsuite.AnyFunSuiteLike +import scodec.bits.ByteVector + +import scala.util.Random + +class ReplaceableTxFunderSpec extends TestKitBaseClass with AnyFunSuiteLike { + + private def createAnchorTx(): (CommitTx, ClaimLocalAnchorOutputTx) = { + val anchorScript = Scripts.anchor(PlaceHolderPubKey) + val commitInput = Funding.makeFundingInputInfo(randomBytes32(), 1, 500 sat, PlaceHolderPubKey, PlaceHolderPubKey) + val commitTx = Transaction( + 2, + Seq(TxIn(commitInput.outPoint, commitInput.redeemScript, 0, Scripts.witness2of2(PlaceHolderSig, PlaceHolderSig, PlaceHolderPubKey, PlaceHolderPubKey))), + Seq(TxOut(330 sat, Script.pay2wsh(anchorScript))), + 0 + ) + val anchorTx = ClaimLocalAnchorOutputTx( + InputInfo(OutPoint(commitTx, 0), commitTx.txOut.head, anchorScript), + Transaction(2, Seq(TxIn(OutPoint(commitTx, 0), ByteVector.empty, 0)), Nil, 0) + ) + (CommitTx(commitInput, commitTx), anchorTx) + } + + test("adjust anchor tx change amount", Tag("fuzzy")) { + val (commitTx, anchorTx) = createAnchorTx() + val dustLimit = 600 sat + val commitFeerate = FeeratePerKw(2500 sat) + val targetFeerate = FeeratePerKw(10000 sat) + for (_ <- 1 to 100) { + val walletInputsCount = 1 + Random.nextInt(5) + val walletInputs = (1 to walletInputsCount).map(_ => TxIn(OutPoint(randomBytes32(), 0), Nil, 0)) + val amountIn = dustLimit * walletInputsCount + Random.nextInt(25_000_000).sat + val amountOut = dustLimit + Random.nextLong(amountIn.toLong).sat + val unsignedTx = ClaimLocalAnchorWithWitnessData(anchorTx.copy(tx = anchorTx.tx.copy( + txIn = anchorTx.tx.txIn ++ walletInputs, + txOut = TxOut(amountOut, Script.pay2wpkh(PlaceHolderPubKey)) :: Nil, + ))) + val adjustedTx = adjustAnchorOutputChange(unsignedTx, commitTx.tx, amountIn, commitFeerate, targetFeerate, dustLimit) + assert(adjustedTx.txInfo.tx.txIn.size === unsignedTx.txInfo.tx.txIn.size) + assert(adjustedTx.txInfo.tx.txOut.size === 1) + assert(adjustedTx.txInfo.tx.txOut.head.amount >= dustLimit) + if (adjustedTx.txInfo.tx.txOut.head.amount > dustLimit) { + // Simulate tx signing to check final feerate. + val signedTx = { + val anchorSigned = addSigs(adjustedTx.txInfo, PlaceHolderSig) + val signedWalletInputs = anchorSigned.tx.txIn.tail.map(txIn => txIn.copy(witness = Script.witnessPay2wpkh(PlaceHolderPubKey, PlaceHolderSig))) + anchorSigned.tx.copy(txIn = anchorSigned.tx.txIn.head +: signedWalletInputs) + } + // We want the package anchor tx + commit tx to reach our target feerate, but the commit tx already pays a (smaller) fee + val targetFee = weight2fee(targetFeerate, signedTx.weight() + commitTx.tx.weight()) - weight2fee(commitFeerate, commitTx.tx.weight()) + val actualFee = amountIn - signedTx.txOut.map(_.amount).sum + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee amountIn=$amountIn tx=$signedTx") + } + } + } + + private def createHtlcTxs(): (HtlcSuccessWithWitnessData, HtlcTimeoutWithWitnessData) = { + val preimage = randomBytes32() + val paymentHash = Crypto.sha256(preimage) + val htlcSuccessScript = Scripts.htlcReceived(PlaceHolderPubKey, PlaceHolderPubKey, PlaceHolderPubKey, paymentHash, CltvExpiry(0), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + val htlcTimeoutScript = Scripts.htlcOffered(PlaceHolderPubKey, PlaceHolderPubKey, PlaceHolderPubKey, randomBytes32(), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + val commitTx = Transaction( + 2, + Seq(TxIn(OutPoint(randomBytes32(), 1), Script.write(Script.pay2wpkh(PlaceHolderPubKey)), 0, Script.witnessPay2wpkh(PlaceHolderPubKey, PlaceHolderSig))), + Seq(TxOut(5000 sat, Script.pay2wsh(htlcSuccessScript)), TxOut(4000 sat, Script.pay2wsh(htlcTimeoutScript))), + 0 + ) + val htlcSuccess = HtlcSuccessWithWitnessData(HtlcSuccessTx( + InputInfo(OutPoint(commitTx, 0), commitTx.txOut.head, htlcSuccessScript), + Transaction(2, Seq(TxIn(OutPoint(commitTx, 0), ByteVector.empty, 0)), Seq(TxOut(5000 sat, Script.pay2wpkh(PlaceHolderPubKey))), 0), + paymentHash, + 17 + ), PlaceHolderSig, preimage) + val htlcTimeout = HtlcTimeoutWithWitnessData(HtlcTimeoutTx( + InputInfo(OutPoint(commitTx, 1), commitTx.txOut.last, htlcTimeoutScript), + Transaction(2, Seq(TxIn(OutPoint(commitTx, 1), ByteVector.empty, 0)), Seq(TxOut(4000 sat, Script.pay2wpkh(PlaceHolderPubKey))), 0), + 12 + ), PlaceHolderSig) + (htlcSuccess, htlcTimeout) + } + + test("adjust htlc tx change amount", Tag("fuzzy")) { + val dustLimit = 600 sat + val targetFeerate = FeeratePerKw(10000 sat) + val (htlcSuccess, htlcTimeout) = createHtlcTxs() + for (_ <- 1 to 100) { + val walletInputsCount = 1 + Random.nextInt(5) + val walletInputs = (1 to walletInputsCount).map(_ => TxIn(OutPoint(randomBytes32(), 0), Nil, 0)) + val walletAmountIn = dustLimit * walletInputsCount + Random.nextInt(25_000_000).sat + val changeOutput = TxOut(Random.nextLong(walletAmountIn.toLong).sat, Script.pay2wpkh(PlaceHolderPubKey)) + val unsignedHtlcSuccessTx = htlcSuccess.updateTx(htlcSuccess.txInfo.tx.copy( + txIn = htlcSuccess.txInfo.tx.txIn ++ walletInputs, + txOut = htlcSuccess.txInfo.tx.txOut ++ Seq(changeOutput) + )) + val unsignedHtlcTimeoutTx = htlcTimeout.updateTx(htlcTimeout.txInfo.tx.copy( + txIn = htlcTimeout.txInfo.tx.txIn ++ walletInputs, + txOut = htlcTimeout.txInfo.tx.txOut ++ Seq(changeOutput) + )) + for (unsignedTx <- Seq(unsignedHtlcSuccessTx, unsignedHtlcTimeoutTx)) { + val totalAmountIn = unsignedTx.txInfo.input.txOut.amount + walletAmountIn + val adjustedTx = adjustHtlcTxChange(unsignedTx, totalAmountIn, targetFeerate, dustLimit, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + assert(adjustedTx.txInfo.tx.txIn.size === unsignedTx.txInfo.tx.txIn.size) + assert(adjustedTx.txInfo.tx.txOut.size === 1 || adjustedTx.txInfo.tx.txOut.size === 2) + if (adjustedTx.txInfo.tx.txOut.size == 2) { + // Simulate tx signing to check final feerate. + val signedTx = { + val htlcSigned = adjustedTx.txInfo match { + case tx: HtlcSuccessTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, ByteVector32.Zeroes, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + case tx: HtlcTimeoutTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + val signedWalletInputs = htlcSigned.tx.txIn.tail.map(txIn => txIn.copy(witness = Script.witnessPay2wpkh(PlaceHolderPubKey, PlaceHolderSig))) + htlcSigned.tx.copy(txIn = htlcSigned.tx.txIn.head +: signedWalletInputs) + } + val targetFee = weight2fee(targetFeerate, signedTx.weight()) + val actualFee = totalAmountIn - signedTx.txOut.map(_.amount).sum + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee amountIn=$walletAmountIn tx=$signedTx") + } + } + } + } + + private def createClaimHtlcTx(): (ClaimHtlcSuccessWithWitnessData, ClaimHtlcTimeoutWithWitnessData) = { + val preimage = randomBytes32() + val paymentHash = Crypto.sha256(preimage) + val htlcSuccessScript = Scripts.htlcReceived(PlaceHolderPubKey, PlaceHolderPubKey, PlaceHolderPubKey, paymentHash, CltvExpiry(0), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + val htlcTimeoutScript = Scripts.htlcOffered(PlaceHolderPubKey, PlaceHolderPubKey, PlaceHolderPubKey, randomBytes32(), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + val claimHtlcSuccess = ClaimHtlcSuccessWithWitnessData(ClaimHtlcSuccessTx( + InputInfo(OutPoint(ByteVector32.Zeroes, 3), TxOut(5000 sat, Script.pay2wsh(htlcSuccessScript)), htlcSuccessScript), + Transaction(2, Seq(TxIn(OutPoint(ByteVector32.Zeroes, 3), ByteVector.empty, 0)), Seq(TxOut(5000 sat, Script.pay2wpkh(PlaceHolderPubKey))), 0), + paymentHash, + 5 + ), preimage) + val claimHtlcTimeout = ClaimHtlcTimeoutWithWitnessData(ClaimHtlcTimeoutTx( + InputInfo(OutPoint(ByteVector32.Zeroes, 7), TxOut(5000 sat, Script.pay2wsh(htlcTimeoutScript)), htlcTimeoutScript), + Transaction(2, Seq(TxIn(OutPoint(ByteVector32.Zeroes, 7), ByteVector.empty, 0)), Seq(TxOut(5000 sat, Script.pay2wpkh(PlaceHolderPubKey))), 0), + 7 + )) + (claimHtlcSuccess, claimHtlcTimeout) + } + + test("adjust claim htlc tx change amount") { + val dustLimit = 750 sat + val (claimHtlcSuccess, claimHtlcTimeout) = createClaimHtlcTx() + for (claimHtlc <- Seq(claimHtlcSuccess, claimHtlcTimeout)) { + var previousAmount = claimHtlc.txInfo.tx.txOut.head.amount + for (i <- 1 to 100) { + val targetFeerate = FeeratePerKw(250 * i sat) + adjustClaimHtlcTxOutput(claimHtlc, targetFeerate, dustLimit) match { + case Left(_) => assert(targetFeerate >= FeeratePerKw(7000 sat)) + case Right(updatedClaimHtlc) => + assert(updatedClaimHtlc.txInfo.tx.txIn.length === 1) + assert(updatedClaimHtlc.txInfo.tx.txOut.length === 1) + assert(updatedClaimHtlc.txInfo.tx.txOut.head.amount < previousAmount) + previousAmount = updatedClaimHtlc.txInfo.tx.txOut.head.amount + val signedTx = updatedClaimHtlc match { + case ClaimHtlcSuccessWithWitnessData(txInfo, preimage) => addSigs(txInfo, PlaceHolderSig, preimage) + case ClaimHtlcTimeoutWithWitnessData(txInfo) => addSigs(txInfo, PlaceHolderSig) + case _: LegacyClaimHtlcSuccessWithWitnessData => fail("legacy claim htlc success not supported") + } + val txFeerate = fee2rate(signedTx.fee, signedTx.tx.weight()) + assert(targetFeerate * 0.9 <= txFeerate && txFeerate <= targetFeerate * 1.1, s"actualFeerate=$txFeerate targetFeerate=$targetFeerate") + } + } + } + } + + test("adjust previous anchor transaction outputs") { + val (commitTx, initialAnchorTx) = createAnchorTx() + val previousAnchorTx = ClaimLocalAnchorWithWitnessData(initialAnchorTx).updateTx(initialAnchorTx.tx.copy( + txIn = Seq( + initialAnchorTx.tx.txIn.head, + // The previous funding attempt added two wallet inputs: + TxIn(OutPoint(randomBytes32(), 3), ByteVector.empty, 0, Script.witnessPay2wpkh(PlaceHolderPubKey, PlaceHolderSig)), + TxIn(OutPoint(randomBytes32(), 1), ByteVector.empty, 0, Script.witnessPay2wpkh(PlaceHolderPubKey, PlaceHolderSig)) + ), + // And a change output: + txOut = Seq(TxOut(5000 sat, Script.pay2wpkh(PlaceHolderPubKey))) + )) + + val commitments = mock[Commitments] + val localParams = mock[LocalParams] + localParams.dustLimit.returns(1000 sat) + commitments.localParams.returns(localParams) + val localCommit = mock[LocalCommit] + localCommit.commitTxAndRemoteSig.returns(CommitTxAndRemoteSig(commitTx, PlaceHolderSig)) + commitments.localCommit.returns(localCommit) + + // We can handle a small feerate update by lowering the change output. + val TxOutputAdjusted(feerateUpdate1) = adjustPreviousTxOutput(FundedTx(previousAnchorTx, 12000 sat, FeeratePerKw(2500 sat)), FeeratePerKw(5000 sat), commitments) + assert(feerateUpdate1.txInfo.tx.txIn === previousAnchorTx.txInfo.tx.txIn) + assert(feerateUpdate1.txInfo.tx.txOut.length === 1) + val TxOutputAdjusted(feerateUpdate2) = adjustPreviousTxOutput(FundedTx(previousAnchorTx, 12000 sat, FeeratePerKw(2500 sat)), FeeratePerKw(6000 sat), commitments) + assert(feerateUpdate2.txInfo.tx.txIn === previousAnchorTx.txInfo.tx.txIn) + assert(feerateUpdate2.txInfo.tx.txOut.length === 1) + assert(feerateUpdate2.txInfo.tx.txOut.head.amount < feerateUpdate1.txInfo.tx.txOut.head.amount) + + // But if the feerate increase is too large, we must add new wallet inputs. + val AddWalletInputs(previousTx) = adjustPreviousTxOutput(FundedTx(previousAnchorTx, 12000 sat, FeeratePerKw(2500 sat)), FeeratePerKw(10000 sat), commitments) + assert(previousTx === previousAnchorTx) + } + + test("adjust previous htlc transaction outputs", Tag("fuzzy")) { + val commitments = mock[Commitments] + val localParams = mock[LocalParams] + localParams.dustLimit.returns(600 sat) + commitments.localParams.returns(localParams) + val (initialHtlcSuccess, initialHtlcTimeout) = createHtlcTxs() + for (initialHtlcTx <- Seq(initialHtlcSuccess, initialHtlcTimeout)) { + val previousTx = initialHtlcTx.updateTx(initialHtlcTx.txInfo.tx.copy( + txIn = Seq( + initialHtlcTx.txInfo.tx.txIn.head, + // The previous funding attempt added three wallet inputs: + TxIn(OutPoint(randomBytes32(), 3), ByteVector.empty, 0, Script.witnessPay2wpkh(PlaceHolderPubKey, PlaceHolderSig)), + TxIn(OutPoint(randomBytes32(), 1), ByteVector.empty, 0, Script.witnessPay2wpkh(PlaceHolderPubKey, PlaceHolderSig)), + TxIn(OutPoint(randomBytes32(), 5), ByteVector.empty, 0, Script.witnessPay2wpkh(PlaceHolderPubKey, PlaceHolderSig)) + ), + txOut = Seq( + initialHtlcTx.txInfo.tx.txOut.head, + // And one change output: + TxOut(5000 sat, Script.pay2wpkh(PlaceHolderPubKey)) + ) + )) + + // We can handle a small feerate update by lowering the change output. + val TxOutputAdjusted(feerateUpdate1) = adjustPreviousTxOutput(FundedTx(previousTx, 15000 sat, FeeratePerKw(2500 sat)), FeeratePerKw(5000 sat), commitments) + assert(feerateUpdate1.txInfo.tx.txIn === previousTx.txInfo.tx.txIn) + assert(feerateUpdate1.txInfo.tx.txOut.length === 2) + assert(feerateUpdate1.txInfo.tx.txOut.head === previousTx.txInfo.tx.txOut.head) + val TxOutputAdjusted(feerateUpdate2) = adjustPreviousTxOutput(FundedTx(previousTx, 15000 sat, FeeratePerKw(2500 sat)), FeeratePerKw(6000 sat), commitments) + assert(feerateUpdate2.txInfo.tx.txIn === previousTx.txInfo.tx.txIn) + assert(feerateUpdate2.txInfo.tx.txOut.length === 2) + assert(feerateUpdate2.txInfo.tx.txOut.head === previousTx.txInfo.tx.txOut.head) + assert(feerateUpdate2.txInfo.tx.txOut.last.amount < feerateUpdate1.txInfo.tx.txOut.last.amount) + + // If the previous funding attempt didn't add a change output, we must add new wallet inputs. + val previousTxNoChange = previousTx.updateTx(previousTx.txInfo.tx.copy(txOut = Seq(previousTx.txInfo.tx.txOut.head))) + val AddWalletInputs(tx) = adjustPreviousTxOutput(FundedTx(previousTxNoChange, 25000 sat, FeeratePerKw(2500 sat)), FeeratePerKw(5000 sat), commitments) + assert(tx === previousTxNoChange) + + for (_ <- 1 to 100) { + val amountIn = Random.nextInt(25_000_000).sat + val changeAmount = Random.nextInt(amountIn.toLong.toInt).sat + val fuzzyPreviousTx = previousTx.updateTx(previousTx.txInfo.tx.copy(txOut = Seq( + initialHtlcTx.txInfo.tx.txOut.head, + TxOut(changeAmount, Script.pay2wpkh(PlaceHolderPubKey)) + ))) + val targetFeerate = FeeratePerKw(2500 sat) + FeeratePerKw(Random.nextInt(20000).sat) + adjustPreviousTxOutput(FundedTx(fuzzyPreviousTx, amountIn, FeeratePerKw(2500 sat)), targetFeerate, commitments) match { + case AdjustPreviousTxOutputResult.Skip(_) => // nothing do check + case AddWalletInputs(tx) => assert(tx === fuzzyPreviousTx) + case TxOutputAdjusted(updatedTx) => + assert(updatedTx.txInfo.tx.txIn === fuzzyPreviousTx.txInfo.tx.txIn) + assert(Set(1, 2).contains(updatedTx.txInfo.tx.txOut.length)) + assert(updatedTx.txInfo.tx.txOut.head === fuzzyPreviousTx.txInfo.tx.txOut.head) + assert(updatedTx.txInfo.tx.txOut.last.amount >= 600.sat) + } + } + } + } + + test("adjust previous claim htlc transaction outputs") { + val commitments = mock[Commitments] + val localParams = mock[LocalParams] + localParams.dustLimit.returns(500 sat) + commitments.localParams.returns(localParams) + val (claimHtlcSuccess, claimHtlcTimeout) = createClaimHtlcTx() + for (claimHtlc <- Seq(claimHtlcSuccess, claimHtlcTimeout)) { + var previousAmount = claimHtlc.txInfo.tx.txOut.head.amount + for (i <- 1 to 100) { + val targetFeerate = FeeratePerKw(250 * i sat) + adjustPreviousTxOutput(FundedTx(claimHtlc, claimHtlc.txInfo.amountIn, FeeratePerKw(2500 sat)), targetFeerate, commitments) match { + case AdjustPreviousTxOutputResult.Skip(_) => assert(targetFeerate >= FeeratePerKw(10000 sat)) + case AddWalletInputs(_) => fail("shouldn't add wallet inputs to claim-htlc-tx") + case TxOutputAdjusted(updatedTx) => + assert(updatedTx.txInfo.tx.txIn === claimHtlc.txInfo.tx.txIn) + assert(updatedTx.txInfo.tx.txOut.length === 1) + assert(updatedTx.txInfo.tx.txOut.head.amount < previousAmount) + previousAmount = updatedTx.txInfo.tx.txOut.head.amount + } + } + } + } + +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala index 223e399e1b..33fcd061b5 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala @@ -20,7 +20,8 @@ import akka.actor.typed.ActorRef import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, actorRefAdapter} import akka.pattern.pipe import akka.testkit.{TestFSMRef, TestProbe} -import fr.acinq.bitcoin.{BtcAmount, ByteVector32, ByteVector64, MilliBtcDouble, OutPoint, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut} +import fr.acinq.bitcoin.{BtcAmount, ByteVector32, MilliBtcDouble, SatoshiLong, Transaction} +import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator import fr.acinq.eclair.TestConstants.TestFeeEstimator import fr.acinq.eclair.blockchain.CurrentBlockCount import fr.acinq.eclair.blockchain.bitcoind.BitcoindService @@ -29,22 +30,20 @@ import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.MempoolTx import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinCoreClient, BitcoinJsonRPCClient} import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.channel._ -import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher.{ClaimLocalAnchorWithWitnessData, HtlcSuccessWithWitnessData, HtlcTimeoutWithWitnessData} import fr.acinq.eclair.channel.publish.ReplaceableTxPublisher.{Publish, Stop} import fr.acinq.eclair.channel.publish.TxPublisher.TxRejectedReason._ import fr.acinq.eclair.channel.publish.TxPublisher._ import fr.acinq.eclair.channel.states.{ChannelStateTestsHelperMethods, ChannelStateTestsTags} +import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.transactions.{Scripts, Transactions} -import fr.acinq.eclair.{MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32, randomKey} +import fr.acinq.eclair.{MilliSatoshiLong, TestConstants, TestKitBaseClass, randomKey} +import org.scalatest.BeforeAndAfterAll import org.scalatest.funsuite.AnyFunSuiteLike -import org.scalatest.{BeforeAndAfterAll, Tag} import java.util.UUID import java.util.concurrent.atomic.AtomicLong import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.duration.DurationInt -import scala.util.Random class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike with BitcoindService with ChannelStateTestsHelperMethods with BeforeAndAfterAll { @@ -72,6 +71,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w system.spawnAnonymous(ReplaceableTxPublisher(alice.underlyingActor.nodeParams, wallet, alice2blockchain.ref, TxPublishLogContext(UUID.randomUUID(), randomKey().publicKey, None))) } + def aliceBlockHeight(): Long = alice.underlyingActor.nodeParams.currentBlockHeight + + def bobBlockHeight(): Long = bob.underlyingActor.nodeParams.currentBlockHeight + /** Set uniform feerate for all block targets. */ def setFeerate(feerate: FeeratePerKw): Unit = { alice.underlyingActor.nodeParams.onChainFeeConf.feeEstimator.asInstanceOf[TestFeeEstimator].setFeerate(FeeratesPerKw.single(feerate)) @@ -84,19 +87,23 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w bob.underlyingActor.nodeParams.onChainFeeConf.feeEstimator.asInstanceOf[TestFeeEstimator].setFeerate(blockTarget, feerate) } - def getMempool: Seq[Transaction] = { + def getMempool(): Seq[Transaction] = { wallet.getMempool().pipeTo(probe.ref) probe.expectMsgType[Seq[Transaction]] } def getMempoolTxs(expectedTxCount: Int): Seq[MempoolTx] = { - awaitCond(getMempool.size == expectedTxCount, interval = 200 milliseconds) - getMempool.map(tx => { + awaitCond(getMempool().size == expectedTxCount, interval = 200 milliseconds) + getMempool().map(tx => { wallet.getMempoolTx(tx.txid).pipeTo(probe.ref) probe.expectMsgType[MempoolTx] }) } + def isInMempool(txid: ByteVector32): Boolean = { + getMempool().exists(_.txid == txid) + } + } // NB: we can't use ScalaTest's fixtures, they would see uninitialized bitcoind fields because they sandbox each test. @@ -143,7 +150,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } } - def closeChannelWithoutHtlcs(f: Fixture): (PublishFinalTx, PublishReplaceableTx) = { + def closeChannelWithoutHtlcs(f: Fixture, commitDeadline: Long): (PublishFinalTx, PublishReplaceableTx) = { import f._ val commitTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.fullySignedLocalCommitTx(alice.underlyingActor.nodeParams.channelKeyManager) @@ -153,7 +160,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // Forward the commit tx to the publisher. val publishCommitTx = alice2blockchain.expectMsg(PublishFinalTx(commitTx, commitTx.fee, None)) // Forward the anchor tx to the publisher. - val publishAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] + val publishAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(deadline = commitDeadline) assert(publishAnchor.txInfo.input.outPoint.txid === commitTx.tx.txid) assert(publishAnchor.txInfo.isInstanceOf[ClaimLocalAnchorOutputTx]) @@ -166,7 +173,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val commitFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate setFeerate(commitFeerate) - val (_, anchorTx) = closeChannelWithoutHtlcs(f) + val (_, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 24) publisher ! Publish(probe.ref, anchorTx) val result = probe.expectMsgType[TxRejected] @@ -179,7 +186,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 12) wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) probe.expectMsg(commitTx.tx.txid) generateBlocks(1) @@ -198,7 +205,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val commitFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate setFeerate(commitFeerate) - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 6) wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) probe.expectMsg(commitTx.tx.txid) generateBlocks(1) @@ -215,7 +222,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w import f._ val remoteCommit = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.fullySignedLocalCommitTx(bob.underlyingActor.nodeParams.channelKeyManager) - val (_, anchorTx) = closeChannelWithoutHtlcs(f) + val (_, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 12) wallet.publishTransaction(remoteCommit.tx).pipeTo(probe.ref) probe.expectMsg(remoteCommit.tx.txid) generateBlocks(1) @@ -233,7 +240,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w import f._ val remoteCommit = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.fullySignedLocalCommitTx(bob.underlyingActor.nodeParams.channelKeyManager) - val (_, anchorTx) = closeChannelWithoutHtlcs(f) + val (_, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 12) wallet.publishTransaction(remoteCommit.tx).pipeTo(probe.ref) probe.expectMsg(remoteCommit.tx.txid) @@ -257,7 +264,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // We lower the feerate to make it easy to replace our commit tx by theirs in the mempool. val lowFeerate = FeeratePerKw(500 sat) updateFee(lowFeerate, alice, bob, alice2bob, bob2alice) - val (localCommit, anchorTx) = closeChannelWithoutHtlcs(f) + val (localCommit, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 16) // We set a slightly higher feerate to ensure the local anchor is used. setFeerate(FeeratePerKw(600 sat)) publisher ! Publish(probe.ref, anchorTx) @@ -281,7 +288,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w import f._ // close channel and wait for the commit tx to be published, anchor will not be published because we don't have enough funds - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 6) wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) probe.expectMsg(commitTx.tx.txid) @@ -299,16 +306,14 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ - val (commitTx, anchorTx) = { - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) - (commitTx, anchorTx.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight + 1)) - } + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) probe.expectMsg(commitTx.tx.txid) - assert(getMempool.length === 1) + assert(getMempool().length === 1) val targetFeerate = FeeratePerKw(3000 sat) - setFeerate(targetFeerate, blockTarget = 1) + // NB: we try to get transactions confirmed *before* their deadline, so we aim for a more aggressive block target than the deadline. + setFeerate(targetFeerate, blockTarget = 12) publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published val mempoolTxs = getMempoolTxs(2) @@ -331,14 +336,12 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ - val (commitTx, anchorTx) = { - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) - (commitTx, anchorTx.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight + 2)) - } - assert(getMempool.isEmpty) + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 32) + assert(getMempool().isEmpty) val targetFeerate = FeeratePerKw(3000 sat) - setFeerate(targetFeerate, blockTarget = 2) + // NB: we try to get transactions confirmed *before* their deadline, so we aim for a more aggressive block target than the deadline. + setFeerate(targetFeerate, blockTarget = 12) publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published val mempoolTxs = getMempoolTxs(2) @@ -369,14 +372,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(utxos, ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ - // NB: when we get close to the deadline, we use aggressive block target: in this case we are 6 blocks away from - // the deadline, and we use a block target of 2 to ensure we confirm before the deadline. + // NB: we try to get transactions confirmed *before* their deadline, so we aim for a more aggressive block target than the deadline. val targetFeerate = FeeratePerKw(10_000 sat) - setFeerate(targetFeerate, blockTarget = 2) - val (commitTx, anchorTx) = { - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) - (commitTx, anchorTx.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight + 6)) - } + setFeerate(targetFeerate, blockTarget = 12) + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 32) publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published @@ -397,13 +396,156 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w }) } + test("commit tx fees not increased when deadline is far and feerate hasn't changed") { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + import f._ + + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) + wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) + probe.expectMsg(commitTx.tx.txid) + + setFeerate(FeeratePerKw(3000 sat)) + publisher ! Publish(probe.ref, anchorTx) + // wait for the commit tx and anchor tx to be published + val mempoolTxs = getMempoolTxs(2) + assert(mempoolTxs.map(_.txid).contains(commitTx.tx.txid)) + + // A new block is found, but we still have time and the feerate hasn't changed, so we don't bump the fees. + // Note that we don't generate blocks, so the transactions are still unconfirmed. + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 5)) + probe.expectNoMessage(500 millis) + val mempoolTxs2 = getMempool() + assert(mempoolTxs.map(_.txid).toSet === mempoolTxs2.map(_.txid).toSet) + }) + } + + test("commit tx not confirming, lowering anchor output amount") { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + import f._ + + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) + wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) + probe.expectMsg(commitTx.tx.txid) + + val oldFeerate = FeeratePerKw(3000 sat) + setFeerate(oldFeerate) + publisher ! Publish(probe.ref, anchorTx) + // wait for the commit tx and anchor tx to be published + val mempoolTxs1 = getMempoolTxs(2) + assert(mempoolTxs1.map(_.txid).contains(commitTx.tx.txid)) + val mempoolAnchorTx1 = mempoolTxs1.filter(_.txid != commitTx.tx.txid).head + + // A new block is found, and the feerate has increased for our block target, so we bump the fees. + val newFeerate = FeeratePerKw(5000 sat) + setFeerate(newFeerate, blockTarget = 12) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 5)) + awaitCond(!isInMempool(mempoolAnchorTx1.txid), interval = 200 millis, max = 30 seconds) + val mempoolTxs2 = getMempoolTxs(2) + val mempoolAnchorTx2 = mempoolTxs2.filter(_.txid != commitTx.tx.txid).head + assert(mempoolAnchorTx1.fees < mempoolAnchorTx2.fees) + + val targetFee = Transactions.weight2fee(newFeerate, mempoolTxs2.map(_.weight).sum.toInt) + val actualFee = mempoolTxs2.map(_.fees).sum + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee") + }) + } + + test("commit tx not confirming, adding other wallet inputs") { + withFixture(Seq(10.5 millibtc, 5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + import f._ + + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) + wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) + probe.expectMsg(commitTx.tx.txid) + + // The feerate is (much) higher for higher block targets + val targetFeerate = FeeratePerKw(75_000 sat) + setFeerate(FeeratePerKw(3000 sat)) + setFeerate(targetFeerate, blockTarget = 6) + publisher ! Publish(probe.ref, anchorTx) + // wait for the commit tx and anchor tx to be published + val mempoolTxs1 = getMempoolTxs(2) + assert(mempoolTxs1.map(_.txid).contains(commitTx.tx.txid)) + val anchorTx1 = getMempool().filter(_.txid != commitTx.tx.txid).head + + // A new block is found, and the feerate has increased for our block target, so we bump the fees. + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 15)) + awaitCond(!isInMempool(anchorTx1.txid), interval = 200 millis, max = 30 seconds) + val anchorTx2 = getMempool().filter(_.txid != commitTx.tx.txid).head + // We used different inputs to be able to bump to the desired feerate. + assert(anchorTx1.txIn.map(_.outPoint).toSet != anchorTx2.txIn.map(_.outPoint).toSet) + + val mempoolTxs2 = getMempoolTxs(2) + val targetFee = Transactions.weight2fee(targetFeerate, mempoolTxs2.map(_.weight).sum.toInt) + val actualFee = mempoolTxs2.map(_.fees).sum + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee") + }) + } + + test("commit tx not confirming, not enough funds to increase fees") { + withFixture(Seq(10.2 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + import f._ + + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) + wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) + probe.expectMsg(commitTx.tx.txid) + + // The feerate is higher for higher block targets + val targetFeerate = FeeratePerKw(25_000 sat) + setFeerate(FeeratePerKw(3000 sat)) + setFeerate(targetFeerate, blockTarget = 6) + publisher ! Publish(probe.ref, anchorTx) + // wait for the commit tx and anchor tx to be published + val mempoolTxs1 = getMempoolTxs(2) + assert(mempoolTxs1.map(_.txid).contains(commitTx.tx.txid)) + + // A new block is found, and the feerate has increased for our block target, but we don't have enough funds to bump the fees. + system.eventStream.subscribe(probe.ref, classOf[NotifyNodeOperator]) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 15)) + probe.expectMsgType[NotifyNodeOperator] + val mempoolTxs2 = getMempool() + assert(mempoolTxs1.map(_.txid).toSet === mempoolTxs2.map(_.txid).toSet) + }) + } + + test("commit tx not confirming, cannot use new unconfirmed inputs to increase fees") { + withFixture(Seq(10.2 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + import f._ + + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) + wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) + probe.expectMsg(commitTx.tx.txid) + + // The feerate is higher for higher block targets + val targetFeerate = FeeratePerKw(25_000 sat) + setFeerate(FeeratePerKw(3000 sat)) + setFeerate(targetFeerate, blockTarget = 6) + publisher ! Publish(probe.ref, anchorTx) + // wait for the commit tx and anchor tx to be published + val mempoolTxs1 = getMempoolTxs(2) + assert(mempoolTxs1.map(_.txid).contains(commitTx.tx.txid)) + + // Our wallet receives new unconfirmed utxos: unfortunately, BIP 125 rule #2 doesn't let us use that input... + wallet.getReceiveAddress().pipeTo(probe.ref) + val walletAddress = probe.expectMsgType[String] + val walletTx = sendToAddress(walletAddress, 5 millibtc, probe) + + // A new block is found, and the feerate has increased for our block target, but we can't use our unconfirmed input. + system.eventStream.subscribe(probe.ref, classOf[NotifyNodeOperator]) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 15)) + probe.expectMsgType[NotifyNodeOperator] + val mempoolTxs2 = getMempool() + assert(mempoolTxs1.map(_.txid).toSet + walletTx.txid === mempoolTxs2.map(_.txid).toSet) + }) + } + test("unlock utxos when anchor tx cannot be published") { withFixture(Seq(500 millibtc, 200 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ val targetFeerate = FeeratePerKw(3000 sat) setFeerate(targetFeerate) - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 36) publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published @@ -435,7 +577,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val targetFeerate = FeeratePerKw(3000 sat) setFeerate(targetFeerate) - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 16) publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published @@ -448,43 +590,6 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w }) } - test("adjust anchor tx change amount", Tag("fuzzy")) { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { - val commitFeerate = f.alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate - assert(commitFeerate < TestConstants.feeratePerKw) - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) - val anchorTxInfo = anchorTx.txInfo.asInstanceOf[ClaimLocalAnchorOutputTx] - val dustLimit = anchorTx.commitments.localParams.dustLimit - for (_ <- 1 to 100) { - val walletInputsCount = 1 + Random.nextInt(5) - val walletInputs = (1 to walletInputsCount).map(_ => TxIn(OutPoint(randomBytes32(), 0), Nil, 0)) - val amountIn = dustLimit * walletInputsCount + Random.nextInt(25_000_000).sat - val amountOut = dustLimit + Random.nextLong(amountIn.toLong).sat - val unsignedTx = ClaimLocalAnchorWithWitnessData(anchorTxInfo.copy(tx = anchorTxInfo.tx.copy( - txIn = anchorTxInfo.tx.txIn ++ walletInputs, - txOut = TxOut(amountOut, Script.pay2wpkh(randomKey().publicKey)) :: Nil, - ))) - val (adjustedTx, fee) = ReplaceableTxFunder.adjustAnchorOutputChange(unsignedTx, commitTx.tx, amountIn, commitFeerate, TestConstants.feeratePerKw, dustLimit) - assert(fee === amountIn - adjustedTx.txInfo.tx.txOut.map(_.amount).sum) - assert(adjustedTx.txInfo.tx.txIn.size === unsignedTx.txInfo.tx.txIn.size) - assert(adjustedTx.txInfo.tx.txOut.size === 1) - assert(adjustedTx.txInfo.tx.txOut.head.amount >= dustLimit) - if (adjustedTx.txInfo.tx.txOut.head.amount > dustLimit) { - // Simulate tx signing to check final feerate. - val signedTx = { - val anchorSigned = Transactions.addSigs(adjustedTx.txInfo, Transactions.PlaceHolderSig) - val signedWalletInputs = anchorSigned.tx.txIn.tail.map(txIn => txIn.copy(witness = ScriptWitness(Seq(Scripts.der(Transactions.PlaceHolderSig), Transactions.PlaceHolderPubKey.value)))) - anchorSigned.tx.copy(txIn = anchorSigned.tx.txIn.head +: signedWalletInputs) - } - // We want the package anchor tx + commit tx to reach our target feerate, but the commit tx already pays a (smaller) fee - val targetFee = Transactions.weight2fee(TestConstants.feeratePerKw, signedTx.weight() + commitTx.tx.weight()) - Transactions.weight2fee(commitFeerate, commitTx.tx.weight()) - val actualFee = amountIn - signedTx.txOut.map(_.amount).sum - assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee amountIn=$amountIn tx=$signedTx") - } - } - }) - } - test("remote commit tx confirmed, not publishing htlc tx") { withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ @@ -532,7 +637,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w }) } - def closeChannelWithHtlcs(f: Fixture): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { + def closeChannelWithHtlcs(f: Fixture, htlcDeadline: Long): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { import f._ // Add htlcs in both directions and ensure that preimages are available. @@ -557,9 +662,9 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.isInstanceOf[ClaimLocalAnchorOutputTx]) alice2blockchain.expectMsgType[PublishFinalTx] // claim main output - val htlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx] + val htlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(deadline = htlcDeadline) assert(htlcSuccess.txInfo.isInstanceOf[HtlcSuccessTx]) - val htlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx] + val htlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(deadline = htlcDeadline) assert(htlcTimeout.txInfo.isInstanceOf[HtlcTimeoutTx]) alice2blockchain.expectMsgType[WatchTxConfirmed] // commit tx @@ -576,10 +681,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(Seq(10.5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ - val (commitTx, htlcSuccess) = { - val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f) - (commitTx, htlcSuccess.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight)) - } + val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f, aliceBlockHeight()) val htlcSuccessPublisher = createPublisher() setFeerate(FeeratePerKw(75_000 sat), blockTarget = 1) htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess) @@ -596,12 +698,8 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w private def testPublishHtlcSuccess(f: Fixture, commitTx: Transaction, htlcSuccess: PublishReplaceableTx, targetFeerate: FeeratePerKw): Transaction = { import f._ - // The HTLC-success tx will be immediately published since the commit tx is confirmed. - // NB: when we get close to the deadline (here, 10 blocks from it) we use an aggressive block target (in this case, 2) - setFeerate(targetFeerate, blockTarget = 2) - val htlcSuccessWithDeadline = htlcSuccess.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight + 10) val htlcSuccessPublisher = createPublisher() - htlcSuccessPublisher ! Publish(probe.ref, htlcSuccessWithDeadline) + htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess) val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) val htlcSuccessTx = getMempoolTxs(1).head @@ -611,7 +709,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(4) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) val htlcSuccessResult = probe.expectMsgType[TxConfirmed] - assert(htlcSuccessResult.cmd === htlcSuccessWithDeadline) + assert(htlcSuccessResult.cmd === htlcSuccess) assert(htlcSuccessResult.tx.txIn.map(_.outPoint.txid).contains(commitTx.txid)) htlcSuccessPublisher ! Stop htlcSuccessResult.tx @@ -626,12 +724,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // The HTLC-timeout will be published after the timeout. val htlcTimeoutPublisher = createPublisher() - val htlcTimeoutWithDeadline = htlcTimeout.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight) - htlcTimeoutPublisher ! Publish(probe.ref, htlcTimeoutWithDeadline) + htlcTimeoutPublisher ! Publish(probe.ref, htlcTimeout) alice2blockchain.expectNoMessage(100 millis) generateBlocks(144) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) - setFeerate(targetFeerate, blockTarget = 1) // the feerate is higher than what it was when the channel force-closed + setFeerate(targetFeerate) // the feerate is higher than what it was when the channel force-closed val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) val htlcTimeoutTx = getMempoolTxs(1).head @@ -641,7 +738,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(4) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) val htlcTimeoutResult = probe.expectMsgType[TxConfirmed] - assert(htlcTimeoutResult.cmd === htlcTimeoutWithDeadline) + assert(htlcTimeoutResult.cmd === htlcTimeout) assert(htlcTimeoutResult.tx.txIn.map(_.outPoint.txid).contains(commitTx.txid)) htlcTimeoutPublisher ! Stop htlcTimeoutResult.tx @@ -652,7 +749,8 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w import f._ val currentFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate - val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f) + val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 64) + setFeerate(currentFeerate) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, currentFeerate) assert(htlcSuccess.txInfo.fee > 0.sat) assert(htlcSuccessTx.txIn.length === 1) @@ -664,8 +762,12 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w test("htlc tx feerate too low, adding wallet inputs") { withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputs, f => { + import f._ + val targetFeerate = FeeratePerKw(15_000 sat) - val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f) + val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 64) + // NB: we try to get transactions confirmed *before* their deadline, so we aim for a more aggressive block target than the deadline. + setFeerate(targetFeerate, blockTarget = 36) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, targetFeerate) assert(htlcSuccessTx.txIn.length > 1) val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, targetFeerate) @@ -675,8 +777,29 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w test("htlc tx feerate zero, adding wallet inputs") { withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + import f._ + val targetFeerate = FeeratePerKw(15_000 sat) - val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f) + val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 30) + // NB: we try to get transactions confirmed *before* their deadline, so we aim for a more aggressive block target than the deadline. + setFeerate(targetFeerate, blockTarget = 12) + assert(htlcSuccess.txInfo.fee === 0.sat) + val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, targetFeerate) + assert(htlcSuccessTx.txIn.length > 1) + assert(htlcTimeout.txInfo.fee === 0.sat) + val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, targetFeerate) + assert(htlcTimeoutTx.txIn.length > 1) + }) + } + + test("htlc tx feerate zero, high commit feerate, adding wallet inputs") { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + import f._ + + val commitFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate + val targetFeerate = commitFeerate / 2 + val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 30) + setFeerate(targetFeerate) assert(htlcSuccess.txInfo.fee === 0.sat) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, targetFeerate) assert(htlcSuccessTx.txIn.length > 1) @@ -703,8 +826,12 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w 5100 sat ) withFixture(utxos, ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + import f._ + val targetFeerate = FeeratePerKw(8_000 sat) - val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f) + val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 30) + // NB: we try to get transactions confirmed *before* their deadline, so we aim for a more aggressive block target than the deadline. + setFeerate(targetFeerate, blockTarget = 12) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, targetFeerate) assert(htlcSuccessTx.txIn.length > 2) val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, targetFeerate) @@ -712,13 +839,127 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w }) } + test("htlc success tx not confirming, lowering output amount") { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + import f._ + + val initialFeerate = FeeratePerKw(15_000 sat) + setFeerate(initialFeerate) + val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f, aliceBlockHeight() + 30) + + val htlcSuccessPublisher = createPublisher() + htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess) + val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] + w.replyTo ! WatchParentTxConfirmedTriggered(aliceBlockHeight().toInt, 0, commitTx) + val htlcSuccessTx1 = getMempoolTxs(1).head + val htlcSuccessInputs1 = getMempool().head.txIn.map(_.outPoint).toSet + + // New blocks are found, which makes us aim for a more aggressive block target, so we bump the fees. + val targetFeerate = FeeratePerKw(25_000 sat) + setFeerate(targetFeerate, blockTarget = 6) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 15)) + awaitCond(!isInMempool(htlcSuccessTx1.txid), interval = 200 millis, max = 30 seconds) + val htlcSuccessTx2 = getMempoolTxs(1).head + val htlcSuccessInputs2 = getMempool().head.txIn.map(_.outPoint).toSet + assert(htlcSuccessTx1.fees < htlcSuccessTx2.fees) + assert(htlcSuccessInputs1 === htlcSuccessInputs2) + val htlcSuccessTargetFee = Transactions.weight2fee(targetFeerate, htlcSuccessTx2.weight.toInt) + assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx2.fees && htlcSuccessTx2.fees <= htlcSuccessTargetFee * 1.4, s"actualFee=${htlcSuccessTx2.fees} targetFee=$htlcSuccessTargetFee") + }) + } + + test("htlc success tx not confirming, adding other wallet inputs") { + withFixture(Seq(10.2 millibtc, 2 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + import f._ + + val initialFeerate = FeeratePerKw(15_000 sat) + setFeerate(initialFeerate) + val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f, aliceBlockHeight() + 15) + + val htlcSuccessPublisher = createPublisher() + htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess) + val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] + w.replyTo ! WatchParentTxConfirmedTriggered(aliceBlockHeight().toInt, 0, commitTx) + val htlcSuccessTx1 = getMempoolTxs(1).head + val htlcSuccessInputs1 = getMempool().head.txIn.map(_.outPoint).toSet + + // New blocks are found, which makes us aim for a more aggressive block target, so we bump the fees. + val targetFeerate = FeeratePerKw(75_000 sat) + setFeerate(targetFeerate, blockTarget = 2) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 10)) + awaitCond(!isInMempool(htlcSuccessTx1.txid), interval = 200 millis, max = 30 seconds) + val htlcSuccessTx2 = getMempoolTxs(1).head + val htlcSuccessInputs2 = getMempool().head.txIn.map(_.outPoint).toSet + assert(htlcSuccessTx1.fees < htlcSuccessTx2.fees) + assert(htlcSuccessInputs1 !== htlcSuccessInputs2) + val htlcSuccessTargetFee = Transactions.weight2fee(targetFeerate, htlcSuccessTx2.weight.toInt) + assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx2.fees && htlcSuccessTx2.fees <= htlcSuccessTargetFee * 1.4, s"actualFee=${htlcSuccessTx2.fees} targetFee=$htlcSuccessTargetFee") + }) + } + + test("htlc success tx deadline reached, increasing fees") { + withFixture(Seq(50 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + import f._ + + val initialFeerate = FeeratePerKw(10_000 sat) + setFeerate(initialFeerate) + val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f, aliceBlockHeight() + 6) + + val htlcSuccessPublisher = createPublisher() + htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess) + val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] + w.replyTo ! WatchParentTxConfirmedTriggered(aliceBlockHeight().toInt, 0, commitTx) + var htlcSuccessTx = getMempoolTxs(1).head + + // We are only 6 blocks away from the deadline, so we bump the fees at each new block. + (1 to 3).foreach(i => { + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + i)) + awaitCond(!isInMempool(htlcSuccessTx.txid), interval = 200 millis, max = 30 seconds) + val bumpedHtlcSuccessTx = getMempoolTxs(1).head + assert(htlcSuccessTx.fees < bumpedHtlcSuccessTx.fees) + htlcSuccessTx = bumpedHtlcSuccessTx + }) + }) + } + + test("htlc timeout tx not confirming, increasing fees") { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + import f._ + + val feerate = FeeratePerKw(15_000 sat) + setFeerate(feerate) + // The deadline for htlc-timeout corresponds to their CLTV: we should claim them asap once the htlc has timed out. + val (commitTx, _, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 144) + + val htlcTimeoutPublisher = createPublisher() + htlcTimeoutPublisher ! Publish(probe.ref, htlcTimeout) + generateBlocks(144) + system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) + val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] + w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) + val htlcTimeoutTx1 = getMempoolTxs(1).head + val htlcTimeoutInputs1 = getMempool().head.txIn.map(_.outPoint).toSet + + // A new block is found, and we've already reached the deadline, so we bump the fees. + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 145)) + awaitCond(!isInMempool(htlcTimeoutTx1.txid), interval = 200 millis, max = 30 seconds) + val htlcTimeoutTx2 = getMempoolTxs(1).head + val htlcTimeoutInputs2 = getMempool().head.txIn.map(_.outPoint).toSet + assert(htlcTimeoutTx1.fees < htlcTimeoutTx2.fees) + assert(htlcTimeoutInputs1 === htlcTimeoutInputs2) + // Once the deadline is reach, we should raise the feerate by at least 20% at every block. + val htlcTimeoutTargetFee = Transactions.weight2fee(feerate * 1.2, htlcTimeoutTx2.weight.toInt) + assert(htlcTimeoutTargetFee * 0.9 <= htlcTimeoutTx2.fees && htlcTimeoutTx2.fees <= htlcTimeoutTargetFee * 1.1, s"actualFee=${htlcTimeoutTx2.fees} targetFee=$htlcTimeoutTargetFee") + }) + } + test("unlock utxos when htlc tx cannot be published") { withFixture(Seq(500 millibtc, 200 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ val targetFeerate = FeeratePerKw(5_000 sat) setFeerate(targetFeerate) - val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f) + val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f, aliceBlockHeight() + 18) val publisher1 = createPublisher() publisher1 ! Publish(probe.ref, htlcSuccess) val w1 = alice2blockchain.expectMsgType[WatchParentTxConfirmed] @@ -752,7 +993,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w import f._ setFeerate(FeeratePerKw(5_000 sat)) - val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f) + val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f, aliceBlockHeight() + 48) publisher ! Publish(probe.ref, htlcSuccess) val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) @@ -764,50 +1005,6 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w }) } - test("adjust htlc tx change amount", Tag("fuzzy")) { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { - val (_, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f) - val commitments = htlcSuccess.commitments - val dustLimit = commitments.localParams.dustLimit - val targetFeerate = TestConstants.feeratePerKw - for (_ <- 1 to 100) { - val walletInputsCount = 1 + Random.nextInt(5) - val walletInputs = (1 to walletInputsCount).map(_ => TxIn(OutPoint(randomBytes32(), 0), Nil, 0)) - val walletAmountIn = dustLimit * walletInputsCount + Random.nextInt(25_000_000).sat - val changeOutput = TxOut(Random.nextLong(walletAmountIn.toLong).sat, Script.pay2wpkh(randomKey().publicKey)) - val unsignedHtlcSuccessTx = HtlcSuccessWithWitnessData(htlcSuccess.txInfo.asInstanceOf[HtlcSuccessTx].copy(tx = htlcSuccess.txInfo.tx.copy( - txIn = htlcSuccess.txInfo.tx.txIn ++ walletInputs, - txOut = htlcSuccess.txInfo.tx.txOut ++ Seq(changeOutput) - )), ByteVector64.Zeroes, ByteVector32.Zeroes) - val unsignedHtlcTimeoutTx = HtlcTimeoutWithWitnessData(htlcTimeout.txInfo.asInstanceOf[HtlcTimeoutTx].copy(tx = htlcTimeout.txInfo.tx.copy( - txIn = htlcTimeout.txInfo.tx.txIn ++ walletInputs, - txOut = htlcTimeout.txInfo.tx.txOut ++ Seq(changeOutput) - )), ByteVector64.Zeroes) - for (unsignedTx <- Seq(unsignedHtlcSuccessTx, unsignedHtlcTimeoutTx)) { - val totalAmountIn = unsignedTx.txInfo.input.txOut.amount + walletAmountIn - val (adjustedTx, fee) = ReplaceableTxFunder.adjustHtlcTxChange(unsignedTx, totalAmountIn, targetFeerate, commitments) - assert(fee === totalAmountIn - adjustedTx.txInfo.tx.txOut.map(_.amount).sum) - assert(adjustedTx.txInfo.tx.txIn.size === unsignedTx.txInfo.tx.txIn.size) - assert(adjustedTx.txInfo.tx.txOut.size === 1 || adjustedTx.txInfo.tx.txOut.size === 2) - if (adjustedTx.txInfo.tx.txOut.size == 2) { - // Simulate tx signing to check final feerate. - val signedTx = { - val htlcSigned = adjustedTx.txInfo match { - case tx: HtlcSuccessTx => Transactions.addSigs(tx, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig, ByteVector32.Zeroes, commitments.commitmentFormat) - case tx: HtlcTimeoutTx => Transactions.addSigs(tx, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig, commitments.commitmentFormat) - } - val signedWalletInputs = htlcSigned.tx.txIn.tail.map(txIn => txIn.copy(witness = ScriptWitness(Seq(Scripts.der(Transactions.PlaceHolderSig), Transactions.PlaceHolderPubKey.value)))) - htlcSigned.tx.copy(txIn = htlcSigned.tx.txIn.head +: signedWalletInputs) - } - val targetFee = Transactions.weight2fee(targetFeerate, signedTx.weight()) - val actualFee = totalAmountIn - signedTx.txOut.map(_.amount).sum - assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee amountIn=$walletAmountIn tx=$signedTx") - } - } - } - }) - } - test("local commit tx confirmed, not publishing claim htlc tx") { withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ @@ -854,7 +1051,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w }) } - def remoteCloseChannelWithHtlcs(f: Fixture): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { + def remoteCloseChannelWithHtlcs(f: Fixture, htlcDeadline: Long): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { import f._ // Add htlcs in both directions and ensure that preimages are available. @@ -880,9 +1077,9 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(1) alice2blockchain.expectMsgType[PublishFinalTx] // claim main output - val claimHtlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx] + val claimHtlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(deadline = htlcDeadline) assert(claimHtlcTimeout.txInfo.isInstanceOf[ClaimHtlcTimeoutTx]) - val claimHtlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx] + val claimHtlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(deadline = htlcDeadline) assert(claimHtlcSuccess.txInfo.isInstanceOf[ClaimHtlcSuccessTx]) alice2blockchain.expectMsgType[WatchTxConfirmed] // commit tx @@ -897,11 +1094,8 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w private def testPublishClaimHtlcSuccess(f: Fixture, remoteCommitTx: Transaction, claimHtlcSuccess: PublishReplaceableTx, targetFeerate: FeeratePerKw): Transaction = { import f._ - // The Claim-HTLC-success tx will be immediately published since the commit tx is confirmed. - setFeerate(targetFeerate, blockTarget = 2) val claimHtlcSuccessPublisher = createPublisher() - val claimHtlcSuccessWithDeadline = claimHtlcSuccess.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight + 4) - claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccessWithDeadline) + claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess) val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, remoteCommitTx) val claimHtlcSuccessTx = getMempoolTxs(1).head @@ -911,7 +1105,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(4) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) val claimHtlcSuccessResult = probe.expectMsgType[TxConfirmed] - assert(claimHtlcSuccessResult.cmd === claimHtlcSuccessWithDeadline) + assert(claimHtlcSuccessResult.cmd === claimHtlcSuccess) assert(claimHtlcSuccessResult.tx.txIn.map(_.outPoint.txid).contains(remoteCommitTx.txid)) claimHtlcSuccessPublisher ! Stop claimHtlcSuccessResult.tx @@ -926,12 +1120,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // The Claim-HTLC-timeout will be published after the timeout. val claimHtlcTimeoutPublisher = createPublisher() - val claimHtlcTimeoutWithDeadline = claimHtlcTimeout.copy(deadline = alice.underlyingActor.nodeParams.currentBlockHeight) - claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeoutWithDeadline) + claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout) alice2blockchain.expectNoMessage(100 millis) generateBlocks(144) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) - setFeerate(targetFeerate, blockTarget = 1) // the feerate is higher than what it was when the channel force-closed + setFeerate(targetFeerate) // the feerate is higher than what it was when the channel force-closed val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, remoteCommitTx) val claimHtlcTimeoutTx = getMempoolTxs(1).head @@ -941,7 +1134,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(4) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) val claimHtlcTimeoutResult = probe.expectMsgType[TxConfirmed] - assert(claimHtlcTimeoutResult.cmd === claimHtlcTimeoutWithDeadline) + assert(claimHtlcTimeoutResult.cmd === claimHtlcTimeout) assert(claimHtlcTimeoutResult.tx.txIn.map(_.outPoint.txid).contains(remoteCommitTx.txid)) claimHtlcTimeoutPublisher ! Stop claimHtlcTimeoutResult.tx @@ -952,7 +1145,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w import f._ val currentFeerate = alice.underlyingActor.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(2) - val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f) + val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 50) val claimHtlcSuccessTx = testPublishClaimHtlcSuccess(f, remoteCommitTx, claimHtlcSuccess, currentFeerate) assert(claimHtlcSuccess.txInfo.fee > 0.sat) assert(claimHtlcSuccessTx.txIn.length === 1) @@ -964,8 +1157,12 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w test("claim htlc tx feerate too low, lowering output amount") { withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputs, f => { + import f._ + val targetFeerate = FeeratePerKw(15_000 sat) - val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f) + val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 32) + // NB: we try to get transactions confirmed *before* their deadline, so we aim for a more aggressive block target than the deadline. + setFeerate(targetFeerate, blockTarget = 12) val claimHtlcSuccessTx = testPublishClaimHtlcSuccess(f, remoteCommitTx, claimHtlcSuccess, targetFeerate) assert(claimHtlcSuccessTx.txIn.length === 1) assert(claimHtlcSuccessTx.txOut.length === 1) @@ -982,7 +1179,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w import f._ val targetFeerate = FeeratePerKw(15_000 sat) - val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f) + val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 300) // The Claim-HTLC-success tx will be immediately published. setFeerate(targetFeerate) @@ -1021,7 +1218,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputs, f => { import f._ - val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f) + val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 300) setFeerate(FeeratePerKw(50_000 sat)) val claimHtlcSuccessPublisher = createPublisher() @@ -1046,4 +1243,76 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w }) } + test("claim htlc tx not confirming, lowering output amount again (standard commitment format)") { + withFixture(Seq(11 millibtc), ChannelTypes.Standard, f => { + import f._ + + val initialFeerate = FeeratePerKw(15_000 sat) + val targetFeerate = FeeratePerKw(20_000 sat) + + val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 144) + + // The Claim-HTLC-success tx will be immediately published. + setFeerate(initialFeerate) + val claimHtlcSuccessPublisher = createPublisher() + claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess) + val claimHtlcSuccessTx1 = getMempoolTxs(1).head + + setFeerate(targetFeerate) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 5)) + awaitCond(!isInMempool(claimHtlcSuccessTx1.txid), interval = 200 millis, max = 30 seconds) + val claimHtlcSuccessTx2 = getMempoolTxs(1).head + assert(claimHtlcSuccessTx1.fees < claimHtlcSuccessTx2.fees) + val targetHtlcSuccessFee = Transactions.weight2fee(targetFeerate, claimHtlcSuccessTx2.weight.toInt) + assert(targetHtlcSuccessFee * 0.9 <= claimHtlcSuccessTx2.fees && claimHtlcSuccessTx2.fees <= targetHtlcSuccessFee * 1.1, s"actualFee=${claimHtlcSuccessTx2.fees} targetFee=$targetHtlcSuccessFee") + val finalHtlcSuccessTx = getMempool().head + assert(finalHtlcSuccessTx.txIn.length === 1) + assert(finalHtlcSuccessTx.txOut.length === 1) + assert(finalHtlcSuccessTx.txIn.head.outPoint.txid === remoteCommitTx.txid) + + // The Claim-HTLC-timeout will be published after the timeout. + setFeerate(initialFeerate) + val claimHtlcTimeoutPublisher = createPublisher() + claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout) + generateBlocks(144) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 144)) + assert(probe.expectMsgType[TxConfirmed].tx.txid === finalHtlcSuccessTx.txid) // the claim-htlc-success is now confirmed + val claimHtlcTimeoutTx1 = getMempoolTxs(1).head + + setFeerate(targetFeerate) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 145)) + awaitCond(!isInMempool(claimHtlcTimeoutTx1.txid), interval = 200 millis, max = 30 seconds) + val claimHtlcTimeoutTx2 = getMempoolTxs(1).head + assert(claimHtlcTimeoutTx1.fees < claimHtlcTimeoutTx2.fees) + val targetHtlcTimeoutFee = Transactions.weight2fee(targetFeerate, claimHtlcTimeoutTx2.weight.toInt) + assert(targetHtlcTimeoutFee * 0.9 <= claimHtlcTimeoutTx2.fees && claimHtlcTimeoutTx2.fees <= targetHtlcTimeoutFee * 1.1, s"actualFee=${claimHtlcTimeoutTx2.fees} targetFee=$targetHtlcTimeoutFee") + val finalHtlcTimeoutTx = getMempool().head + assert(finalHtlcTimeoutTx.txIn.length === 1) + assert(finalHtlcTimeoutTx.txOut.length === 1) + assert(finalHtlcTimeoutTx.txIn.head.outPoint.txid === remoteCommitTx.txid) + }) + } + + test("claim htlc tx not confirming, but cannot lower output amount again") { + withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputs, f => { + import f._ + + val (remoteCommitTx, claimHtlcSuccess, _) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 300) + + setFeerate(FeeratePerKw(5_000 sat)) + val claimHtlcSuccessPublisher = createPublisher() + claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess) + val w1 = alice2blockchain.expectMsgType[WatchParentTxConfirmed] + w1.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, remoteCommitTx) + val claimHtlcSuccessTx = getMempoolTxs(1).head + + // New blocks are found and the feerate is higher, but the htlc would become dust, so we don't bump the fees. + setFeerate(FeeratePerKw(50_000 sat)) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 5)) + probe.expectNoMessage(500 millis) + val mempoolTxs = getMempool() + assert(mempoolTxs.map(_.txid).toSet === Set(claimHtlcSuccessTx.txid)) + }) + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala index d2a63103d2..d8c9a3a603 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala @@ -109,10 +109,10 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike import f._ // alice and bob see different on-chain feerates - alice.feeEstimator.setFeerate(FeeratesPerKw(FeeratePerKw(250 sat), FeeratePerKw(10000 sat), FeeratePerKw(5000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat))) - bob.feeEstimator.setFeerate(FeeratesPerKw(FeeratePerKw(250 sat), FeeratePerKw(15000 sat), FeeratePerKw(7500 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat))) - assert(alice.feeTargets.mutualCloseBlockTarget == 2) - assert(bob.feeTargets.mutualCloseBlockTarget == 2) + alice.feeEstimator.setFeerate(FeeratesPerKw(FeeratePerKw(250 sat), FeeratePerKw(10000 sat), FeeratePerKw(8000 sat), FeeratePerKw(7500 sat), FeeratePerKw(5000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat))) + bob.feeEstimator.setFeerate(FeeratesPerKw(FeeratePerKw(250 sat), FeeratePerKw(15000 sat), FeeratePerKw(12500 sat), FeeratePerKw(10000 sat), FeeratePerKw(7500 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat))) + assert(alice.feeTargets.mutualCloseBlockTarget == 12) + assert(bob.feeTargets.mutualCloseBlockTarget == 12) if (bobInitiates) { bobClose(f) @@ -494,7 +494,7 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike // alice starts with a very low proposal val (aliceClosing1, _) = makeLegacyClosingSigned(f, 500 sat) alice2bob.send(bob, aliceClosing1) - val bobClosing1 = bob2alice.expectMsgType[ClosingSigned] + bob2alice.expectMsgType[ClosingSigned] // at this point bob has received a mutual close signature from alice, but doesn't yet agree on the fee // bob's mutual close is published from the outside of the actor assert(bob.stateName === NEGOTIATING) From b0667538d8dbf95c15b4c77c042787ba9930ea59 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 11 Jan 2022 15:22:11 +0100 Subject: [PATCH 06/23] Fix review comments for 3b1537e * Add a BlockHeight type * Rename deadline to confirmation target * Add comments on HtlcTx type inconsistency --- .../scala/fr/acinq/eclair/BlockHeight.scala | 38 ++++++++++ .../fr/acinq/eclair/channel/Channel.scala | 14 ++-- .../fr/acinq/eclair/channel/ChannelData.scala | 4 + .../publish/ReplaceableTxPublisher.scala | 26 +++---- .../eclair/channel/publish/TxPublisher.scala | 20 ++--- .../eclair/transactions/Transactions.scala | 10 +++ .../publish/ReplaceableTxPublisherSpec.scala | 74 +++++++++---------- .../channel/publish/TxPublisherSpec.scala | 38 +++++----- .../channel/states/e/NormalStateSpec.scala | 20 ++--- .../channel/states/h/ClosingStateSpec.scala | 8 +- 10 files changed, 153 insertions(+), 99 deletions(-) create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/BlockHeight.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/BlockHeight.scala b/eclair-core/src/main/scala/fr/acinq/eclair/BlockHeight.scala new file mode 100644 index 0000000000..33cece8586 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/BlockHeight.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2022 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair + +/** + * Created by t-bast on 11/01/2022. + */ + +case class BlockHeight(private val underlying: Long) extends Ordered[BlockHeight] { + // @formatter:off + override def compare(other: BlockHeight): Int = underlying.compareTo(other.underlying) + def +(other: BlockHeight) = BlockHeight(underlying + other.underlying) + def +(i: Int) = BlockHeight(underlying + i) + def +(l: Long) = BlockHeight(underlying + l) + def -(other: BlockHeight) = BlockHeight(underlying - other.underlying) + def -(i: Int) = BlockHeight(underlying - i) + def -(l: Long) = BlockHeight(underlying - l) + def unary_- = BlockHeight(-underlying) + + def toLong: Long = underlying + def toInt: Int = underlying.toInt + override def toString = underlying.toString + // @formatter:on +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala index 4a3c7daf0e..bb0a98820d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala @@ -2473,14 +2473,15 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo case _: Transactions.HtlcSuccessTx => commitments.localCommit.spec.findIncomingHtlcById(tx.htlcId).map(_.add) case _: Transactions.HtlcTimeoutTx => commitments.localCommit.spec.findOutgoingHtlcById(tx.htlcId).map(_.add) } - val deadline = htlc_opt.map(_.cltvExpiry.toLong).getOrElse(nodeParams.currentBlockHeight + nodeParams.onChainFeeConf.feeTargets.commitmentBlockTarget) - PublishReplaceableTx(tx, commitments, deadline) + (tx, htlc_opt) + }.collect { + case (tx, Some(htlc)) => PublishReplaceableTx(tx, commitments, BlockHeight(htlc.cltvExpiry.toLong)) } val claimLocalAnchor = claimAnchorTxs.collect { case tx: Transactions.ClaimLocalAnchorOutputTx => // NB: if we don't have pending HTLCs, we don't have funds at risk, so we can use a longer deadline. - val deadline = redeemableHtlcTxs.map(_.deadline).minOption.getOrElse(nodeParams.currentBlockHeight + nodeParams.onChainFeeConf.feeTargets.claimMainBlockTarget) - PublishReplaceableTx(tx, commitments, deadline) + val confirmationTarget = redeemableHtlcTxs.map(_.confirmationTarget.toLong).minOption.getOrElse(nodeParams.currentBlockHeight + nodeParams.onChainFeeConf.feeTargets.claimMainBlockTarget) + PublishReplaceableTx(tx, commitments, BlockHeight(confirmationTarget)) } List(PublishFinalTx(commitTx, commitInput, "commit-tx", Closing.commitTxFee(commitments.commitInput, commitTx, isFunder), None)) ++ claimLocalAnchor ++ claimMainDelayedOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)) ++ redeemableHtlcTxs ++ claimHtlcDelayedTxs.map(tx => PublishFinalTx(tx, tx.fee, None)) } @@ -2558,8 +2559,9 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo case _: Transactions.ClaimHtlcSuccessTx => commitments.remoteNextCommitInfo.left.toOption.flatMap(_.nextRemoteCommit.spec.findOutgoingHtlcById(tx.htlcId)).orElse(commitments.remoteCommit.spec.findOutgoingHtlcById(tx.htlcId)).map(_.add) case _: Transactions.ClaimHtlcTimeoutTx => commitments.remoteNextCommitInfo.left.toOption.flatMap(_.nextRemoteCommit.spec.findIncomingHtlcById(tx.htlcId)).orElse(commitments.remoteCommit.spec.findIncomingHtlcById(tx.htlcId)).map(_.add) } - val deadline = htlc_opt.map(_.cltvExpiry.toLong).getOrElse(nodeParams.currentBlockHeight + nodeParams.onChainFeeConf.feeTargets.commitmentBlockTarget) - PublishReplaceableTx(tx, commitments, deadline) + (tx, htlc_opt) + }.collect { + case (tx, Some(htlc)) => PublishReplaceableTx(tx, commitments, BlockHeight(htlc.cltvExpiry.toLong)) } val publishQueue = claimMainOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)).toSeq ++ redeemableHtlcTxs publishIfNeeded(publishQueue, irrevocablySpent) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala index 6997fc9768..e7e3093d71 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala @@ -290,6 +290,8 @@ sealed trait CommitPublished { * None only for incoming HTLCs for which we don't have the preimage (we can't claim them yet). * @param claimHtlcDelayedTxs 3rd-stage txs (spending the output of HTLC txs). * @param claimAnchorTxs txs spending anchor outputs to bump the feerate of the commitment tx (if applicable). + * We currently only claim our local anchor, but it would be nice to claim both when it + * is economical to do so to avoid polluting the utxo set. */ case class LocalCommitPublished(commitTx: Transaction, claimMainDelayedOutputTx: Option[ClaimLocalDelayedOutputTx], htlcTxs: Map[OutPoint, Option[HtlcTx]], claimHtlcDelayedTxs: List[HtlcDelayedTx], claimAnchorTxs: List[ClaimAnchorOutputTx], irrevocablySpent: Map[OutPoint, Transaction]) extends CommitPublished { /** @@ -322,6 +324,8 @@ case class LocalCommitPublished(commitTx: Transaction, claimMainDelayedOutputTx: * @param claimHtlcTxs txs claiming HTLCs. There will be one entry for each pending HTLC. The value will be None * only for incoming HTLCs for which we don't have the preimage (we can't claim them yet). * @param claimAnchorTxs txs spending anchor outputs to bump the feerate of the commitment tx (if applicable). + * We currently only claim our local anchor, but it would be nice to claim both when it is + * economical to do so to avoid polluting the utxo set. */ case class RemoteCommitPublished(commitTx: Transaction, claimMainOutputTx: Option[ClaimRemoteCommitMainOutputTx], claimHtlcTxs: Map[OutPoint, Option[ClaimHtlcTx]], claimAnchorTxs: List[ClaimAnchorOutputTx], irrevocablySpent: Map[OutPoint, Transaction]) extends CommitPublished { /** diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index 12e08a36af..419fa36c9b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -20,7 +20,6 @@ import akka.actor.typed.eventstream.EventStream import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler} import akka.actor.typed.{ActorRef, Behavior} import fr.acinq.bitcoin.{OutPoint, Transaction} -import fr.acinq.eclair.NodeParams import fr.acinq.eclair.blockchain.CurrentBlockCount import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient @@ -28,6 +27,7 @@ import fr.acinq.eclair.blockchain.fee.{FeeEstimator, FeeratePerKw} import fr.acinq.eclair.channel.publish.ReplaceableTxFunder.FundedTx import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher.{ClaimLocalAnchorWithWitnessData, ReplaceableTxWithWitnessData} import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishLogContext +import fr.acinq.eclair.{BlockHeight, NodeParams} import scala.concurrent.duration.{DurationInt, DurationLong} import scala.concurrent.{ExecutionContext, Future} @@ -39,7 +39,7 @@ import scala.util.{Random, Success} /** * This actor sets the fees, signs and publishes a transaction that can be RBF-ed. - * It regularly RBFs the transaction as we get closer to its deadline. + * It regularly RBFs the transaction as we get closer to its confirmation target. * It waits for confirmation or failure before reporting back to the requesting actor. */ object ReplaceableTxPublisher { @@ -75,14 +75,14 @@ object ReplaceableTxPublisher { } } - def getFeerate(feeEstimator: FeeEstimator, deadline: Long, currentBlockHeight: Long): FeeratePerKw = { - val remainingBlocks = deadline - currentBlockHeight + def getFeerate(feeEstimator: FeeEstimator, confirmationTarget: BlockHeight, currentBlockHeight: BlockHeight): FeeratePerKw = { + val remainingBlocks = (confirmationTarget - currentBlockHeight).toLong val blockTarget = remainingBlocks match { // If our target is still very far in the future, no need to rush case t if t >= 144 => 144 case t if t >= 72 => 72 case t if t >= 36 => 36 - // However, if we get closer to the deadline, we start being more aggressive + // However, if we get closer to the target, we start being more aggressive case t if t >= 18 => 12 case t if t >= 12 => 6 case t if t >= 2 => 2 @@ -138,7 +138,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc } def fund(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = { - val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.deadline, nodeParams.currentBlockHeight) + val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.confirmationTarget, BlockHeight(nodeParams.currentBlockHeight)) val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, loggingInfo), "tx-funder") txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, Right(txWithWitnessData), targetFeerate) Behaviors.receiveMessagePartial { @@ -164,7 +164,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc } // Wait for our transaction to be confirmed or rejected from the mempool. - // If we get close to the deadline and our transaction is stuck in the mempool, we will initiate an RBF attempt. + // If we get close to the confirmation target and our transaction is stuck in the mempool, we will initiate an RBF attempt. def wait(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, txMonitor: ActorRef[MempoolTxMonitor.Command], tx: FundedTx): Behavior[Command] = { Behaviors.receiveMessagePartial { case WrappedTxResult(MempoolTxMonitor.TxConfirmed(confirmedTx)) => sendResult(replyTo, TxPublisher.TxConfirmed(cmd, confirmedTx)) @@ -177,16 +177,16 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc timers.startSingleTimer(CheckFeeKey, CheckFee(currentBlockCount), (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis) Behaviors.same case CheckFee(currentBlockCount) => - val currentFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.deadline, currentBlockCount) - val targetFeerate_opt = if (cmd.deadline <= currentBlockCount + 6) { - log.debug("{} deadline is close (in {} blocks): bumping fees", cmd.desc, cmd.deadline - currentBlockCount) - // We make sure we increase the fees by at least 20% as we get close to the deadline. + val currentFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.confirmationTarget, BlockHeight(currentBlockCount)) + val targetFeerate_opt = if (cmd.confirmationTarget.toLong <= currentBlockCount + 6) { + log.debug("{} confirmation target is close (in {} blocks): bumping fees", cmd.desc, cmd.confirmationTarget.toLong - currentBlockCount) + // We make sure we increase the fees by at least 20% as we get close to the confirmation target. Some(currentFeerate.max(tx.feerate * 1.2)) } else if (tx.feerate * 1.2 <= currentFeerate) { - log.debug("{} deadline is in {} blocks: bumping fees", cmd.desc, cmd.deadline - currentBlockCount) + log.debug("{} confirmation target is in {} blocks: bumping fees", cmd.desc, cmd.confirmationTarget.toLong - currentBlockCount) Some(currentFeerate) } else { - log.debug("{} deadline is in {} blocks: no need to bump fees", cmd.desc, cmd.deadline - currentBlockCount) + log.debug("{} confirmation target is in {} blocks: no need to bump fees", cmd.desc, cmd.confirmationTarget.toLong - currentBlockCount) None } targetFeerate_opt.foreach(targetFeerate => { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala index 0eb362e1c7..6df21f55a4 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala @@ -26,7 +26,7 @@ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.channel.Commitments import fr.acinq.eclair.transactions.Transactions.{ReplaceableTransactionWithInputInfo, TransactionWithInputInfo} -import fr.acinq.eclair.{Logs, NodeParams} +import fr.acinq.eclair.{BlockHeight, Logs, NodeParams} import java.util.UUID import scala.concurrent.duration.DurationLong @@ -50,7 +50,7 @@ object TxPublisher { // +---------+ | +--------------------+ // | PublishTx | TxPublisher | // +------------>| - stores txs and |---+ +-----------------+ - // | deadlines | | create child actor | TxPublish | + // | block targets | | create child actor | TxPublish | // | - create child | | ask it to publish | - preconditions | // | actors that fund | | at a given feerate | - (funding) | // | and publish txs | +--------------------->| - (signing) | @@ -85,7 +85,7 @@ object TxPublisher { def apply(txInfo: TransactionWithInputInfo, fee: Satoshi, parentTx_opt: Option[ByteVector32]): PublishFinalTx = PublishFinalTx(txInfo.tx, txInfo.input.outPoint, txInfo.desc, fee, parentTx_opt) } /** Publish an unsigned transaction that can be RBF-ed. */ - case class PublishReplaceableTx(txInfo: ReplaceableTransactionWithInputInfo, commitments: Commitments, deadline: Long) extends PublishTx { + case class PublishReplaceableTx(txInfo: ReplaceableTransactionWithInputInfo, commitments: Commitments, confirmationTarget: BlockHeight) extends PublishTx { override def input: OutPoint = txInfo.input.outPoint override def desc: String = txInfo.desc } @@ -196,12 +196,12 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact case cmd: PublishReplaceableTx => val attempts = pending.getOrElse(cmd.input, Seq.empty) val alreadyPublished = attempts.collectFirst { - // If there is already an attempt at spending this outpoint with a more aggressive deadline, there is no point in publishing again. - case a: ReplaceableAttempt if a.cmd.deadline <= cmd.deadline => a.cmd.deadline + // If there is already an attempt at spending this outpoint with a more aggressive confirmation target, there is no point in publishing again. + case a: ReplaceableAttempt if a.cmd.confirmationTarget <= cmd.confirmationTarget => a.cmd.confirmationTarget } alreadyPublished match { - case Some(currentDeadline) => - log.info("not publishing replaceable {} spending {}:{} with deadline={}, publishing is already in progress with deadline={}", cmd.desc, cmd.input.txid, cmd.input.index, cmd.deadline, currentDeadline) + case Some(currentConfirmationTarget) => + log.info("not publishing replaceable {} spending {}:{} with confirmation target={}, publishing is already in progress with confirmation target={}", cmd.desc, cmd.input.txid, cmd.input.index, cmd.confirmationTarget, currentConfirmationTarget) Behaviors.same case None => val publishId = UUID.randomUUID() @@ -240,9 +240,9 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact // it doesn't make sense to retry, we will keep getting rejected. run(pending2, retryNextBlock, channelInfo) case _: PublishReplaceableTx => - // The mempool contains a transaction that pays more fees, but as we get closer to the deadline, we will - // try to publish with higher fees, so if the conflicting transaction doesn't confirm, we should be able - // to replace it before we reach the deadline. + // The mempool contains a transaction that pays more fees, but as we get closer to the confirmation + // target, we will try to publish with higher fees, so if the conflicting transaction doesn't confirm, + // we should be able to replace it before we reach the confirmation target. run(pending2, retryNextBlock ++ rejectedAttempts.map(_.cmd), channelInfo) } case TxRejectedReason.ConflictingTxConfirmed => diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 8938f8f58b..22a020cfb7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -112,6 +112,16 @@ object Transactions { sealed trait ReplaceableTransactionWithInputInfo extends TransactionWithInputInfo case class CommitTx(input: InputInfo, tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "commit-tx" } + // It's important to note that htlc transactions with the default commitment format are not actually replaceable: only + // anchor outputs htlc transactions are replaceable. We should have used different types for these different kinds of + // htlc transactions, but we introduced that before implementing the replacement strategy. + // Unfortunately, if we wanted to change that, we would have to update the codecs and implement a migration of channel + // data, which isn't trivial, so we chose to temporarily live with that inconsistency (and have the transaction + // replacement logic abort when non-anchor outputs htlc transactions are provided). + // Ideally, we'd like to implement a dynamic commitment format upgrade mechanism and depreciate the pre-anchor outputs + // format soon, which will get rid of this inconsistency. + // The next time we introduce a new type of commitment, we should avoid repeating that mistake and define separate + // types right from the start. sealed trait HtlcTx extends ReplaceableTransactionWithInputInfo { def htlcId: Long override def sighash(txOwner: TxOwner, commitmentFormat: CommitmentFormat): Int = commitmentFormat match { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala index 33fcd061b5..49cda7bb7e 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala @@ -36,7 +36,7 @@ import fr.acinq.eclair.channel.publish.TxPublisher._ import fr.acinq.eclair.channel.states.{ChannelStateTestsHelperMethods, ChannelStateTestsTags} import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.{MilliSatoshiLong, TestConstants, TestKitBaseClass, randomKey} +import fr.acinq.eclair.{BlockHeight, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomKey} import org.scalatest.BeforeAndAfterAll import org.scalatest.funsuite.AnyFunSuiteLike @@ -71,9 +71,9 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w system.spawnAnonymous(ReplaceableTxPublisher(alice.underlyingActor.nodeParams, wallet, alice2blockchain.ref, TxPublishLogContext(UUID.randomUUID(), randomKey().publicKey, None))) } - def aliceBlockHeight(): Long = alice.underlyingActor.nodeParams.currentBlockHeight + def aliceBlockHeight(): BlockHeight = BlockHeight(alice.underlyingActor.nodeParams.currentBlockHeight) - def bobBlockHeight(): Long = bob.underlyingActor.nodeParams.currentBlockHeight + def bobBlockHeight(): BlockHeight = BlockHeight(bob.underlyingActor.nodeParams.currentBlockHeight) /** Set uniform feerate for all block targets. */ def setFeerate(feerate: FeeratePerKw): Unit = { @@ -150,7 +150,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } } - def closeChannelWithoutHtlcs(f: Fixture, commitDeadline: Long): (PublishFinalTx, PublishReplaceableTx) = { + def closeChannelWithoutHtlcs(f: Fixture, commitTarget: BlockHeight): (PublishFinalTx, PublishReplaceableTx) = { import f._ val commitTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.fullySignedLocalCommitTx(alice.underlyingActor.nodeParams.channelKeyManager) @@ -160,7 +160,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // Forward the commit tx to the publisher. val publishCommitTx = alice2blockchain.expectMsg(PublishFinalTx(commitTx, commitTx.fee, None)) // Forward the anchor tx to the publisher. - val publishAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(deadline = commitDeadline) + val publishAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = commitTarget) assert(publishAnchor.txInfo.input.outPoint.txid === commitTx.tx.txid) assert(publishAnchor.txInfo.isInstanceOf[ClaimLocalAnchorOutputTx]) @@ -312,7 +312,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(getMempool().length === 1) val targetFeerate = FeeratePerKw(3000 sat) - // NB: we try to get transactions confirmed *before* their deadline, so we aim for a more aggressive block target than the deadline. + // NB: we try to get transactions confirmed *before* their confirmation target, so we aim for a more aggressive block target what's provided. setFeerate(targetFeerate, blockTarget = 12) publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published @@ -340,7 +340,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(getMempool().isEmpty) val targetFeerate = FeeratePerKw(3000 sat) - // NB: we try to get transactions confirmed *before* their deadline, so we aim for a more aggressive block target than the deadline. + // NB: we try to get transactions confirmed *before* their confirmation target, so we aim for a more aggressive block target than what's provided. setFeerate(targetFeerate, blockTarget = 12) publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published @@ -372,7 +372,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(utxos, ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ - // NB: we try to get transactions confirmed *before* their deadline, so we aim for a more aggressive block target than the deadline. + // NB: we try to get transactions confirmed *before* their confirmation target, so we aim for a more aggressive block target than what's provided. val targetFeerate = FeeratePerKw(10_000 sat) setFeerate(targetFeerate, blockTarget = 12) val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 32) @@ -396,7 +396,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w }) } - test("commit tx fees not increased when deadline is far and feerate hasn't changed") { + test("commit tx fees not increased when confirmation target is far and feerate hasn't changed") { withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ @@ -412,7 +412,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // A new block is found, but we still have time and the feerate hasn't changed, so we don't bump the fees. // Note that we don't generate blocks, so the transactions are still unconfirmed. - system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 5)) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 5)) probe.expectNoMessage(500 millis) val mempoolTxs2 = getMempool() assert(mempoolTxs.map(_.txid).toSet === mempoolTxs2.map(_.txid).toSet) @@ -438,7 +438,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // A new block is found, and the feerate has increased for our block target, so we bump the fees. val newFeerate = FeeratePerKw(5000 sat) setFeerate(newFeerate, blockTarget = 12) - system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 5)) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 5)) awaitCond(!isInMempool(mempoolAnchorTx1.txid), interval = 200 millis, max = 30 seconds) val mempoolTxs2 = getMempoolTxs(2) val mempoolAnchorTx2 = mempoolTxs2.filter(_.txid != commitTx.tx.txid).head @@ -469,7 +469,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val anchorTx1 = getMempool().filter(_.txid != commitTx.tx.txid).head // A new block is found, and the feerate has increased for our block target, so we bump the fees. - system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 15)) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 15)) awaitCond(!isInMempool(anchorTx1.txid), interval = 200 millis, max = 30 seconds) val anchorTx2 = getMempool().filter(_.txid != commitTx.tx.txid).head // We used different inputs to be able to bump to the desired feerate. @@ -501,7 +501,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // A new block is found, and the feerate has increased for our block target, but we don't have enough funds to bump the fees. system.eventStream.subscribe(probe.ref, classOf[NotifyNodeOperator]) - system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 15)) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 15)) probe.expectMsgType[NotifyNodeOperator] val mempoolTxs2 = getMempool() assert(mempoolTxs1.map(_.txid).toSet === mempoolTxs2.map(_.txid).toSet) @@ -532,7 +532,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // A new block is found, and the feerate has increased for our block target, but we can't use our unconfirmed input. system.eventStream.subscribe(probe.ref, classOf[NotifyNodeOperator]) - system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 15)) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 15)) probe.expectMsgType[NotifyNodeOperator] val mempoolTxs2 = getMempool() assert(mempoolTxs1.map(_.txid).toSet + walletTx.txid === mempoolTxs2.map(_.txid).toSet) @@ -637,7 +637,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w }) } - def closeChannelWithHtlcs(f: Fixture, htlcDeadline: Long): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { + def closeChannelWithHtlcs(f: Fixture, htlcTarget: BlockHeight): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { import f._ // Add htlcs in both directions and ensure that preimages are available. @@ -662,9 +662,9 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.isInstanceOf[ClaimLocalAnchorOutputTx]) alice2blockchain.expectMsgType[PublishFinalTx] // claim main output - val htlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(deadline = htlcDeadline) + val htlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = htlcTarget) assert(htlcSuccess.txInfo.isInstanceOf[HtlcSuccessTx]) - val htlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(deadline = htlcDeadline) + val htlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = htlcTarget) assert(htlcTimeout.txInfo.isInstanceOf[HtlcTimeoutTx]) alice2blockchain.expectMsgType[WatchTxConfirmed] // commit tx @@ -766,7 +766,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val targetFeerate = FeeratePerKw(15_000 sat) val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 64) - // NB: we try to get transactions confirmed *before* their deadline, so we aim for a more aggressive block target than the deadline. + // NB: we try to get transactions confirmed *before* their confirmation target, so we aim for a more aggressive block target than what's provided. setFeerate(targetFeerate, blockTarget = 36) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, targetFeerate) assert(htlcSuccessTx.txIn.length > 1) @@ -781,7 +781,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val targetFeerate = FeeratePerKw(15_000 sat) val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 30) - // NB: we try to get transactions confirmed *before* their deadline, so we aim for a more aggressive block target than the deadline. + // NB: we try to get transactions confirmed *before* their confirmation target, so we aim for a more aggressive block target than what's provided. setFeerate(targetFeerate, blockTarget = 12) assert(htlcSuccess.txInfo.fee === 0.sat) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, targetFeerate) @@ -830,7 +830,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val targetFeerate = FeeratePerKw(8_000 sat) val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 30) - // NB: we try to get transactions confirmed *before* their deadline, so we aim for a more aggressive block target than the deadline. + // NB: we try to get transactions confirmed *before* their confirmation target, so we aim for a more aggressive block target than what's provided. setFeerate(targetFeerate, blockTarget = 12) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, targetFeerate) assert(htlcSuccessTx.txIn.length > 2) @@ -857,7 +857,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // New blocks are found, which makes us aim for a more aggressive block target, so we bump the fees. val targetFeerate = FeeratePerKw(25_000 sat) setFeerate(targetFeerate, blockTarget = 6) - system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 15)) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 15)) awaitCond(!isInMempool(htlcSuccessTx1.txid), interval = 200 millis, max = 30 seconds) val htlcSuccessTx2 = getMempoolTxs(1).head val htlcSuccessInputs2 = getMempool().head.txIn.map(_.outPoint).toSet @@ -886,7 +886,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // New blocks are found, which makes us aim for a more aggressive block target, so we bump the fees. val targetFeerate = FeeratePerKw(75_000 sat) setFeerate(targetFeerate, blockTarget = 2) - system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 10)) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 10)) awaitCond(!isInMempool(htlcSuccessTx1.txid), interval = 200 millis, max = 30 seconds) val htlcSuccessTx2 = getMempoolTxs(1).head val htlcSuccessInputs2 = getMempool().head.txIn.map(_.outPoint).toSet @@ -897,7 +897,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w }) } - test("htlc success tx deadline reached, increasing fees") { + test("htlc success tx confirmation target reached, increasing fees") { withFixture(Seq(50 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ @@ -911,9 +911,9 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w w.replyTo ! WatchParentTxConfirmedTriggered(aliceBlockHeight().toInt, 0, commitTx) var htlcSuccessTx = getMempoolTxs(1).head - // We are only 6 blocks away from the deadline, so we bump the fees at each new block. + // We are only 6 blocks away from the confirmation target, so we bump the fees at each new block. (1 to 3).foreach(i => { - system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + i)) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + i)) awaitCond(!isInMempool(htlcSuccessTx.txid), interval = 200 millis, max = 30 seconds) val bumpedHtlcSuccessTx = getMempoolTxs(1).head assert(htlcSuccessTx.fees < bumpedHtlcSuccessTx.fees) @@ -928,7 +928,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val feerate = FeeratePerKw(15_000 sat) setFeerate(feerate) - // The deadline for htlc-timeout corresponds to their CLTV: we should claim them asap once the htlc has timed out. + // The confirmation target for htlc-timeout corresponds to their CLTV: we should claim them asap once the htlc has timed out. val (commitTx, _, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 144) val htlcTimeoutPublisher = createPublisher() @@ -940,14 +940,14 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val htlcTimeoutTx1 = getMempoolTxs(1).head val htlcTimeoutInputs1 = getMempool().head.txIn.map(_.outPoint).toSet - // A new block is found, and we've already reached the deadline, so we bump the fees. - system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 145)) + // A new block is found, and we've already reached the confirmation target, so we bump the fees. + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 145)) awaitCond(!isInMempool(htlcTimeoutTx1.txid), interval = 200 millis, max = 30 seconds) val htlcTimeoutTx2 = getMempoolTxs(1).head val htlcTimeoutInputs2 = getMempool().head.txIn.map(_.outPoint).toSet assert(htlcTimeoutTx1.fees < htlcTimeoutTx2.fees) assert(htlcTimeoutInputs1 === htlcTimeoutInputs2) - // Once the deadline is reach, we should raise the feerate by at least 20% at every block. + // Once the confirmation target is reach, we should raise the feerate by at least 20% at every block. val htlcTimeoutTargetFee = Transactions.weight2fee(feerate * 1.2, htlcTimeoutTx2.weight.toInt) assert(htlcTimeoutTargetFee * 0.9 <= htlcTimeoutTx2.fees && htlcTimeoutTx2.fees <= htlcTimeoutTargetFee * 1.1, s"actualFee=${htlcTimeoutTx2.fees} targetFee=$htlcTimeoutTargetFee") }) @@ -1051,7 +1051,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w }) } - def remoteCloseChannelWithHtlcs(f: Fixture, htlcDeadline: Long): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { + def remoteCloseChannelWithHtlcs(f: Fixture, htlcTarget: BlockHeight): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { import f._ // Add htlcs in both directions and ensure that preimages are available. @@ -1077,9 +1077,9 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(1) alice2blockchain.expectMsgType[PublishFinalTx] // claim main output - val claimHtlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(deadline = htlcDeadline) + val claimHtlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = htlcTarget) assert(claimHtlcTimeout.txInfo.isInstanceOf[ClaimHtlcTimeoutTx]) - val claimHtlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(deadline = htlcDeadline) + val claimHtlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = htlcTarget) assert(claimHtlcSuccess.txInfo.isInstanceOf[ClaimHtlcSuccessTx]) alice2blockchain.expectMsgType[WatchTxConfirmed] // commit tx @@ -1161,7 +1161,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val targetFeerate = FeeratePerKw(15_000 sat) val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 32) - // NB: we try to get transactions confirmed *before* their deadline, so we aim for a more aggressive block target than the deadline. + // NB: we try to get transactions confirmed *before* their confirmation target, so we aim for a more aggressive block target than what's provided. setFeerate(targetFeerate, blockTarget = 12) val claimHtlcSuccessTx = testPublishClaimHtlcSuccess(f, remoteCommitTx, claimHtlcSuccess, targetFeerate) assert(claimHtlcSuccessTx.txIn.length === 1) @@ -1259,7 +1259,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val claimHtlcSuccessTx1 = getMempoolTxs(1).head setFeerate(targetFeerate) - system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 5)) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 5)) awaitCond(!isInMempool(claimHtlcSuccessTx1.txid), interval = 200 millis, max = 30 seconds) val claimHtlcSuccessTx2 = getMempoolTxs(1).head assert(claimHtlcSuccessTx1.fees < claimHtlcSuccessTx2.fees) @@ -1275,12 +1275,12 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val claimHtlcTimeoutPublisher = createPublisher() claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout) generateBlocks(144) - system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 144)) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 144)) assert(probe.expectMsgType[TxConfirmed].tx.txid === finalHtlcSuccessTx.txid) // the claim-htlc-success is now confirmed val claimHtlcTimeoutTx1 = getMempoolTxs(1).head setFeerate(targetFeerate) - system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 145)) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 145)) awaitCond(!isInMempool(claimHtlcTimeoutTx1.txid), interval = 200 millis, max = 30 seconds) val claimHtlcTimeoutTx2 = getMempoolTxs(1).head assert(claimHtlcTimeoutTx1.fees < claimHtlcTimeoutTx2.fees) @@ -1308,7 +1308,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // New blocks are found and the feerate is higher, but the htlc would become dust, so we don't bump the fees. setFeerate(FeeratePerKw(50_000 sat)) - system.eventStream.publish(CurrentBlockCount(aliceBlockHeight() + 5)) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 5)) probe.expectNoMessage(500 millis) val mempoolTxs = getMempool() assert(mempoolTxs.map(_.txid).toSet === Set(claimHtlcSuccessTx.txid)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala index 8c45900db1..e8c26ef4fd 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala @@ -26,7 +26,7 @@ import fr.acinq.eclair.channel.publish import fr.acinq.eclair.channel.publish.TxPublisher.TxRejectedReason._ import fr.acinq.eclair.channel.publish.TxPublisher._ import fr.acinq.eclair.transactions.Transactions.{ClaimLocalAnchorOutputTx, HtlcSuccessTx, InputInfo} -import fr.acinq.eclair.{NodeParams, TestConstants, TestKitBaseClass, randomBytes32, randomKey} +import fr.acinq.eclair.{BlockHeight, NodeParams, TestConstants, TestKitBaseClass, randomBytes32, randomKey} import org.scalatest.Outcome import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -101,9 +101,9 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { test("publish replaceable tx") { f => import f._ - val deadline = nodeParams.currentBlockHeight + 12 + val confirmationTarget = BlockHeight(nodeParams.currentBlockHeight + 12) val input = OutPoint(randomBytes32(), 3) - val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null, deadline) + val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null, confirmationTarget) txPublisher ! cmd val child = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor val p = child.expectMsgType[ReplaceableTxPublisher.Publish] @@ -113,27 +113,27 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { test("publish replaceable tx duplicate") { f => import f._ - val deadline = nodeParams.currentBlockHeight + 12 + val confirmationTarget = BlockHeight(nodeParams.currentBlockHeight + 12) val input = OutPoint(randomBytes32(), 3) - val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null, deadline) + val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null, confirmationTarget) txPublisher ! cmd val child1 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor val p1 = child1.expectMsgType[ReplaceableTxPublisher.Publish] assert(p1.cmd === cmd) - // We ignore duplicates that don't use a more aggressive deadline: + // We ignore duplicates that don't use a more aggressive confirmation target: txPublisher ! cmd factory.expectNoMessage(100 millis) - val cmdHigherDeadline = cmd.copy(deadline = deadline + 1) - txPublisher ! cmdHigherDeadline + val cmdHigherTarget = cmd.copy(confirmationTarget = confirmationTarget + 1) + txPublisher ! cmdHigherTarget factory.expectNoMessage(100 millis) - // But we retry publishing if the deadline is more aggressive than previous attempts: - val cmdLowerDeadline = cmd.copy(deadline = deadline - 6) - txPublisher ! cmdLowerDeadline + // But we retry publishing if the confirmation target is more aggressive than previous attempts: + val cmdLowerTarget = cmd.copy(confirmationTarget = confirmationTarget - 6) + txPublisher ! cmdLowerTarget val child2 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor val p2 = child2.expectMsgType[ReplaceableTxPublisher.Publish] - assert(p2.cmd === cmdLowerDeadline) + assert(p2.cmd === cmdLowerTarget) } test("stop publishing attempts when transaction confirms") { f => @@ -152,7 +152,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val attempt2 = factory.expectMsgType[FinalTxPublisherSpawned].actor attempt2.expectMsgType[FinalTxPublisher.Publish] - val cmd3 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null, nodeParams.currentBlockHeight) + val cmd3 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null, BlockHeight(nodeParams.currentBlockHeight)) txPublisher ! cmd3 val attempt3 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor attempt3.expectMsgType[ReplaceableTxPublisher.Publish] @@ -174,7 +174,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val attempt1 = factory.expectMsgType[FinalTxPublisherSpawned] attempt1.actor.expectMsgType[FinalTxPublisher.Publish] - val cmd2 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null, nodeParams.currentBlockHeight) + val cmd2 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null, BlockHeight(nodeParams.currentBlockHeight)) txPublisher ! cmd2 val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish] @@ -191,16 +191,16 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { test("publishing attempt fails (not enough funds)") { f => import f._ - val deadline1 = nodeParams.currentBlockHeight + 12 + val target1 = BlockHeight(nodeParams.currentBlockHeight + 12) val input = OutPoint(randomBytes32(), 7) val paymentHash = randomBytes32() - val cmd1 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, deadline1) + val cmd1 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, target1) txPublisher ! cmd1 val attempt1 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt1.actor.expectMsgType[ReplaceableTxPublisher.Publish] - val deadline2 = nodeParams.currentBlockHeight + 6 - val cmd2 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, deadline2) + val target2 = BlockHeight(nodeParams.currentBlockHeight + 6) + val cmd2 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, target2) txPublisher ! cmd2 val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish] @@ -265,7 +265,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val input = OutPoint(randomBytes32(), 7) val paymentHash = randomBytes32() - val cmd = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, nodeParams.currentBlockHeight) + val cmd = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, BlockHeight(nodeParams.currentBlockHeight)) txPublisher ! cmd val attempt1 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt1.actor.expectMsgType[ReplaceableTxPublisher.Publish] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index 90c4c31849..06badb13d5 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -2967,9 +2967,9 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val amountClaimed = claimMain.txOut.head.amount + htlcAmountClaimed assert(amountClaimed === 814880.sat) - // alice sets the publication deadlines to the HTLC expiry - assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcSuccessTx, _, deadline) => (tx.htlcId, deadline) }.toMap === Map(htlcb1.id -> htlcb1.cltvExpiry.toLong)) - assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcTimeoutTx, _, deadline) => (tx.htlcId, deadline) }.toMap === Map(htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) + // alice sets the confirmation targets to the HTLC expiry + assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcSuccessTx, _, confirmationTarget) => (tx.htlcId, confirmationTarget.toLong) }.toMap === Map(htlcb1.id -> htlcb1.cltvExpiry.toLong)) + assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcTimeoutTx, _, confirmationTarget) => (tx.htlcId, confirmationTarget.toLong) }.toMap === Map(htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.txid) @@ -3058,8 +3058,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val amountClaimed = claimMain.txOut.head.amount + htlcAmountClaimed assert(amountClaimed === 822310.sat) - // alice sets the publication deadlines to the HTLC expiry - assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcTimeoutTx, _, deadline) => (tx.htlcId, deadline) }.toMap === Map(htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) + // alice sets the confirmation targets to the HTLC expiry + assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcTimeoutTx, _, confirmationTarget) => (tx.htlcId, confirmationTarget.toLong) }.toMap === Map(htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.txid) // claim-main @@ -3319,15 +3319,15 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) val localAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] - assert(localAnchor.deadline === htlca1.cltvExpiry.toLong) // the deadline is set to match the first htlc that expires + assert(localAnchor.confirmationTarget.toLong === htlca1.cltvExpiry.toLong) // the target is set to match the first htlc that expires val claimMain = alice2blockchain.expectMsgType[PublishFinalTx] // alice can only claim 3 out of 4 htlcs, she can't do anything regarding the htlc sent by bob for which she does not have the preimage - val htlcDeadlines = Seq( + val htlcConfirmationTargets = Seq( alice2blockchain.expectMsgType[PublishReplaceableTx], // htlc 1 alice2blockchain.expectMsgType[PublishReplaceableTx], // htlc 2 alice2blockchain.expectMsgType[PublishReplaceableTx], // htlc 3 - ).map(p => p.txInfo.asInstanceOf[HtlcTx].htlcId -> p.deadline).toMap - assert(htlcDeadlines === Map(htlcb1.id -> htlcb1.cltvExpiry.toLong, htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) + ).map(p => p.txInfo.asInstanceOf[HtlcTx].htlcId -> p.confirmationTarget.toLong).toMap + assert(htlcConfirmationTargets === Map(htlcb1.id -> htlcb1.cltvExpiry.toLong, htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === aliceCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.tx.txid) @@ -3357,7 +3357,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val blockTargets = alice.underlyingActor.nodeParams.onChainFeeConf.feeTargets val localAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] // When there are no pending HTLCs, there is no rush to get the commit tx confirmed - assert(localAnchor.deadline === currentBlockHeight + blockTargets.claimMainBlockTarget) + assert(localAnchor.confirmationTarget.toLong === currentBlockHeight + blockTargets.claimMainBlockTarget) val claimMain = alice2blockchain.expectMsgType[PublishFinalTx] assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === aliceCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.tx.txid) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala index e53bf46978..a209233424 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala @@ -777,7 +777,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Transaction.correctlySpends(claimHtlcSuccessTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) val publishHtlcSuccessTx = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(publishHtlcSuccessTx.txInfo.tx === claimHtlcSuccessTx) - assert(publishHtlcSuccessTx.deadline === htlc1.cltvExpiry.toLong) + assert(publishHtlcSuccessTx.confirmationTarget.toLong === htlc1.cltvExpiry.toLong) // Alice resets watches on all relevant transactions. assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) @@ -820,7 +820,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with closingState.claimMainOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[PublishFinalTx].tx === claimMain.tx)) val publishHtlcTimeoutTx = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(publishHtlcTimeoutTx.txInfo.tx === htlcTimeoutTx.tx) - assert(publishHtlcTimeoutTx.deadline === htlca.cltvExpiry.toLong) + assert(publishHtlcTimeoutTx.confirmationTarget.toLong === htlca.cltvExpiry.toLong) closingState.claimMainOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.tx.txid)) assert(alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex === htlcTimeoutTx.input.outPoint.index) } @@ -952,10 +952,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Transaction.correctlySpends(claimHtlcSuccessTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) val publishHtlcSuccessTx = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(publishHtlcSuccessTx.txInfo.tx === claimHtlcSuccessTx) - assert(publishHtlcSuccessTx.deadline === htlc1.cltvExpiry.toLong) + assert(publishHtlcSuccessTx.confirmationTarget.toLong === htlc1.cltvExpiry.toLong) val publishHtlcTimeoutTx = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(publishHtlcTimeoutTx.txInfo.tx === claimHtlcTimeoutTx) - assert(publishHtlcTimeoutTx.deadline === htlc2.cltvExpiry.toLong) + assert(publishHtlcTimeoutTx.confirmationTarget.toLong === htlc2.cltvExpiry.toLong) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === closingState.claimMainOutputTx.get.tx.txid) From 3cd7b83c3b5de4777b922cf3af6483c5336f6c7f Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 12 Jan 2022 15:31:11 +0100 Subject: [PATCH 07/23] Fix review comments for 9bfdd90 * Remove toString on BlockHeight * Add confirmBefore to ReplaceableTransactionWithInputInfo * Add new codecs to add confirmation target to txs --- eclair-core/src/main/resources/reference.conf | 9 +-- .../scala/fr/acinq/eclair/BlockHeight.scala | 1 - .../scala/fr/acinq/eclair/NodeParams.scala | 1 + .../eclair/blockchain/fee/FeeEstimator.scala | 2 +- .../fr/acinq/eclair/channel/Channel.scala | 44 ++++----------- .../fr/acinq/eclair/channel/Helpers.scala | 26 +++++---- .../publish/ReplaceableTxPrePublisher.scala | 4 +- .../publish/ReplaceableTxPublisher.scala | 16 +++--- .../eclair/channel/publish/TxPublisher.scala | 8 +-- .../acinq/eclair/json/JsonSerializers.scala | 21 ++++++- .../eclair/transactions/Transactions.scala | 55 ++++++++++--------- .../channel/version0/ChannelCodecs0.scala | 10 ++-- .../channel/version0/ChannelTypes0.scala | 10 ++-- .../channel/version1/ChannelCodecs1.scala | 9 +-- .../channel/version2/ChannelCodecs2.scala | 11 ++-- .../channel/version3/ChannelCodecs3.scala | 55 +++++++++++++------ .../eclair/wire/protocol/CommonCodecs.scala | 7 ++- .../scala/fr/acinq/eclair/TestConstants.scala | 4 +- .../blockchain/fee/FeeEstimatorSpec.scala | 6 +- .../eclair/channel/CommitmentsSpec.scala | 2 +- .../publish/ReplaceableTxFunderSpec.scala | 17 ++++-- .../publish/ReplaceableTxPublisherSpec.scala | 27 +++++---- .../channel/publish/TxPublisherSpec.scala | 25 +++++---- .../channel/states/e/NormalStateSpec.scala | 18 +++--- .../channel/states/e/OfflineStateSpec.scala | 2 +- .../channel/states/h/ClosingStateSpec.scala | 8 +-- .../eclair/json/JsonSerializersSpec.scala | 10 ++-- .../transactions/TransactionsSpec.scala | 13 ++++- .../channel/version3/ChannelCodecs3Spec.scala | 54 +++++++++++++++++- 29 files changed, 283 insertions(+), 192 deletions(-) diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index cffe0e41f0..7f89775901 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -144,10 +144,11 @@ eclair { // number of blocks to target when computing fees for each transaction type target-blocks { - funding = 6 // target for the funding transaction - commitment = 2 // target for the commitment transaction (used in force-close scenario) *do not change this unless you know what you are doing* - mutual-close = 12 // target for the mutual close transaction - claim-main = 12 // target for the claim main transaction (tx that spends main channel output back to wallet) + funding = 6 // target for the funding transaction + commitment = 2 // target for the commitment transaction (used in force-close scenario) *do not change this unless you know what you are doing* + commitment-without-htlcs = 12 // target for the commitment transaction when we have no htlcs to claim (used in force-close scenario) *do not change this unless you know what you are doing* + mutual-close = 12 // target for the mutual close transaction + claim-main = 12 // target for the claim main transaction (tx that spends main channel output back to wallet) } feerate-tolerance { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/BlockHeight.scala b/eclair-core/src/main/scala/fr/acinq/eclair/BlockHeight.scala index 33cece8586..9189d9db54 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/BlockHeight.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/BlockHeight.scala @@ -33,6 +33,5 @@ case class BlockHeight(private val underlying: Long) extends Ordered[BlockHeight def toLong: Long = underlying def toInt: Int = underlying.toInt - override def toString = underlying.toString // @formatter:on } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index d8129c1194..98526d0b1c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -316,6 +316,7 @@ object NodeParams extends Logging { val feeTargets = FeeTargets( fundingBlockTarget = config.getInt("on-chain-fees.target-blocks.funding"), commitmentBlockTarget = config.getInt("on-chain-fees.target-blocks.commitment"), + commitmentWithoutHtlcsBlockTarget = config.getInt("on-chain-fees.target-blocks.commitment-without-htlcs"), mutualCloseBlockTarget = config.getInt("on-chain-fees.target-blocks.mutual-close"), claimMainBlockTarget = config.getInt("on-chain-fees.target-blocks.claim-main") ) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala index 7dd4ec744b..10590d8965 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala @@ -30,7 +30,7 @@ trait FeeEstimator { // @formatter:on } -case class FeeTargets(fundingBlockTarget: Int, commitmentBlockTarget: Int, mutualCloseBlockTarget: Int, claimMainBlockTarget: Int) +case class FeeTargets(fundingBlockTarget: Int, commitmentBlockTarget: Int, commitmentWithoutHtlcsBlockTarget: Int, mutualCloseBlockTarget: Int, claimMainBlockTarget: Int) /** * @param maxExposure maximum exposure to pending dust htlcs we tolerate: we will automatically fail HTLCs when going above this threshold. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala index bb0a98820d..bc0f1e9469 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala @@ -1405,12 +1405,12 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo Commitments.sendFulfill(d.commitments, c) match { case Right((commitments1, _)) => log.info("got valid payment preimage, recalculating transactions to redeem the corresponding htlc on-chain") - val localCommitPublished1 = d.localCommitPublished.map(localCommitPublished => Helpers.Closing.claimCurrentLocalCommitTxOutputs(keyManager, commitments1, localCommitPublished.commitTx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets)) - val remoteCommitPublished1 = d.remoteCommitPublished.map(remoteCommitPublished => Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, commitments1, commitments1.remoteCommit, remoteCommitPublished.commitTx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets)) + val localCommitPublished1 = d.localCommitPublished.map(localCommitPublished => Helpers.Closing.claimCurrentLocalCommitTxOutputs(keyManager, commitments1, localCommitPublished.commitTx, BlockHeight(nodeParams.currentBlockHeight), nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets)) + val remoteCommitPublished1 = d.remoteCommitPublished.map(remoteCommitPublished => Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, commitments1, commitments1.remoteCommit, remoteCommitPublished.commitTx, BlockHeight(nodeParams.currentBlockHeight), nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets)) val nextRemoteCommitPublished1 = d.nextRemoteCommitPublished.map(remoteCommitPublished => { require(commitments1.remoteNextCommitInfo.isLeft, "next remote commit must be defined") val remoteCommit = commitments1.remoteNextCommitInfo.swap.toOption.get.nextRemoteCommit - Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, commitments1, remoteCommit, remoteCommitPublished.commitTx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) + Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, commitments1, remoteCommit, remoteCommitPublished.commitTx, BlockHeight(nodeParams.currentBlockHeight), nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) }) def republish(): Unit = { @@ -2413,7 +2413,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo stay() } else { val commitTx = d.commitments.fullySignedLocalCommitTx(keyManager).tx - val localCommitPublished = Helpers.Closing.claimCurrentLocalCommitTxOutputs(keyManager, d.commitments, commitTx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) + val localCommitPublished = Helpers.Closing.claimCurrentLocalCommitTxOutputs(keyManager, d.commitments, commitTx, BlockHeight(nodeParams.currentBlockHeight), nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) val nextData = d match { case closing: DATA_CLOSING => closing.copy(localCommitPublished = Some(localCommitPublished)) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, fundingTx = None, waitingSinceBlock = nodeParams.currentBlockHeight, negotiating.closingTxProposed.flatten.map(_.unsignedTx), localCommitPublished = Some(localCommitPublished)) @@ -2467,22 +2467,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo val redeemableHtlcTxs = htlcTxs.values.flatten.map(tx => PublishFinalTx(tx, tx.fee, Some(commitTx.txid))) List(PublishFinalTx(commitTx, commitInput, "commit-tx", Closing.commitTxFee(commitments.commitInput, commitTx, isFunder), None)) ++ (claimMainDelayedOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)) ++ redeemableHtlcTxs ++ claimHtlcDelayedTxs.map(tx => PublishFinalTx(tx, tx.fee, None))) case _: Transactions.AnchorOutputsCommitmentFormat => - val redeemableHtlcTxs = htlcTxs.values.collect { - case Some(tx) => - val htlc_opt = tx match { - case _: Transactions.HtlcSuccessTx => commitments.localCommit.spec.findIncomingHtlcById(tx.htlcId).map(_.add) - case _: Transactions.HtlcTimeoutTx => commitments.localCommit.spec.findOutgoingHtlcById(tx.htlcId).map(_.add) - } - (tx, htlc_opt) - }.collect { - case (tx, Some(htlc)) => PublishReplaceableTx(tx, commitments, BlockHeight(htlc.cltvExpiry.toLong)) - } - val claimLocalAnchor = claimAnchorTxs.collect { - case tx: Transactions.ClaimLocalAnchorOutputTx => - // NB: if we don't have pending HTLCs, we don't have funds at risk, so we can use a longer deadline. - val confirmationTarget = redeemableHtlcTxs.map(_.confirmationTarget.toLong).minOption.getOrElse(nodeParams.currentBlockHeight + nodeParams.onChainFeeConf.feeTargets.claimMainBlockTarget) - PublishReplaceableTx(tx, commitments, BlockHeight(confirmationTarget)) - } + val redeemableHtlcTxs = htlcTxs.values.flatten.map(tx => PublishReplaceableTx(tx, commitments)) + val claimLocalAnchor = claimAnchorTxs.collect { case tx: Transactions.ClaimLocalAnchorOutputTx => PublishReplaceableTx(tx, commitments) } List(PublishFinalTx(commitTx, commitInput, "commit-tx", Closing.commitTxFee(commitments.commitInput, commitTx, isFunder), None)) ++ claimLocalAnchor ++ claimMainDelayedOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)) ++ redeemableHtlcTxs ++ claimHtlcDelayedTxs.map(tx => PublishFinalTx(tx, tx.fee, None)) } publishIfNeeded(publishQueue, irrevocablySpent) @@ -2505,7 +2491,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo require(commitTx.txid == d.commitments.remoteCommit.txid, "txid mismatch") context.system.eventStream.publish(TransactionPublished(d.channelId, remoteNodeId, commitTx, Closing.commitTxFee(d.commitments.commitInput, commitTx, d.commitments.localParams.isFunder), "remote-commit")) - val remoteCommitPublished = Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, d.commitments, d.commitments.remoteCommit, commitTx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) + val remoteCommitPublished = Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, d.commitments, d.commitments.remoteCommit, commitTx, BlockHeight(nodeParams.currentBlockHeight), nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) val nextData = d match { case closing: DATA_CLOSING => closing.copy(remoteCommitPublished = Some(remoteCommitPublished)) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, fundingTx = None, waitingSinceBlock = nodeParams.currentBlockHeight, negotiating.closingTxProposed.flatten.map(_.unsignedTx), remoteCommitPublished = Some(remoteCommitPublished)) @@ -2539,7 +2525,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo require(commitTx.txid == remoteCommit.txid, "txid mismatch") context.system.eventStream.publish(TransactionPublished(d.channelId, remoteNodeId, commitTx, Closing.commitTxFee(d.commitments.commitInput, commitTx, d.commitments.localParams.isFunder), "next-remote-commit")) - val remoteCommitPublished = Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, d.commitments, remoteCommit, commitTx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) + val remoteCommitPublished = Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, d.commitments, remoteCommit, commitTx, BlockHeight(nodeParams.currentBlockHeight), nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) val nextData = d match { case closing: DATA_CLOSING => closing.copy(nextRemoteCommitPublished = Some(remoteCommitPublished)) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, fundingTx = None, waitingSinceBlock = nodeParams.currentBlockHeight, negotiating.closingTxProposed.flatten.map(_.unsignedTx), nextRemoteCommitPublished = Some(remoteCommitPublished)) @@ -2552,17 +2538,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo private def doPublish(remoteCommitPublished: RemoteCommitPublished, commitments: Commitments): Unit = { import remoteCommitPublished._ - val redeemableHtlcTxs = claimHtlcTxs.values.collect { - case Some(tx) => - val htlc_opt = tx match { - case _: Transactions.LegacyClaimHtlcSuccessTx => commitments.remoteNextCommitInfo.left.toOption.flatMap(_.nextRemoteCommit.spec.findOutgoingHtlcById(tx.htlcId)).orElse(commitments.remoteCommit.spec.findOutgoingHtlcById(tx.htlcId)).map(_.add) - case _: Transactions.ClaimHtlcSuccessTx => commitments.remoteNextCommitInfo.left.toOption.flatMap(_.nextRemoteCommit.spec.findOutgoingHtlcById(tx.htlcId)).orElse(commitments.remoteCommit.spec.findOutgoingHtlcById(tx.htlcId)).map(_.add) - case _: Transactions.ClaimHtlcTimeoutTx => commitments.remoteNextCommitInfo.left.toOption.flatMap(_.nextRemoteCommit.spec.findIncomingHtlcById(tx.htlcId)).orElse(commitments.remoteCommit.spec.findIncomingHtlcById(tx.htlcId)).map(_.add) - } - (tx, htlc_opt) - }.collect { - case (tx, Some(htlc)) => PublishReplaceableTx(tx, commitments, BlockHeight(htlc.cltvExpiry.toLong)) - } + val redeemableHtlcTxs = claimHtlcTxs.values.flatten.map(tx => PublishReplaceableTx(tx, commitments)) val publishQueue = claimMainOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)).toSeq ++ redeemableHtlcTxs publishIfNeeded(publishQueue, irrevocablySpent) @@ -2627,7 +2603,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo // let's try to spend our current local tx val commitTx = d.commitments.fullySignedLocalCommitTx(keyManager).tx - val localCommitPublished = Helpers.Closing.claimCurrentLocalCommitTxOutputs(keyManager, d.commitments, commitTx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) + val localCommitPublished = Helpers.Closing.claimCurrentLocalCommitTxOutputs(keyManager, d.commitments, commitTx, BlockHeight(nodeParams.currentBlockHeight), nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) goto(ERR_INFORMATION_LEAK) calling doPublish(localCommitPublished, d.commitments) sending error } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 026059ae1d..1c699f7b2d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -633,7 +633,7 @@ object Helpers { * @param commitments our commitment data, which include payment preimages * @return a list of transactions (one per output of the commit tx that we can claim) */ - def claimCurrentLocalCommitTxOutputs(keyManager: ChannelKeyManager, commitments: Commitments, tx: Transaction, feeEstimator: FeeEstimator, feeTargets: FeeTargets)(implicit log: LoggingAdapter): LocalCommitPublished = { + def claimCurrentLocalCommitTxOutputs(keyManager: ChannelKeyManager, commitments: Commitments, tx: Transaction, currentBlockHeight: BlockHeight, feeEstimator: FeeEstimator, feeTargets: FeeTargets)(implicit log: LoggingAdapter): LocalCommitPublished = { import commitments._ require(localCommit.commitTxAndRemoteSig.commitTx.tx.txid == tx.txid, "txid mismatch, provided tx is not the current local commit tx") val channelKeyPath = keyManager.keyPath(localParams, channelConfig) @@ -655,7 +655,7 @@ object Helpers { val preimages = commitments.localChanges.all.collect { case u: UpdateFulfillHtlc => u.paymentPreimage }.map(r => Crypto.sha256(r) -> r).toMap val htlcTxs: Map[OutPoint, Option[HtlcTx]] = localCommit.htlcTxsAndRemoteSigs.collect { - case HtlcTxAndRemoteSig(txInfo@HtlcSuccessTx(_, _, paymentHash, _), remoteSig) => + case HtlcTxAndRemoteSig(txInfo@HtlcSuccessTx(_, _, paymentHash, _, _), remoteSig) => if (preimages.contains(paymentHash)) { // incoming htlc for which we have the preimage: we can spend it immediately txInfo.input.outPoint -> withTxGenerationLog("htlc-success") { @@ -675,9 +675,11 @@ object Helpers { } }.toMap + // If we don't have pending HTLCs, we don't have funds at risk, so we can aim for a slower confirmation. + val confirmCommitBefore = htlcTxs.values.flatten.map(htlcTx => htlcTx.confirmBefore).minOption.getOrElse(currentBlockHeight + feeTargets.commitmentWithoutHtlcsBlockTarget) val claimAnchorTxs: List[ClaimAnchorOutputTx] = List( withTxGenerationLog("local-anchor") { - Transactions.makeClaimLocalAnchorOutputTx(tx, localFundingPubKey) + Transactions.makeClaimLocalAnchorOutputTx(tx, localFundingPubKey, confirmCommitBefore) }, withTxGenerationLog("remote-anchor") { Transactions.makeClaimRemoteAnchorOutputTx(tx, commitments.remoteParams.fundingPubKey) @@ -729,7 +731,7 @@ object Helpers { * @param tx the remote commitment transaction that has just been published * @return a list of transactions (one per output of the commit tx that we can claim) */ - def claimRemoteCommitTxOutputs(keyManager: ChannelKeyManager, commitments: Commitments, remoteCommit: RemoteCommit, tx: Transaction, feeEstimator: FeeEstimator, feeTargets: FeeTargets)(implicit log: LoggingAdapter): RemoteCommitPublished = { + def claimRemoteCommitTxOutputs(keyManager: ChannelKeyManager, commitments: Commitments, remoteCommit: RemoteCommit, tx: Transaction, currentBlockHeight: BlockHeight, feeEstimator: FeeEstimator, feeTargets: FeeTargets)(implicit log: LoggingAdapter): RemoteCommitPublished = { require(remoteCommit.txid == tx.txid, "txid mismatch, provided tx is not the current remote commit tx") val (remoteCommitTx, _) = Commitments.makeRemoteTxs(keyManager, commitments.channelConfig, commitments.channelFeatures, remoteCommit.index, commitments.localParams, commitments.remoteParams, commitments.commitInput, remoteCommit.remotePerCommitmentPoint, remoteCommit.spec) require(remoteCommitTx.tx.txid == tx.txid, "txid mismatch, cannot recompute the current remote commit tx") @@ -780,9 +782,11 @@ object Helpers { }) }.toSeq.flatten.toMap + // If we don't have pending HTLCs, we don't have funds at risk, so we can aim for a slower confirmation. + val confirmCommitBefore = htlcTxs.values.flatten.map(htlcTx => htlcTx.confirmBefore).minOption.getOrElse(currentBlockHeight + feeTargets.commitmentWithoutHtlcsBlockTarget) val claimAnchorTxs: List[ClaimAnchorOutputTx] = List( withTxGenerationLog("local-anchor") { - Transactions.makeClaimLocalAnchorOutputTx(tx, localFundingPubkey) + Transactions.makeClaimLocalAnchorOutputTx(tx, localFundingPubkey, confirmCommitBefore) }, withTxGenerationLog("remote-anchor") { Transactions.makeClaimRemoteAnchorOutputTx(tx, commitments.remoteParams.fundingPubKey) @@ -1101,19 +1105,19 @@ object Helpers { localCommit.spec.htlcs.collect(outgoing) -- untrimmedHtlcs } else { // maybe this is a timeout tx, in that case we can resolve and fail the corresponding htlc - val isMissingHtlcIndex = localCommitPublished.htlcTxs.values.collect { case Some(HtlcTimeoutTx(_, _, htlcId)) => htlcId }.toSet == Set(0) + val isMissingHtlcIndex = localCommitPublished.htlcTxs.values.collect { case Some(HtlcTimeoutTx(_, _, htlcId, _)) => htlcId }.toSet == Set(0) if (isMissingHtlcIndex && commitmentFormat == DefaultCommitmentFormat) { tx.txIn .map(_.witness) .collect(Scripts.extractPaymentHashFromHtlcTimeout) .flatMap { paymentHash160 => log.info(s"htlc-timeout tx for paymentHash160=${paymentHash160.toHex} expiry=${tx.lockTime} has been confirmed (tx=$tx)") - val timeoutTxs = localCommitPublished.htlcTxs.values.collect { case Some(HtlcTimeoutTx(_, tx, _)) => tx }.toSeq + val timeoutTxs = localCommitPublished.htlcTxs.values.collect { case Some(HtlcTimeoutTx(_, tx, _, _)) => tx }.toSeq findTimedOutHtlc(tx, paymentHash160, untrimmedHtlcs, timeoutTxs, Scripts.extractPaymentHashFromHtlcTimeout) }.toSet } else { tx.txIn.flatMap(txIn => localCommitPublished.htlcTxs.get(txIn.outPoint) match { - case Some(Some(HtlcTimeoutTx(_, _, htlcId))) if isHtlcTimeout(tx, localCommitPublished) => + case Some(Some(HtlcTimeoutTx(_, _, htlcId, _))) if isHtlcTimeout(tx, localCommitPublished) => untrimmedHtlcs.find(_.id == htlcId) match { case Some(htlc) => log.info(s"htlc-timeout tx for htlc #$htlcId paymentHash=${htlc.paymentHash} expiry=${tx.lockTime} has been confirmed (tx=$tx)") @@ -1143,19 +1147,19 @@ object Helpers { remoteCommit.spec.htlcs.collect(incoming) -- untrimmedHtlcs } else { // maybe this is a timeout tx, in that case we can resolve and fail the corresponding htlc - val isMissingHtlcIndex = remoteCommitPublished.claimHtlcTxs.values.collect { case Some(ClaimHtlcTimeoutTx(_, _, htlcId)) => htlcId }.toSet == Set(0) + val isMissingHtlcIndex = remoteCommitPublished.claimHtlcTxs.values.collect { case Some(ClaimHtlcTimeoutTx(_, _, htlcId, _)) => htlcId }.toSet == Set(0) if (isMissingHtlcIndex && commitmentFormat == DefaultCommitmentFormat) { tx.txIn .map(_.witness) .collect(Scripts.extractPaymentHashFromClaimHtlcTimeout) .flatMap { paymentHash160 => log.info(s"claim-htlc-timeout tx for paymentHash160=${paymentHash160.toHex} expiry=${tx.lockTime} has been confirmed (tx=$tx)") - val timeoutTxs = remoteCommitPublished.claimHtlcTxs.values.collect { case Some(ClaimHtlcTimeoutTx(_, tx, _)) => tx }.toSeq + val timeoutTxs = remoteCommitPublished.claimHtlcTxs.values.collect { case Some(ClaimHtlcTimeoutTx(_, tx, _, _)) => tx }.toSeq findTimedOutHtlc(tx, paymentHash160, untrimmedHtlcs, timeoutTxs, Scripts.extractPaymentHashFromClaimHtlcTimeout) }.toSet } else { tx.txIn.flatMap(txIn => remoteCommitPublished.claimHtlcTxs.get(txIn.outPoint) match { - case Some(Some(ClaimHtlcTimeoutTx(_, _, htlcId))) if isClaimHtlcTimeout(tx, remoteCommitPublished) => + case Some(Some(ClaimHtlcTimeoutTx(_, _, htlcId, _))) if isClaimHtlcTimeout(tx, remoteCommitPublished) => untrimmedHtlcs.find(_.id == htlcId) match { case Some(htlc) => log.info(s"claim-htlc-timeout tx for htlc #$htlcId paymentHash=${htlc.paymentHash} expiry=${tx.lockTime} has been confirmed (tx=$tx)") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala index 4d588aade4..f655c891c0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala @@ -201,7 +201,7 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, bitcoinClient: B htlcTx match { case tx: HtlcSuccessTx => commitments.localCommit.htlcTxsAndRemoteSigs.collectFirst { - case HtlcTxAndRemoteSig(HtlcSuccessTx(input, _, _, _), remoteSig) if input.outPoint == tx.input.outPoint => remoteSig + case HtlcTxAndRemoteSig(HtlcSuccessTx(input, _, _, _, _), remoteSig) if input.outPoint == tx.input.outPoint => remoteSig } match { case Some(remoteSig) => commitments.localChanges.all.collectFirst { @@ -218,7 +218,7 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, bitcoinClient: B } case tx: HtlcTimeoutTx => commitments.localCommit.htlcTxsAndRemoteSigs.collectFirst { - case HtlcTxAndRemoteSig(HtlcTimeoutTx(input, _, _), remoteSig) if input.outPoint == tx.input.outPoint => remoteSig + case HtlcTxAndRemoteSig(HtlcTimeoutTx(input, _, _, _), remoteSig) if input.outPoint == tx.input.outPoint => remoteSig } match { case Some(remoteSig) => Some(HtlcTimeoutWithWitnessData(tx, remoteSig)) case None => diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index 419fa36c9b..343c5166fb 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -75,8 +75,8 @@ object ReplaceableTxPublisher { } } - def getFeerate(feeEstimator: FeeEstimator, confirmationTarget: BlockHeight, currentBlockHeight: BlockHeight): FeeratePerKw = { - val remainingBlocks = (confirmationTarget - currentBlockHeight).toLong + def getFeerate(feeEstimator: FeeEstimator, confirmBefore: BlockHeight, currentBlockHeight: BlockHeight): FeeratePerKw = { + val remainingBlocks = (confirmBefore - currentBlockHeight).toLong val blockTarget = remainingBlocks match { // If our target is still very far in the future, no need to rush case t if t >= 144 => 144 @@ -138,7 +138,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc } def fund(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = { - val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.confirmationTarget, BlockHeight(nodeParams.currentBlockHeight)) + val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.txInfo.confirmBefore, BlockHeight(nodeParams.currentBlockHeight)) val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, loggingInfo), "tx-funder") txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, Right(txWithWitnessData), targetFeerate) Behaviors.receiveMessagePartial { @@ -177,16 +177,16 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc timers.startSingleTimer(CheckFeeKey, CheckFee(currentBlockCount), (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis) Behaviors.same case CheckFee(currentBlockCount) => - val currentFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.confirmationTarget, BlockHeight(currentBlockCount)) - val targetFeerate_opt = if (cmd.confirmationTarget.toLong <= currentBlockCount + 6) { - log.debug("{} confirmation target is close (in {} blocks): bumping fees", cmd.desc, cmd.confirmationTarget.toLong - currentBlockCount) + val currentFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.txInfo.confirmBefore, BlockHeight(currentBlockCount)) + val targetFeerate_opt = if (cmd.txInfo.confirmBefore.toLong <= currentBlockCount + 6) { + log.debug("{} confirmation target is close (in {} blocks): bumping fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) // We make sure we increase the fees by at least 20% as we get close to the confirmation target. Some(currentFeerate.max(tx.feerate * 1.2)) } else if (tx.feerate * 1.2 <= currentFeerate) { - log.debug("{} confirmation target is in {} blocks: bumping fees", cmd.desc, cmd.confirmationTarget.toLong - currentBlockCount) + log.debug("{} confirmation target is in {} blocks: bumping fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) Some(currentFeerate) } else { - log.debug("{} confirmation target is in {} blocks: no need to bump fees", cmd.desc, cmd.confirmationTarget.toLong - currentBlockCount) + log.debug("{} confirmation target is in {} blocks: no need to bump fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) None } targetFeerate_opt.foreach(targetFeerate => { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala index 6df21f55a4..567887c420 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala @@ -26,7 +26,7 @@ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.channel.Commitments import fr.acinq.eclair.transactions.Transactions.{ReplaceableTransactionWithInputInfo, TransactionWithInputInfo} -import fr.acinq.eclair.{BlockHeight, Logs, NodeParams} +import fr.acinq.eclair.{Logs, NodeParams} import java.util.UUID import scala.concurrent.duration.DurationLong @@ -85,7 +85,7 @@ object TxPublisher { def apply(txInfo: TransactionWithInputInfo, fee: Satoshi, parentTx_opt: Option[ByteVector32]): PublishFinalTx = PublishFinalTx(txInfo.tx, txInfo.input.outPoint, txInfo.desc, fee, parentTx_opt) } /** Publish an unsigned transaction that can be RBF-ed. */ - case class PublishReplaceableTx(txInfo: ReplaceableTransactionWithInputInfo, commitments: Commitments, confirmationTarget: BlockHeight) extends PublishTx { + case class PublishReplaceableTx(txInfo: ReplaceableTransactionWithInputInfo, commitments: Commitments) extends PublishTx { override def input: OutPoint = txInfo.input.outPoint override def desc: String = txInfo.desc } @@ -197,11 +197,11 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact val attempts = pending.getOrElse(cmd.input, Seq.empty) val alreadyPublished = attempts.collectFirst { // If there is already an attempt at spending this outpoint with a more aggressive confirmation target, there is no point in publishing again. - case a: ReplaceableAttempt if a.cmd.confirmationTarget <= cmd.confirmationTarget => a.cmd.confirmationTarget + case a: ReplaceableAttempt if a.cmd.txInfo.confirmBefore <= cmd.txInfo.confirmBefore => a.cmd.txInfo.confirmBefore } alreadyPublished match { case Some(currentConfirmationTarget) => - log.info("not publishing replaceable {} spending {}:{} with confirmation target={}, publishing is already in progress with confirmation target={}", cmd.desc, cmd.input.txid, cmd.input.index, cmd.confirmationTarget, currentConfirmationTarget) + log.info("not publishing replaceable {} spending {}:{} with confirmation target={}, publishing is already in progress with confirmation target={}", cmd.desc, cmd.input.txid, cmd.input.index, cmd.txInfo.confirmBefore, currentConfirmationTarget) Behaviors.same case None => val publishId = UUID.randomUUID() diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala index fcbe96d09b..9d2352a573 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala @@ -211,17 +211,27 @@ object TransactionWithInputInfoSerializer extends MinimalSerializer({ JField("txid", JString(x.tx.txid.toHex)), JField("tx", JString(x.tx.toString())), JField("paymentHash", JString(x.paymentHash.toString())), - JField("htlcId", JLong(x.htlcId)) + JField("htlcId", JLong(x.htlcId)), + JField("confirmBeforeBlock", JLong(x.confirmBefore.toLong)) )) case x: HtlcTimeoutTx => JObject(List( JField("txid", JString(x.tx.txid.toHex)), JField("tx", JString(x.tx.toString())), - JField("htlcId", JLong(x.htlcId)) + JField("htlcId", JLong(x.htlcId)), + JField("confirmBeforeBlock", JLong(x.confirmBefore.toLong)) + )) + case x: ClaimHtlcSuccessTx => JObject(List( + JField("txid", JString(x.tx.txid.toHex)), + JField("tx", JString(x.tx.toString())), + JField("paymentHash", JString(x.paymentHash.toString())), + JField("htlcId", JLong(x.htlcId)), + JField("confirmBeforeBlock", JLong(x.confirmBefore.toLong)) )) case x: ClaimHtlcTx => JObject(List( JField("txid", JString(x.tx.txid.toHex)), JField("tx", JString(x.tx.toString())), - JField("htlcId", JLong(x.htlcId)) + JField("htlcId", JLong(x.htlcId)), + JField("confirmBeforeBlock", JLong(x.confirmBefore.toLong)) )) case x: ClosingTx => val txFields = List( @@ -238,6 +248,11 @@ object TransactionWithInputInfoSerializer extends MinimalSerializer({ JObject(txFields :+ toLocalField) case None => JObject(txFields) } + case x: ReplaceableTransactionWithInputInfo => JObject(List( + JField("txid", JString(x.tx.txid.toHex)), + JField("tx", JString(x.tx.toString())), + JField("confirmBeforeBlock", JLong(x.confirmBefore.toLong)) + )) case x: TransactionWithInputInfo => JObject(List( JField("txid", JString(x.tx.txid.toHex)), JField("tx", JString(x.tx.toString())) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 22a020cfb7..d7e6f13ace 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -109,19 +109,24 @@ object Transactions { /** Sighash flags to use when signing the transaction. */ def sighash(txOwner: TxOwner, commitmentFormat: CommitmentFormat): Int = SIGHASH_ALL } - sealed trait ReplaceableTransactionWithInputInfo extends TransactionWithInputInfo + sealed trait ReplaceableTransactionWithInputInfo extends TransactionWithInputInfo { + /** Block before which the transaction must be confirmed. */ + def confirmBefore: BlockHeight + } case class CommitTx(input: InputInfo, tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "commit-tx" } - // It's important to note that htlc transactions with the default commitment format are not actually replaceable: only - // anchor outputs htlc transactions are replaceable. We should have used different types for these different kinds of - // htlc transactions, but we introduced that before implementing the replacement strategy. - // Unfortunately, if we wanted to change that, we would have to update the codecs and implement a migration of channel - // data, which isn't trivial, so we chose to temporarily live with that inconsistency (and have the transaction - // replacement logic abort when non-anchor outputs htlc transactions are provided). - // Ideally, we'd like to implement a dynamic commitment format upgrade mechanism and depreciate the pre-anchor outputs - // format soon, which will get rid of this inconsistency. - // The next time we introduce a new type of commitment, we should avoid repeating that mistake and define separate - // types right from the start. + /** + * It's important to note that htlc transactions with the default commitment format are not actually replaceable: only + * anchor outputs htlc transactions are replaceable. We should have used different types for these different kinds of + * htlc transactions, but we introduced that before implementing the replacement strategy. + * Unfortunately, if we wanted to change that, we would have to update the codecs and implement a migration of channel + * data, which isn't trivial, so we chose to temporarily live with that inconsistency (and have the transaction + * replacement logic abort when non-anchor outputs htlc transactions are provided). + * Ideally, we'd like to implement a dynamic commitment format upgrade mechanism and depreciate the pre-anchor outputs + * format soon, which will get rid of this inconsistency. + * The next time we introduce a new type of commitment, we should avoid repeating that mistake and define separate + * types right from the start. + */ sealed trait HtlcTx extends ReplaceableTransactionWithInputInfo { def htlcId: Long override def sighash(txOwner: TxOwner, commitmentFormat: CommitmentFormat): Int = commitmentFormat match { @@ -132,15 +137,15 @@ object Transactions { } } } - case class HtlcSuccessTx(input: InputInfo, tx: Transaction, paymentHash: ByteVector32, htlcId: Long) extends HtlcTx { override def desc: String = "htlc-success" } - case class HtlcTimeoutTx(input: InputInfo, tx: Transaction, htlcId: Long) extends HtlcTx { override def desc: String = "htlc-timeout" } + case class HtlcSuccessTx(input: InputInfo, tx: Transaction, paymentHash: ByteVector32, htlcId: Long, confirmBefore: BlockHeight) extends HtlcTx { override def desc: String = "htlc-success" } + case class HtlcTimeoutTx(input: InputInfo, tx: Transaction, htlcId: Long, confirmBefore: BlockHeight) extends HtlcTx { override def desc: String = "htlc-timeout" } case class HtlcDelayedTx(input: InputInfo, tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "htlc-delayed" } sealed trait ClaimHtlcTx extends ReplaceableTransactionWithInputInfo { def htlcId: Long } - case class LegacyClaimHtlcSuccessTx(input: InputInfo, tx: Transaction, htlcId: Long) extends ClaimHtlcTx { override def desc: String = "claim-htlc-success" } - case class ClaimHtlcSuccessTx(input: InputInfo, tx: Transaction, paymentHash: ByteVector32, htlcId: Long) extends ClaimHtlcTx { override def desc: String = "claim-htlc-success" } - case class ClaimHtlcTimeoutTx(input: InputInfo, tx: Transaction, htlcId: Long) extends ClaimHtlcTx { override def desc: String = "claim-htlc-timeout" } + case class LegacyClaimHtlcSuccessTx(input: InputInfo, tx: Transaction, htlcId: Long, confirmBefore: BlockHeight) extends ClaimHtlcTx { override def desc: String = "claim-htlc-success" } + case class ClaimHtlcSuccessTx(input: InputInfo, tx: Transaction, paymentHash: ByteVector32, htlcId: Long, confirmBefore: BlockHeight) extends ClaimHtlcTx { override def desc: String = "claim-htlc-success" } + case class ClaimHtlcTimeoutTx(input: InputInfo, tx: Transaction, htlcId: Long, confirmBefore: BlockHeight) extends ClaimHtlcTx { override def desc: String = "claim-htlc-timeout" } sealed trait ClaimAnchorOutputTx extends TransactionWithInputInfo - case class ClaimLocalAnchorOutputTx(input: InputInfo, tx: Transaction) extends ClaimAnchorOutputTx with ReplaceableTransactionWithInputInfo { override def desc: String = "local-anchor" } + case class ClaimLocalAnchorOutputTx(input: InputInfo, tx: Transaction, confirmBefore: BlockHeight) extends ClaimAnchorOutputTx with ReplaceableTransactionWithInputInfo { override def desc: String = "local-anchor" } case class ClaimRemoteAnchorOutputTx(input: InputInfo, tx: Transaction) extends ClaimAnchorOutputTx { override def desc: String = "remote-anchor" } sealed trait ClaimRemoteCommitMainOutputTx extends TransactionWithInputInfo case class ClaimP2WPKHOutputTx(input: InputInfo, tx: Transaction) extends ClaimRemoteCommitMainOutputTx { override def desc: String = "remote-main" } @@ -463,7 +468,7 @@ object Transactions { txOut = TxOut(amount, pay2wsh(toLocalDelayed(localRevocationPubkey, toLocalDelay, localDelayedPaymentPubkey))) :: Nil, lockTime = htlc.cltvExpiry.toLong ) - Right(HtlcTimeoutTx(input, tx, htlc.id)) + Right(HtlcTimeoutTx(input, tx, htlc.id, BlockHeight(htlc.cltvExpiry.toLong))) } } @@ -490,7 +495,7 @@ object Transactions { txOut = TxOut(amount, pay2wsh(toLocalDelayed(localRevocationPubkey, toLocalDelay, localDelayedPaymentPubkey))) :: Nil, lockTime = 0 ) - Right(HtlcSuccessTx(input, tx, htlc.paymentHash, htlc.id)) + Right(HtlcSuccessTx(input, tx, htlc.paymentHash, htlc.id, BlockHeight(htlc.cltvExpiry.toLong))) } } @@ -537,14 +542,14 @@ object Transactions { txIn = TxIn(input.outPoint, ByteVector.empty, getHtlcTxInputSequence(commitmentFormat)) :: Nil, txOut = TxOut(Satoshi(0), localFinalScriptPubKey) :: Nil, lockTime = 0) - val weight = addSigs(ClaimHtlcSuccessTx(input, tx, htlc.paymentHash, htlc.id), PlaceHolderSig, ByteVector32.Zeroes).tx.weight() + val weight = addSigs(ClaimHtlcSuccessTx(input, tx, htlc.paymentHash, htlc.id, BlockHeight(htlc.cltvExpiry.toLong)), PlaceHolderSig, ByteVector32.Zeroes).tx.weight() val fee = weight2fee(feeratePerKw, weight) val amount = input.txOut.amount - fee if (amount < localDustLimit) { Left(AmountBelowDustLimit) } else { val tx1 = tx.copy(txOut = tx.txOut.head.copy(amount = amount) :: Nil) - Right(ClaimHtlcSuccessTx(input, tx1, htlc.paymentHash, htlc.id)) + Right(ClaimHtlcSuccessTx(input, tx1, htlc.paymentHash, htlc.id, BlockHeight(htlc.cltvExpiry.toLong))) } case None => Left(OutputNotFound) } @@ -572,14 +577,14 @@ object Transactions { txIn = TxIn(input.outPoint, ByteVector.empty, getHtlcTxInputSequence(commitmentFormat)) :: Nil, txOut = TxOut(Satoshi(0), localFinalScriptPubKey) :: Nil, lockTime = htlc.cltvExpiry.toLong) - val weight = addSigs(ClaimHtlcTimeoutTx(input, tx, htlc.id), PlaceHolderSig).tx.weight() + val weight = addSigs(ClaimHtlcTimeoutTx(input, tx, htlc.id, BlockHeight(htlc.cltvExpiry.toLong)), PlaceHolderSig).tx.weight() val fee = weight2fee(feeratePerKw, weight) val amount = input.txOut.amount - fee if (amount < localDustLimit) { Left(AmountBelowDustLimit) } else { val tx1 = tx.copy(txOut = tx.txOut.head.copy(amount = amount) :: Nil) - Right(ClaimHtlcTimeoutTx(input, tx1, htlc.id)) + Right(ClaimHtlcTimeoutTx(input, tx1, htlc.id, BlockHeight(htlc.cltvExpiry.toLong))) } case None => Left(OutputNotFound) } @@ -692,8 +697,8 @@ object Transactions { } } - def makeClaimLocalAnchorOutputTx(commitTx: Transaction, localFundingPubkey: PublicKey): Either[TxGenerationSkipped, ClaimLocalAnchorOutputTx] = { - makeClaimAnchorOutputTx(commitTx, localFundingPubkey).map { case (input, tx) => ClaimLocalAnchorOutputTx(input, tx) } + def makeClaimLocalAnchorOutputTx(commitTx: Transaction, localFundingPubkey: PublicKey, confirmBefore: BlockHeight): Either[TxGenerationSkipped, ClaimLocalAnchorOutputTx] = { + makeClaimAnchorOutputTx(commitTx, localFundingPubkey).map { case (input, tx) => ClaimLocalAnchorOutputTx(input, tx, confirmBefore) } } def makeClaimRemoteAnchorOutputTx(commitTx: Transaction, remoteFundingPubkey: PublicKey): Either[TxGenerationSkipped, ClaimRemoteAnchorOutputTx] = { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala index e5ce53cd53..730b0f287c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala @@ -18,7 +18,6 @@ package fr.acinq.eclair.wire.internal.channel.version0 import fr.acinq.bitcoin.DeterministicWallet.{ExtendedPrivateKey, KeyPath} import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Crypto, OutPoint, Transaction, TxOut} -import fr.acinq.eclair.TimestampSecond import fr.acinq.eclair.channel._ import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.transactions.Transactions._ @@ -27,6 +26,7 @@ import fr.acinq.eclair.wire.internal.channel.version0.ChannelTypes0.{HtlcTxAndSi import fr.acinq.eclair.wire.protocol.CommonCodecs._ import fr.acinq.eclair.wire.protocol.LightningMessageCodecs.{channelAnnouncementCodec, channelUpdateCodec, combinedFeaturesCodec} import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{BlockHeight, TimestampSecond} import scodec.Codec import scodec.bits.{BitVector, ByteVector} import scodec.codecs._ @@ -134,10 +134,10 @@ private[channel] object ChannelCodecs0 { // downstream htlc times out, and `Helpers.Closing.timedOutHtlcs` explicitly handles the case where htlcId is missing. val txWithInputInfoCodec: Codec[TransactionWithInputInfo] = discriminated[TransactionWithInputInfo].by(uint16) .typecase(0x01, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[CommitTx]) - .typecase(0x02, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | provide(0L))).as[HtlcSuccessTx]) - .typecase(0x03, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L))).as[HtlcTimeoutTx]) - .typecase(0x04, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L))).as[LegacyClaimHtlcSuccessTx]) - .typecase(0x05, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L))).as[ClaimHtlcTimeoutTx]) + .typecase(0x02, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcSuccessTx]) + .typecase(0x03, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcTimeoutTx]) + .typecase(0x04, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[LegacyClaimHtlcSuccessTx]) + .typecase(0x05, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[ClaimHtlcTimeoutTx]) .typecase(0x06, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimP2WPKHOutputTx]) .typecase(0x07, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimLocalDelayedOutputTx]) .typecase(0x08, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[MainPenaltyTx]) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelTypes0.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelTypes0.scala index b7d193c053..f136208bb3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelTypes0.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelTypes0.scala @@ -23,7 +23,7 @@ import fr.acinq.eclair.channel._ import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.transactions.CommitmentSpec import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.{Feature, Features, channel} +import fr.acinq.eclair.{BlockHeight, Feature, Features, channel} import scodec.bits.BitVector private[channel] object ChannelTypes0 { @@ -57,8 +57,8 @@ private[channel] object ChannelTypes0 { // the channel will put a watch at start-up which will make us fetch the spending transaction. val irrevocablySpentNew = irrevocablySpent.collect { case (outpoint, txid) if knownTxs.contains(txid) => (outpoint, knownTxs(txid)) } val claimMainDelayedOutputTxNew = claimMainDelayedOutputTx.map(tx => ClaimLocalDelayedOutputTx(getPartialInputInfo(commitTx, tx), tx)) - val htlcSuccessTxsNew = htlcSuccessTxs.map(tx => HtlcSuccessTx(getPartialInputInfo(commitTx, tx), tx, ByteVector32.Zeroes, 0)) - val htlcTimeoutTxsNew = htlcTimeoutTxs.map(tx => HtlcTimeoutTx(getPartialInputInfo(commitTx, tx), tx, 0)) + val htlcSuccessTxsNew = htlcSuccessTxs.map(tx => HtlcSuccessTx(getPartialInputInfo(commitTx, tx), tx, ByteVector32.Zeroes, 0, BlockHeight(0))) + val htlcTimeoutTxsNew = htlcTimeoutTxs.map(tx => HtlcTimeoutTx(getPartialInputInfo(commitTx, tx), tx, 0, BlockHeight(0))) val htlcTxsNew = (htlcSuccessTxsNew ++ htlcTimeoutTxsNew).map(tx => tx.input.outPoint -> Some(tx)).toMap val claimHtlcDelayedTxsNew = claimHtlcDelayedTxs.map(tx => { val htlcTx = htlcTxs.find(_.txid == tx.txIn.head.outPoint.txid) @@ -77,8 +77,8 @@ private[channel] object ChannelTypes0 { // the channel will put a watch at start-up which will make us fetch the spending transaction. val irrevocablySpentNew = irrevocablySpent.collect { case (outpoint, txid) if knownTxs.contains(txid) => (outpoint, knownTxs(txid)) } val claimMainOutputTxNew = claimMainOutputTx.map(tx => ClaimP2WPKHOutputTx(getPartialInputInfo(commitTx, tx), tx)) - val claimHtlcSuccessTxsNew = claimHtlcSuccessTxs.map(tx => LegacyClaimHtlcSuccessTx(getPartialInputInfo(commitTx, tx), tx, 0)) - val claimHtlcTimeoutTxsNew = claimHtlcTimeoutTxs.map(tx => ClaimHtlcTimeoutTx(getPartialInputInfo(commitTx, tx), tx, 0)) + val claimHtlcSuccessTxsNew = claimHtlcSuccessTxs.map(tx => LegacyClaimHtlcSuccessTx(getPartialInputInfo(commitTx, tx), tx, 0, BlockHeight(0))) + val claimHtlcTimeoutTxsNew = claimHtlcTimeoutTxs.map(tx => ClaimHtlcTimeoutTx(getPartialInputInfo(commitTx, tx), tx, 0, BlockHeight(0))) val claimHtlcTxsNew = (claimHtlcSuccessTxsNew ++ claimHtlcTimeoutTxsNew).map(tx => tx.input.outPoint -> Some(tx)).toMap channel.RemoteCommitPublished(commitTx, claimMainOutputTxNew, claimHtlcTxsNew, Nil, irrevocablySpentNew) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version1/ChannelCodecs1.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version1/ChannelCodecs1.scala index 42415f02ad..4d4af56fed 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version1/ChannelCodecs1.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version1/ChannelCodecs1.scala @@ -18,6 +18,7 @@ package fr.acinq.eclair.wire.internal.channel.version1 import fr.acinq.bitcoin.DeterministicWallet.{ExtendedPrivateKey, KeyPath} import fr.acinq.bitcoin.{ByteVector32, OutPoint, Transaction, TxOut} +import fr.acinq.eclair.BlockHeight import fr.acinq.eclair.channel._ import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.transactions.Transactions._ @@ -108,10 +109,10 @@ private[channel] object ChannelCodecs1 { // downstream htlc times out, and `Helpers.Closing.timedOutHtlcs` explicitly handles the case where htlcId is missing. val txWithInputInfoCodec: Codec[TransactionWithInputInfo] = discriminated[TransactionWithInputInfo].by(uint16) .typecase(0x01, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[CommitTx]) - .typecase(0x02, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | provide(0L))).as[HtlcSuccessTx]) - .typecase(0x03, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L))).as[HtlcTimeoutTx]) - .typecase(0x04, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L))).as[LegacyClaimHtlcSuccessTx]) - .typecase(0x05, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L))).as[ClaimHtlcTimeoutTx]) + .typecase(0x02, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcSuccessTx]) + .typecase(0x03, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcTimeoutTx]) + .typecase(0x04, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[LegacyClaimHtlcSuccessTx]) + .typecase(0x05, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[ClaimHtlcTimeoutTx]) .typecase(0x06, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimP2WPKHOutputTx]) .typecase(0x07, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimLocalDelayedOutputTx]) .typecase(0x08, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[MainPenaltyTx]) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version2/ChannelCodecs2.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version2/ChannelCodecs2.scala index d83dea764d..c37b8cd73b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version2/ChannelCodecs2.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version2/ChannelCodecs2.scala @@ -18,6 +18,7 @@ package fr.acinq.eclair.wire.internal.channel.version2 import fr.acinq.bitcoin.DeterministicWallet.{ExtendedPrivateKey, KeyPath} import fr.acinq.bitcoin.{OutPoint, Transaction, TxOut} +import fr.acinq.eclair.BlockHeight import fr.acinq.eclair.channel._ import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.transactions.Transactions._ @@ -105,18 +106,18 @@ private[channel] object ChannelCodecs2 { ("scriptPubKey" | lengthDelimited(bytes))).as[OutputInfo] val commitTxCodec: Codec[CommitTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[CommitTx] - val htlcSuccessTxCodec: Codec[HtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow)).as[HtlcSuccessTx] - val htlcTimeoutTxCodec: Codec[HtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow)).as[HtlcTimeoutTx] + val htlcSuccessTxCodec: Codec[HtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcSuccessTx] + val htlcTimeoutTxCodec: Codec[HtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcTimeoutTx] val htlcDelayedTxCodec: Codec[HtlcDelayedTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[HtlcDelayedTx] - val claimHtlcSuccessTxCodec: Codec[LegacyClaimHtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow)).as[LegacyClaimHtlcSuccessTx] - val claimHtlcTimeoutTxCodec: Codec[ClaimHtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow)).as[ClaimHtlcTimeoutTx] + val claimHtlcSuccessTxCodec: Codec[LegacyClaimHtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[LegacyClaimHtlcSuccessTx] + val claimHtlcTimeoutTxCodec: Codec[ClaimHtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[ClaimHtlcTimeoutTx] val claimLocalDelayedOutputTxCodec: Codec[ClaimLocalDelayedOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimLocalDelayedOutputTx] val claimP2WPKHOutputTxCodec: Codec[ClaimP2WPKHOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimP2WPKHOutputTx] val claimRemoteDelayedOutputTxCodec: Codec[ClaimRemoteDelayedOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimRemoteDelayedOutputTx] val mainPenaltyTxCodec: Codec[MainPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[MainPenaltyTx] val htlcPenaltyTxCodec: Codec[HtlcPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[HtlcPenaltyTx] val claimHtlcDelayedOutputPenaltyTxCodec: Codec[ClaimHtlcDelayedOutputPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimHtlcDelayedOutputPenaltyTx] - val claimLocalAnchorOutputTxCodec: Codec[ClaimLocalAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimLocalAnchorOutputTx] + val claimLocalAnchorOutputTxCodec: Codec[ClaimLocalAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("confirmBefore" | provide(BlockHeight(0)))).as[ClaimLocalAnchorOutputTx] val claimRemoteAnchorOutputTxCodec: Codec[ClaimRemoteAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimRemoteAnchorOutputTx] val closingTxCodec: Codec[ClosingTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("outputIndex" | optional(bool8, outputInfoCodec))).as[ClosingTx] diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3.scala index e833daeb01..4a3b5d9bb5 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3.scala @@ -17,7 +17,7 @@ package fr.acinq.eclair.wire.internal.channel.version3 import fr.acinq.bitcoin.DeterministicWallet.{ExtendedPrivateKey, KeyPath} -import fr.acinq.bitcoin.{ByteVector32, OutPoint, Transaction, TxOut} +import fr.acinq.bitcoin.{OutPoint, Transaction, TxOut} import fr.acinq.eclair.channel._ import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.transactions.Transactions._ @@ -25,7 +25,7 @@ import fr.acinq.eclair.transactions.{CommitmentSpec, DirectedHtlc, IncomingHtlc, import fr.acinq.eclair.wire.protocol.CommonCodecs._ import fr.acinq.eclair.wire.protocol.LightningMessageCodecs._ import fr.acinq.eclair.wire.protocol.UpdateMessage -import fr.acinq.eclair.{FeatureSupport, Features} +import fr.acinq.eclair.{BlockHeight, FeatureSupport, Features} import scodec.bits.{BitVector, ByteVector} import scodec.codecs._ import scodec.{Attempt, Codec} @@ -125,56 +125,75 @@ private[channel] object ChannelCodecs3 { ("scriptPubKey" | lengthDelimited(bytes))).as[OutputInfo] val commitTxCodec: Codec[CommitTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[CommitTx] - val htlcSuccessTxCodec: Codec[HtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow)).as[HtlcSuccessTx] - val htlcTimeoutTxCodec: Codec[HtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow)).as[HtlcTimeoutTx] + val htlcSuccessTxCodec: Codec[HtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | blockHeight)).as[HtlcSuccessTx] + val htlcTimeoutTxCodec: Codec[HtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | blockHeight)).as[HtlcTimeoutTx] + private val htlcSuccessTxNoConfirmCodec: Codec[HtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcSuccessTx] + private val htlcTimeoutTxNoConfirmCodec: Codec[HtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcTimeoutTx] val htlcDelayedTxCodec: Codec[HtlcDelayedTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[HtlcDelayedTx] - private val legacyClaimHtlcSuccessTxCodec: Codec[LegacyClaimHtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow)).as[LegacyClaimHtlcSuccessTx] - val claimHtlcSuccessTxCodec: Codec[ClaimHtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow)).as[ClaimHtlcSuccessTx] - val claimHtlcTimeoutTxCodec: Codec[ClaimHtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow)).as[ClaimHtlcTimeoutTx] + private val legacyClaimHtlcSuccessTxCodec: Codec[LegacyClaimHtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[LegacyClaimHtlcSuccessTx] + val claimHtlcSuccessTxCodec: Codec[ClaimHtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | blockHeight)).as[ClaimHtlcSuccessTx] + val claimHtlcTimeoutTxCodec: Codec[ClaimHtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | blockHeight)).as[ClaimHtlcTimeoutTx] + private val claimHtlcSuccessTxNoConfirmCodec: Codec[ClaimHtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[ClaimHtlcSuccessTx] + private val claimHtlcTimeoutTxNoConfirmCodec: Codec[ClaimHtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[ClaimHtlcTimeoutTx] val claimLocalDelayedOutputTxCodec: Codec[ClaimLocalDelayedOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimLocalDelayedOutputTx] val claimP2WPKHOutputTxCodec: Codec[ClaimP2WPKHOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimP2WPKHOutputTx] val claimRemoteDelayedOutputTxCodec: Codec[ClaimRemoteDelayedOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimRemoteDelayedOutputTx] val mainPenaltyTxCodec: Codec[MainPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[MainPenaltyTx] val htlcPenaltyTxCodec: Codec[HtlcPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[HtlcPenaltyTx] val claimHtlcDelayedOutputPenaltyTxCodec: Codec[ClaimHtlcDelayedOutputPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimHtlcDelayedOutputPenaltyTx] - val claimLocalAnchorOutputTxCodec: Codec[ClaimLocalAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimLocalAnchorOutputTx] + val claimLocalAnchorOutputTxCodec: Codec[ClaimLocalAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("confirmBefore" | blockHeight)).as[ClaimLocalAnchorOutputTx] + private val claimLocalAnchorOutputTxNoConfirmCodec: Codec[ClaimLocalAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("confirmBefore" | provide(BlockHeight(0)))).as[ClaimLocalAnchorOutputTx] val claimRemoteAnchorOutputTxCodec: Codec[ClaimRemoteAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimRemoteAnchorOutputTx] val closingTxCodec: Codec[ClosingTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("outputIndex" | optional(bool8, outputInfoCodec))).as[ClosingTx] val txWithInputInfoCodec: Codec[TransactionWithInputInfo] = discriminated[TransactionWithInputInfo].by(uint16) + // Important: order matters! + .typecase(0x20, claimLocalAnchorOutputTxCodec) + .typecase(0x21, htlcSuccessTxCodec) + .typecase(0x22, htlcTimeoutTxCodec) + .typecase(0x23, claimHtlcSuccessTxCodec) + .typecase(0x24, claimHtlcTimeoutTxCodec) .typecase(0x01, commitTxCodec) - .typecase(0x02, htlcSuccessTxCodec) - .typecase(0x03, htlcTimeoutTxCodec) + .typecase(0x02, htlcSuccessTxNoConfirmCodec) + .typecase(0x03, htlcTimeoutTxNoConfirmCodec) .typecase(0x04, legacyClaimHtlcSuccessTxCodec) - .typecase(0x05, claimHtlcTimeoutTxCodec) + .typecase(0x05, claimHtlcTimeoutTxNoConfirmCodec) .typecase(0x06, claimP2WPKHOutputTxCodec) .typecase(0x07, claimLocalDelayedOutputTxCodec) .typecase(0x08, mainPenaltyTxCodec) .typecase(0x09, htlcPenaltyTxCodec) .typecase(0x10, closingTxCodec) - .typecase(0x11, claimLocalAnchorOutputTxCodec) + .typecase(0x11, claimLocalAnchorOutputTxNoConfirmCodec) .typecase(0x12, claimRemoteAnchorOutputTxCodec) .typecase(0x13, claimRemoteDelayedOutputTxCodec) .typecase(0x14, claimHtlcDelayedOutputPenaltyTxCodec) .typecase(0x15, htlcDelayedTxCodec) - .typecase(0x16, claimHtlcSuccessTxCodec) + .typecase(0x16, claimHtlcSuccessTxNoConfirmCodec) val claimRemoteCommitMainOutputTxCodec: Codec[ClaimRemoteCommitMainOutputTx] = discriminated[ClaimRemoteCommitMainOutputTx].by(uint8) .typecase(0x01, claimP2WPKHOutputTxCodec) .typecase(0x02, claimRemoteDelayedOutputTxCodec) val claimAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = discriminated[ClaimAnchorOutputTx].by(uint8) - .typecase(0x01, claimLocalAnchorOutputTxCodec) + // Important: order matters! + .typecase(0x11, claimLocalAnchorOutputTxCodec) + .typecase(0x01, claimLocalAnchorOutputTxNoConfirmCodec) .typecase(0x02, claimRemoteAnchorOutputTxCodec) val htlcTxCodec: Codec[HtlcTx] = discriminated[HtlcTx].by(uint8) - .typecase(0x01, htlcSuccessTxCodec) - .typecase(0x02, htlcTimeoutTxCodec) + // Important: order matters! + .typecase(0x11, htlcSuccessTxCodec) + .typecase(0x12, htlcTimeoutTxCodec) + .typecase(0x01, htlcSuccessTxNoConfirmCodec) + .typecase(0x02, htlcTimeoutTxNoConfirmCodec) val claimHtlcTxCodec: Codec[ClaimHtlcTx] = discriminated[ClaimHtlcTx].by(uint8) + // Important: order matters! + .typecase(0x22, claimHtlcTimeoutTxCodec) + .typecase(0x23, claimHtlcSuccessTxCodec) .typecase(0x01, legacyClaimHtlcSuccessTxCodec) - .typecase(0x02, claimHtlcTimeoutTxCodec) - .typecase(0x03, claimHtlcSuccessTxCodec) + .typecase(0x02, claimHtlcTimeoutTxNoConfirmCodec) + .typecase(0x03, claimHtlcSuccessTxNoConfirmCodec) val htlcTxsAndRemoteSigsCodec: Codec[HtlcTxAndRemoteSig] = ( ("txinfo" | htlcTxCodec) :: diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala index 973367bb92..047c0307e1 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala @@ -20,7 +20,7 @@ import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Satoshi} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.crypto.Mac32 -import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, MilliSatoshi, ShortChannelId, TimestampSecond, UInt64} +import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshi, ShortChannelId, TimestampSecond, UInt64} import org.apache.commons.codec.binary.Base32 import scodec.bits.{BitVector, ByteVector} import scodec.codecs._ @@ -62,8 +62,9 @@ object CommonCodecs { val feeratePerKw: Codec[FeeratePerKw] = uint32.xmapc(l => FeeratePerKw(Satoshi(l)))(_.toLong) - val cltvExpiry: Codec[CltvExpiry] = uint32.xmapc(CltvExpiry)((_: CltvExpiry).toLong) - val cltvExpiryDelta: Codec[CltvExpiryDelta] = uint16.xmapc(CltvExpiryDelta)((_: CltvExpiryDelta).toInt) + val blockHeight: Codec[BlockHeight] = uint32.xmapc(BlockHeight)(_.toLong) + val cltvExpiry: Codec[CltvExpiry] = uint32.xmapc(CltvExpiry)(_.toLong) + val cltvExpiryDelta: Codec[CltvExpiryDelta] = uint16.xmapc(CltvExpiryDelta)(_.toInt) // this is needed because some millisatoshi values are encoded on 32 bits in the BOLTs // this codec will fail if the amount does not fit on 32 bits diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index fee2bbbc94..2b7ccf23bb 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -128,7 +128,7 @@ object TestConstants { dustLimit = 1100 sat, maxRemoteDustLimit = 1500 sat, onChainFeeConf = OnChainFeeConf( - feeTargets = FeeTargets(6, 2, 12, 18), + feeTargets = FeeTargets(6, 2, 36, 12, 18), feeEstimator = new TestFeeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, @@ -260,7 +260,7 @@ object TestConstants { dustLimit = 1000 sat, maxRemoteDustLimit = 1500 sat, onChainFeeConf = OnChainFeeConf( - feeTargets = FeeTargets(6, 2, 12, 18), + feeTargets = FeeTargets(6, 2, 36, 12, 18), feeEstimator = new TestFeeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala index 571f262704..be6818a5e6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala @@ -28,7 +28,7 @@ class FeeEstimatorSpec extends AnyFunSuite { val defaultFeerateTolerance = FeerateTolerance(0.5, 2.0, FeeratePerKw(2500 sat), DustTolerance(15000 sat, closeOnUpdateFeeOverflow = false)) test("should update fee when diff ratio exceeded") { - val feeConf = OnChainFeeConf(FeeTargets(1, 1, 1, 1), new TestFeeEstimator(), closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty) + val feeConf = OnChainFeeConf(FeeTargets(1, 1, 1, 1, 1), new TestFeeEstimator(), closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty) assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1000 sat))) assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(900 sat))) assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1100 sat))) @@ -39,7 +39,7 @@ class FeeEstimatorSpec extends AnyFunSuite { test("get commitment feerate") { val feeEstimator = new TestFeeEstimator() val channelType = ChannelTypes.Standard - val feeConf = OnChainFeeConf(FeeTargets(1, 2, 1, 1), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty) + val feeConf = OnChainFeeConf(FeeTargets(1, 2, 6, 1, 1), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty) feeEstimator.setFeerate(FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(blocks_2 = FeeratePerKw(5000 sat))) assert(feeConf.getCommitmentFeerate(randomKey().publicKey, channelType, 100000 sat, None) === FeeratePerKw(5000 sat)) @@ -54,7 +54,7 @@ class FeeEstimatorSpec extends AnyFunSuite { val defaultMaxCommitFeerate = defaultFeerateTolerance.anchorOutputMaxCommitFeerate val overrideNodeId = randomKey().publicKey val overrideMaxCommitFeerate = defaultMaxCommitFeerate * 2 - val feeConf = OnChainFeeConf(FeeTargets(1, 2, 1, 1), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map(overrideNodeId -> defaultFeerateTolerance.copy(anchorOutputMaxCommitFeerate = overrideMaxCommitFeerate))) + val feeConf = OnChainFeeConf(FeeTargets(1, 2, 6, 1, 1), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map(overrideNodeId -> defaultFeerateTolerance.copy(anchorOutputMaxCommitFeerate = overrideMaxCommitFeerate))) feeEstimator.setFeerate(FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(blocks_2 = defaultMaxCommitFeerate / 2, mempoolMinFee = FeeratePerKw(250 sat))) assert(feeConf.getCommitmentFeerate(defaultNodeId, ChannelTypes.AnchorOutputs, 100000 sat, None) === defaultMaxCommitFeerate / 2) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala index 781337e2ce..cc16270f77 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala @@ -42,7 +42,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with implicit val log: akka.event.LoggingAdapter = akka.event.NoLogging val feeConfNoMismatch = OnChainFeeConf( - FeeTargets(6, 2, 2, 6), + FeeTargets(6, 2, 12, 2, 6), new TestFeeEstimator(), closeOnOfflineMismatch = false, 1.0, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala index 182f5abdd8..d44f10dd28 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala @@ -25,7 +25,7 @@ import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher._ import fr.acinq.eclair.channel.{CommitTxAndRemoteSig, Commitments, LocalCommit, LocalParams} import fr.acinq.eclair.transactions.Scripts import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.{CltvExpiry, TestKitBaseClass, randomBytes32} +import fr.acinq.eclair.{BlockHeight, CltvExpiry, TestKitBaseClass, randomBytes32} import org.mockito.IdiomaticMockito.StubbingOps import org.mockito.MockitoSugar.mock import org.scalatest.Tag @@ -47,7 +47,8 @@ class ReplaceableTxFunderSpec extends TestKitBaseClass with AnyFunSuiteLike { ) val anchorTx = ClaimLocalAnchorOutputTx( InputInfo(OutPoint(commitTx, 0), commitTx.txOut.head, anchorScript), - Transaction(2, Seq(TxIn(OutPoint(commitTx, 0), ByteVector.empty, 0)), Nil, 0) + Transaction(2, Seq(TxIn(OutPoint(commitTx, 0), ByteVector.empty, 0)), Nil, 0), + BlockHeight(0) ) (CommitTx(commitInput, commitTx), anchorTx) } @@ -100,12 +101,14 @@ class ReplaceableTxFunderSpec extends TestKitBaseClass with AnyFunSuiteLike { InputInfo(OutPoint(commitTx, 0), commitTx.txOut.head, htlcSuccessScript), Transaction(2, Seq(TxIn(OutPoint(commitTx, 0), ByteVector.empty, 0)), Seq(TxOut(5000 sat, Script.pay2wpkh(PlaceHolderPubKey))), 0), paymentHash, - 17 + 17, + BlockHeight(0) ), PlaceHolderSig, preimage) val htlcTimeout = HtlcTimeoutWithWitnessData(HtlcTimeoutTx( InputInfo(OutPoint(commitTx, 1), commitTx.txOut.last, htlcTimeoutScript), Transaction(2, Seq(TxIn(OutPoint(commitTx, 1), ByteVector.empty, 0)), Seq(TxOut(4000 sat, Script.pay2wpkh(PlaceHolderPubKey))), 0), - 12 + 12, + BlockHeight(0) ), PlaceHolderSig) (htlcSuccess, htlcTimeout) } @@ -159,12 +162,14 @@ class ReplaceableTxFunderSpec extends TestKitBaseClass with AnyFunSuiteLike { InputInfo(OutPoint(ByteVector32.Zeroes, 3), TxOut(5000 sat, Script.pay2wsh(htlcSuccessScript)), htlcSuccessScript), Transaction(2, Seq(TxIn(OutPoint(ByteVector32.Zeroes, 3), ByteVector.empty, 0)), Seq(TxOut(5000 sat, Script.pay2wpkh(PlaceHolderPubKey))), 0), paymentHash, - 5 + 5, + BlockHeight(0) ), preimage) val claimHtlcTimeout = ClaimHtlcTimeoutWithWitnessData(ClaimHtlcTimeoutTx( InputInfo(OutPoint(ByteVector32.Zeroes, 7), TxOut(5000 sat, Script.pay2wsh(htlcTimeoutScript)), htlcTimeoutScript), Transaction(2, Seq(TxIn(OutPoint(ByteVector32.Zeroes, 7), ByteVector.empty, 0)), Seq(TxOut(5000 sat, Script.pay2wpkh(PlaceHolderPubKey))), 0), - 7 + 7, + BlockHeight(0) )) (claimHtlcSuccess, claimHtlcTimeout) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala index 49cda7bb7e..7dbd179ed7 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala @@ -150,7 +150,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } } - def closeChannelWithoutHtlcs(f: Fixture, commitTarget: BlockHeight): (PublishFinalTx, PublishReplaceableTx) = { + def closeChannelWithoutHtlcs(f: Fixture, confirmCommitBefore: BlockHeight): (PublishFinalTx, PublishReplaceableTx) = { import f._ val commitTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.fullySignedLocalCommitTx(alice.underlyingActor.nodeParams.channelKeyManager) @@ -160,11 +160,12 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // Forward the commit tx to the publisher. val publishCommitTx = alice2blockchain.expectMsg(PublishFinalTx(commitTx, commitTx.fee, None)) // Forward the anchor tx to the publisher. - val publishAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = commitTarget) + val publishAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(publishAnchor.txInfo.input.outPoint.txid === commitTx.tx.txid) assert(publishAnchor.txInfo.isInstanceOf[ClaimLocalAnchorOutputTx]) + val anchorTx = publishAnchor.txInfo.asInstanceOf[ClaimLocalAnchorOutputTx].copy(confirmBefore = confirmCommitBefore) - (publishCommitTx, publishAnchor) + (publishCommitTx, publishAnchor.copy(txInfo = anchorTx)) } test("commit tx feerate high enough, not spending anchor output") { @@ -637,7 +638,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w }) } - def closeChannelWithHtlcs(f: Fixture, htlcTarget: BlockHeight): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { + def closeChannelWithHtlcs(f: Fixture, confirmHtlcBefore: BlockHeight): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { import f._ // Add htlcs in both directions and ensure that preimages are available. @@ -662,10 +663,12 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.isInstanceOf[ClaimLocalAnchorOutputTx]) alice2blockchain.expectMsgType[PublishFinalTx] // claim main output - val htlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = htlcTarget) + val htlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(htlcSuccess.txInfo.isInstanceOf[HtlcSuccessTx]) - val htlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = htlcTarget) + val htlcSuccessTx = htlcSuccess.txInfo.asInstanceOf[HtlcSuccessTx].copy(confirmBefore = confirmHtlcBefore) + val htlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(htlcTimeout.txInfo.isInstanceOf[HtlcTimeoutTx]) + val htlcTimeoutTx = htlcTimeout.txInfo.asInstanceOf[HtlcTimeoutTx].copy(confirmBefore = confirmHtlcBefore) alice2blockchain.expectMsgType[WatchTxConfirmed] // commit tx alice2blockchain.expectMsgType[WatchTxConfirmed] // claim main output @@ -674,7 +677,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w alice2blockchain.expectMsgType[WatchOutputSpent] // htlc-timeout tx alice2blockchain.expectNoMessage(100 millis) - (commitTx.tx, htlcSuccess, htlcTimeout) + (commitTx.tx, htlcSuccess.copy(txInfo = htlcSuccessTx), htlcTimeout.copy(txInfo = htlcTimeoutTx)) } test("not enough funds to increase htlc tx feerate") { @@ -1051,7 +1054,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w }) } - def remoteCloseChannelWithHtlcs(f: Fixture, htlcTarget: BlockHeight): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { + def remoteCloseChannelWithHtlcs(f: Fixture, confirmHtlcBefore: BlockHeight): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { import f._ // Add htlcs in both directions and ensure that preimages are available. @@ -1077,10 +1080,12 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(1) alice2blockchain.expectMsgType[PublishFinalTx] // claim main output - val claimHtlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = htlcTarget) + val claimHtlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(claimHtlcTimeout.txInfo.isInstanceOf[ClaimHtlcTimeoutTx]) - val claimHtlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = htlcTarget) + val claimHtlcTimeoutTx = claimHtlcTimeout.txInfo.asInstanceOf[ClaimHtlcTimeoutTx].copy(confirmBefore = confirmHtlcBefore) + val claimHtlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(claimHtlcSuccess.txInfo.isInstanceOf[ClaimHtlcSuccessTx]) + val claimHtlcSuccessTx = claimHtlcSuccess.txInfo.asInstanceOf[ClaimHtlcSuccessTx].copy(confirmBefore = confirmHtlcBefore) alice2blockchain.expectMsgType[WatchTxConfirmed] // commit tx alice2blockchain.expectMsgType[WatchTxConfirmed] // claim main output @@ -1088,7 +1093,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w alice2blockchain.expectMsgType[WatchOutputSpent] // claim-htlc-timeout tx alice2blockchain.expectNoMessage(100 millis) - (remoteCommitTx.tx, claimHtlcSuccess, claimHtlcTimeout) + (remoteCommitTx.tx, claimHtlcSuccess.copy(txInfo = claimHtlcSuccessTx), claimHtlcTimeout.copy(txInfo = claimHtlcTimeoutTx)) } private def testPublishClaimHtlcSuccess(f: Fixture, remoteCommitTx: Transaction, claimHtlcSuccess: PublishReplaceableTx, targetFeerate: FeeratePerKw): Transaction = { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala index e8c26ef4fd..c9b9e0792b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala @@ -101,9 +101,9 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { test("publish replaceable tx") { f => import f._ - val confirmationTarget = BlockHeight(nodeParams.currentBlockHeight + 12) + val confirmBefore = BlockHeight(nodeParams.currentBlockHeight + 12) val input = OutPoint(randomBytes32(), 3) - val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null, confirmationTarget) + val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), confirmBefore), null) txPublisher ! cmd val child = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor val p = child.expectMsgType[ReplaceableTxPublisher.Publish] @@ -113,23 +113,24 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { test("publish replaceable tx duplicate") { f => import f._ - val confirmationTarget = BlockHeight(nodeParams.currentBlockHeight + 12) + val confirmBefore = BlockHeight(nodeParams.currentBlockHeight + 12) val input = OutPoint(randomBytes32(), 3) - val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null, confirmationTarget) + val anchorTx = ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), confirmBefore) + val cmd = PublishReplaceableTx(anchorTx, null) txPublisher ! cmd val child1 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor val p1 = child1.expectMsgType[ReplaceableTxPublisher.Publish] assert(p1.cmd === cmd) // We ignore duplicates that don't use a more aggressive confirmation target: - txPublisher ! cmd + txPublisher ! PublishReplaceableTx(anchorTx, null) factory.expectNoMessage(100 millis) - val cmdHigherTarget = cmd.copy(confirmationTarget = confirmationTarget + 1) + val cmdHigherTarget = cmd.copy(txInfo = anchorTx.copy(confirmBefore = confirmBefore + 1)) txPublisher ! cmdHigherTarget factory.expectNoMessage(100 millis) // But we retry publishing if the confirmation target is more aggressive than previous attempts: - val cmdLowerTarget = cmd.copy(confirmationTarget = confirmationTarget - 6) + val cmdLowerTarget = cmd.copy(txInfo = anchorTx.copy(confirmBefore = confirmBefore - 6)) txPublisher ! cmdLowerTarget val child2 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor val p2 = child2.expectMsgType[ReplaceableTxPublisher.Publish] @@ -152,7 +153,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val attempt2 = factory.expectMsgType[FinalTxPublisherSpawned].actor attempt2.expectMsgType[FinalTxPublisher.Publish] - val cmd3 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null, BlockHeight(nodeParams.currentBlockHeight)) + val cmd3 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0), BlockHeight(nodeParams.currentBlockHeight)), null) txPublisher ! cmd3 val attempt3 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor attempt3.expectMsgType[ReplaceableTxPublisher.Publish] @@ -174,7 +175,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val attempt1 = factory.expectMsgType[FinalTxPublisherSpawned] attempt1.actor.expectMsgType[FinalTxPublisher.Publish] - val cmd2 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null, BlockHeight(nodeParams.currentBlockHeight)) + val cmd2 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0), BlockHeight(nodeParams.currentBlockHeight)), null) txPublisher ! cmd2 val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish] @@ -194,13 +195,13 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val target1 = BlockHeight(nodeParams.currentBlockHeight + 12) val input = OutPoint(randomBytes32(), 7) val paymentHash = randomBytes32() - val cmd1 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, target1) + val cmd1 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3, target1), null) txPublisher ! cmd1 val attempt1 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt1.actor.expectMsgType[ReplaceableTxPublisher.Publish] val target2 = BlockHeight(nodeParams.currentBlockHeight + 6) - val cmd2 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, target2) + val cmd2 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3, target2), null) txPublisher ! cmd2 val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish] @@ -265,7 +266,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val input = OutPoint(randomBytes32(), 7) val paymentHash = randomBytes32() - val cmd = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null, BlockHeight(nodeParams.currentBlockHeight)) + val cmd = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3, BlockHeight(nodeParams.currentBlockHeight)), null) txPublisher ! cmd val attempt1 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt1.actor.expectMsgType[ReplaceableTxPublisher.Publish] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index 06badb13d5..4a1df18823 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -2741,7 +2741,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // condition between his HTLC-success and Alice's HTLC-timeout val initialState = bob.stateData.asInstanceOf[DATA_NORMAL] val initialCommitTx = initialState.commitments.localCommit.commitTxAndRemoteSig.commitTx.tx - val HtlcSuccessTx(_, htlcSuccessTx, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx + val HtlcSuccessTx(_, htlcSuccessTx, _, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx bob ! CMD_FULFILL_HTLC(htlc.id, r, commit = true) bob2alice.expectMsgType[UpdateFulfillHtlc] @@ -2774,7 +2774,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // condition between his HTLC-success and Alice's HTLC-timeout val initialState = bob.stateData.asInstanceOf[DATA_NORMAL] val initialCommitTx = initialState.commitments.localCommit.commitTxAndRemoteSig.commitTx.tx - val HtlcSuccessTx(_, htlcSuccessTx, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx + val HtlcSuccessTx(_, htlcSuccessTx, _, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx bob ! CMD_FULFILL_HTLC(htlc.id, r, commit = false) bob2alice.expectMsgType[UpdateFulfillHtlc] @@ -2807,7 +2807,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // condition between his HTLC-success and Alice's HTLC-timeout val initialState = bob.stateData.asInstanceOf[DATA_NORMAL] val initialCommitTx = initialState.commitments.localCommit.commitTxAndRemoteSig.commitTx.tx - val HtlcSuccessTx(_, htlcSuccessTx, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx + val HtlcSuccessTx(_, htlcSuccessTx, _, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx bob ! CMD_FULFILL_HTLC(htlc.id, r, commit = true) bob2alice.expectMsgType[UpdateFulfillHtlc] @@ -2968,8 +2968,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(amountClaimed === 814880.sat) // alice sets the confirmation targets to the HTLC expiry - assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcSuccessTx, _, confirmationTarget) => (tx.htlcId, confirmationTarget.toLong) }.toMap === Map(htlcb1.id -> htlcb1.cltvExpiry.toLong)) - assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcTimeoutTx, _, confirmationTarget) => (tx.htlcId, confirmationTarget.toLong) }.toMap === Map(htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) + assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcSuccessTx, _) => (tx.htlcId, tx.confirmBefore.toLong) }.toMap === Map(htlcb1.id -> htlcb1.cltvExpiry.toLong)) + assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcTimeoutTx, _) => (tx.htlcId, tx.confirmBefore.toLong) }.toMap === Map(htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.txid) @@ -3059,7 +3059,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(amountClaimed === 822310.sat) // alice sets the confirmation targets to the HTLC expiry - assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcTimeoutTx, _, confirmationTarget) => (tx.htlcId, confirmationTarget.toLong) }.toMap === Map(htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) + assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcTimeoutTx, _) => (tx.htlcId, tx.confirmBefore.toLong) }.toMap === Map(htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.txid) // claim-main @@ -3319,14 +3319,14 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) val localAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] - assert(localAnchor.confirmationTarget.toLong === htlca1.cltvExpiry.toLong) // the target is set to match the first htlc that expires + assert(localAnchor.txInfo.confirmBefore.toLong === htlca1.cltvExpiry.toLong) // the target is set to match the first htlc that expires val claimMain = alice2blockchain.expectMsgType[PublishFinalTx] // alice can only claim 3 out of 4 htlcs, she can't do anything regarding the htlc sent by bob for which she does not have the preimage val htlcConfirmationTargets = Seq( alice2blockchain.expectMsgType[PublishReplaceableTx], // htlc 1 alice2blockchain.expectMsgType[PublishReplaceableTx], // htlc 2 alice2blockchain.expectMsgType[PublishReplaceableTx], // htlc 3 - ).map(p => p.txInfo.asInstanceOf[HtlcTx].htlcId -> p.confirmationTarget.toLong).toMap + ).map(p => p.txInfo.asInstanceOf[HtlcTx].htlcId -> p.txInfo.confirmBefore.toLong).toMap assert(htlcConfirmationTargets === Map(htlcb1.id -> htlcb1.cltvExpiry.toLong, htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === aliceCommitTx.txid) @@ -3357,7 +3357,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val blockTargets = alice.underlyingActor.nodeParams.onChainFeeConf.feeTargets val localAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] // When there are no pending HTLCs, there is no rush to get the commit tx confirmed - assert(localAnchor.confirmationTarget.toLong === currentBlockHeight + blockTargets.claimMainBlockTarget) + assert(localAnchor.txInfo.confirmBefore.toLong === currentBlockHeight + blockTargets.commitmentWithoutHtlcsBlockTarget) val claimMain = alice2blockchain.expectMsgType[PublishFinalTx] assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === aliceCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.tx.txid) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala index ea8bbe70d5..3a4e87e6b6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala @@ -529,7 +529,7 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val initialState = bob.stateData.asInstanceOf[DATA_NORMAL] val initialCommitTx = initialState.commitments.localCommit.commitTxAndRemoteSig.commitTx.tx - val HtlcSuccessTx(_, htlcSuccessTx, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx + val HtlcSuccessTx(_, htlcSuccessTx, _, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx disconnect(alice, bob) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala index a209233424..496918602d 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala @@ -777,7 +777,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Transaction.correctlySpends(claimHtlcSuccessTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) val publishHtlcSuccessTx = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(publishHtlcSuccessTx.txInfo.tx === claimHtlcSuccessTx) - assert(publishHtlcSuccessTx.confirmationTarget.toLong === htlc1.cltvExpiry.toLong) + assert(publishHtlcSuccessTx.txInfo.confirmBefore.toLong === htlc1.cltvExpiry.toLong) // Alice resets watches on all relevant transactions. assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) @@ -820,7 +820,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with closingState.claimMainOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[PublishFinalTx].tx === claimMain.tx)) val publishHtlcTimeoutTx = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(publishHtlcTimeoutTx.txInfo.tx === htlcTimeoutTx.tx) - assert(publishHtlcTimeoutTx.confirmationTarget.toLong === htlca.cltvExpiry.toLong) + assert(publishHtlcTimeoutTx.txInfo.confirmBefore.toLong === htlca.cltvExpiry.toLong) closingState.claimMainOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.tx.txid)) assert(alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex === htlcTimeoutTx.input.outPoint.index) } @@ -952,10 +952,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Transaction.correctlySpends(claimHtlcSuccessTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) val publishHtlcSuccessTx = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(publishHtlcSuccessTx.txInfo.tx === claimHtlcSuccessTx) - assert(publishHtlcSuccessTx.confirmationTarget.toLong === htlc1.cltvExpiry.toLong) + assert(publishHtlcSuccessTx.txInfo.confirmBefore.toLong === htlc1.cltvExpiry.toLong) val publishHtlcTimeoutTx = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(publishHtlcTimeoutTx.txInfo.tx === claimHtlcTimeoutTx) - assert(publishHtlcTimeoutTx.confirmationTarget.toLong === htlc2.cltvExpiry.toLong) + assert(publishHtlcTimeoutTx.txInfo.confirmBefore.toLong === htlc2.cltvExpiry.toLong) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === closingState.claimMainOutputTx.get.tx.txid) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala index 2102b03d80..ac87de53b5 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala @@ -212,24 +212,26 @@ class JsonSerializersSpec extends AnyFunSuite with Matchers { val dummyInputInfo = InputInfo(OutPoint(ByteVector32.Zeroes, 0), TxOut(Satoshi(0), Nil), Nil) val htlcSuccessTx = Transaction.read("0200000001c8a8934fb38a44b969528252bc37be66ee166c7897c57384d1e561449e110c93010000006b483045022100dc6c50f445ed53d2fb41067fdcb25686fe79492d90e6e5db43235726ace247210220773d35228af0800c257970bee9cf75175d75217de09a8ecd83521befd040c4ca012102082b751372fe7e3b012534afe0bb8d1f2f09c724b1a10a813ce704e5b9c217ccfdffffff0247ba2300000000001976a914f97a7641228e6b17d4b0b08252ae75bd62a95fe788ace3de24000000000017a914a9fefd4b9a9282a1d7a17d2f14ac7d1eb88141d287f7d50800") - val htlcSuccessTxInfo = HtlcSuccessTx(dummyInputInfo, htlcSuccessTx, ByteVector32.One, 3) + val htlcSuccessTxInfo = HtlcSuccessTx(dummyInputInfo, htlcSuccessTx, ByteVector32.One, 3, BlockHeight(1105)) val htlcSuccessJson = s"""{ | "txid": "${htlcSuccessTx.txid.toHex}", | "tx": "0200000001c8a8934fb38a44b969528252bc37be66ee166c7897c57384d1e561449e110c93010000006b483045022100dc6c50f445ed53d2fb41067fdcb25686fe79492d90e6e5db43235726ace247210220773d35228af0800c257970bee9cf75175d75217de09a8ecd83521befd040c4ca012102082b751372fe7e3b012534afe0bb8d1f2f09c724b1a10a813ce704e5b9c217ccfdffffff0247ba2300000000001976a914f97a7641228e6b17d4b0b08252ae75bd62a95fe788ace3de24000000000017a914a9fefd4b9a9282a1d7a17d2f14ac7d1eb88141d287f7d50800", | "paymentHash": "0100000000000000000000000000000000000000000000000000000000000000", - | "htlcId": 3 + | "htlcId": 3, + | "confirmBeforeBlock": 1105 |} """.stripMargin assertJsonEquals(JsonSerializers.serialization.write(htlcSuccessTxInfo)(JsonSerializers.formats), htlcSuccessJson) val claimHtlcTimeoutTx = Transaction.read("010000000110f01d4a4228ef959681feb1465c2010d0135be88fd598135b2e09d5413bf6f1000000006a473044022074658623424cebdac8290488b76f893cfb17765b7a3805e773e6770b7b17200102202892cfa9dda662d5eac394ba36fcfd1ea6c0b8bb3230ab96220731967bbdb90101210372d437866d9e4ead3d362b01b615d24cc0d5152c740d51e3c55fb53f6d335d82ffffffff01408b0700000000001976a914678db9a7caa2aca887af1177eda6f3d0f702df0d88ac00000000") - val claimHtlcTimeoutTxInfo = ClaimHtlcTimeoutTx(dummyInputInfo, claimHtlcTimeoutTx, 2) + val claimHtlcTimeoutTxInfo = ClaimHtlcTimeoutTx(dummyInputInfo, claimHtlcTimeoutTx, 2, BlockHeight(144)) val claimHtlcTimeoutJson = s"""{ | "txid": "${claimHtlcTimeoutTx.txid.toHex}", | "tx": "010000000110f01d4a4228ef959681feb1465c2010d0135be88fd598135b2e09d5413bf6f1000000006a473044022074658623424cebdac8290488b76f893cfb17765b7a3805e773e6770b7b17200102202892cfa9dda662d5eac394ba36fcfd1ea6c0b8bb3230ab96220731967bbdb90101210372d437866d9e4ead3d362b01b615d24cc0d5152c740d51e3c55fb53f6d335d82ffffffff01408b0700000000001976a914678db9a7caa2aca887af1177eda6f3d0f702df0d88ac00000000", - | "htlcId": 2 + | "htlcId": 2, + | "confirmBeforeBlock": 144 |} """.stripMargin assertJsonEquals(JsonSerializers.serialization.write(claimHtlcTimeoutTxInfo)(JsonSerializers.formats), claimHtlcTimeoutJson) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala index 59b747503a..389596caca 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala @@ -197,8 +197,9 @@ class TransactionsSpec extends AnyFunSuite with Logging { // first we create a fake commitTx tx, containing only the output that will be spent by the ClaimAnchorOutputTx val pubKeyScript = write(pay2wsh(anchor(localFundingPriv.publicKey))) val commitTx = Transaction(version = 0, txIn = Nil, txOut = TxOut(anchorAmount, pubKeyScript) :: Nil, lockTime = 0) - val Right(claimAnchorOutputTx) = makeClaimLocalAnchorOutputTx(commitTx, localFundingPriv.publicKey) + val Right(claimAnchorOutputTx) = makeClaimLocalAnchorOutputTx(commitTx, localFundingPriv.publicKey, BlockHeight(1105)) assert(claimAnchorOutputTx.tx.txOut.isEmpty) + assert(claimAnchorOutputTx.confirmBefore === BlockHeight(1105)) // we will always add at least one input and one output to be able to set our desired feerate // we use dummy signatures to compute the weight val p2wpkhWitness = ScriptWitness(Seq(Scripts.der(PlaceHolderSig), PlaceHolderPubKey.value)) @@ -299,6 +300,8 @@ class TransactionsSpec extends AnyFunSuite with Logging { val htlcTxs = makeHtlcTxs(commitTx.tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, spec.htlcTxFeerate(DefaultCommitmentFormat), outputs, DefaultCommitmentFormat) assert(htlcTxs.length === 4) + val confirmationTargets = htlcTxs.map(tx => tx.htlcId -> tx.confirmBefore.toLong).toMap + assert(confirmationTargets === Map(0 -> 300, 1 -> 310, 2 -> 295, 3 -> 300)) val htlcSuccessTxs = htlcTxs.collect { case tx: HtlcSuccessTx => tx } val htlcTimeoutTxs = htlcTxs.collect { case tx: HtlcTimeoutTx => tx } assert(htlcTimeoutTxs.size == 2) // htlc1 and htlc3 @@ -525,6 +528,8 @@ class TransactionsSpec extends AnyFunSuite with Logging { val htlcTxs = makeHtlcTxs(commitTx.tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, spec.htlcTxFeerate(UnsafeLegacyAnchorOutputsCommitmentFormat), outputs, UnsafeLegacyAnchorOutputsCommitmentFormat) assert(htlcTxs.length === 5) + val confirmationTargets = htlcTxs.map(tx => tx.htlcId -> tx.confirmBefore.toLong).toMap + assert(confirmationTargets === Map(0 -> 300, 1 -> 310, 2 -> 310, 3 -> 295, 4 -> 300)) val htlcSuccessTxs = htlcTxs.collect { case tx: HtlcSuccessTx => tx } val htlcTimeoutTxs = htlcTxs.collect { case tx: HtlcTimeoutTx => tx } assert(htlcTimeoutTxs.size == 2) // htlc1 and htlc3 @@ -536,6 +541,8 @@ class TransactionsSpec extends AnyFunSuite with Logging { val zeroFeeCommitTx = makeCommitTx(commitInput, commitTxNumber, localPaymentPriv.publicKey, remotePaymentPriv.publicKey, localIsFunder = true, zeroFeeOutputs) val zeroFeeHtlcTxs = makeHtlcTxs(zeroFeeCommitTx.tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, spec.htlcTxFeerate(ZeroFeeHtlcTxAnchorOutputsCommitmentFormat), zeroFeeOutputs, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) assert(zeroFeeHtlcTxs.length === 7) + val zeroFeeConfirmationTargets = zeroFeeHtlcTxs.map(tx => tx.htlcId -> tx.confirmBefore.toLong).toMap + assert(zeroFeeConfirmationTargets === Map(0 -> 300, 1 -> 310, 2 -> 310, 3 -> 295, 4 -> 300, 7 -> 300, 8 -> 302)) val zeroFeeHtlcSuccessTxs = zeroFeeHtlcTxs.collect { case tx: HtlcSuccessTx => tx } val zeroFeeHtlcTimeoutTxs = zeroFeeHtlcTxs.collect { case tx: HtlcTimeoutTx => tx } zeroFeeHtlcSuccessTxs.foreach(tx => assert(tx.fee === 0.sat)) @@ -569,7 +576,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { } { // local spends local anchor - val Right(claimAnchorOutputTx) = makeClaimLocalAnchorOutputTx(commitTx.tx, localFundingPriv.publicKey) + val Right(claimAnchorOutputTx) = makeClaimLocalAnchorOutputTx(commitTx.tx, localFundingPriv.publicKey, BlockHeight(0)) assert(checkSpendable(claimAnchorOutputTx).isFailure) val localSig = sign(claimAnchorOutputTx, localFundingPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signedTx = addSigs(claimAnchorOutputTx, localSig) @@ -577,7 +584,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { } { // remote spends remote anchor - val Right(claimAnchorOutputTx) = makeClaimLocalAnchorOutputTx(commitTx.tx, remoteFundingPriv.publicKey) + val Right(claimAnchorOutputTx) = makeClaimLocalAnchorOutputTx(commitTx.tx, remoteFundingPriv.publicKey, BlockHeight(0)) assert(checkSpendable(claimAnchorOutputTx).isFailure) val localSig = sign(claimAnchorOutputTx, remoteFundingPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signedTx = addSigs(claimAnchorOutputTx, localSig) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3Spec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3Spec.scala index 0355694819..b6fc95aa4e 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3Spec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3Spec.scala @@ -19,11 +19,11 @@ import fr.acinq.bitcoin.{ByteVector32, Satoshi} import fr.acinq.eclair.FeatureSupport.{Mandatory, Optional} import fr.acinq.eclair.Features.{ChannelRangeQueries, PaymentSecret, VariableLengthOnion} import fr.acinq.eclair.channel._ -import fr.acinq.eclair.transactions.Transactions.{ClaimHtlcSuccessTx, LegacyClaimHtlcSuccessTx} +import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec.normal import fr.acinq.eclair.wire.internal.channel.version3.ChannelCodecs3.Codecs._ import fr.acinq.eclair.wire.internal.channel.version3.ChannelCodecs3.stateDataCodec -import fr.acinq.eclair.{CltvExpiryDelta, Features, MilliSatoshi, UInt64, randomKey} +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, Features, MilliSatoshi, UInt64, randomKey} import org.scalatest.funsuite.AnyFunSuite import scodec.bits.{ByteVector, HexStringSyntax} @@ -138,7 +138,7 @@ class ChannelCodecs3Spec extends AnyFunSuite { } // We can encode data that contains a payment hash. - val claimHtlcSuccess = ClaimHtlcSuccessTx(legacyClaimHtlcSuccess.input, legacyClaimHtlcSuccess.tx, ByteVector32(hex"0101010101010101010101010101010101010101010101010101010101010101"), legacyClaimHtlcSuccess.htlcId) + val claimHtlcSuccess = ClaimHtlcSuccessTx(legacyClaimHtlcSuccess.input, legacyClaimHtlcSuccess.tx, ByteVector32(hex"0101010101010101010101010101010101010101010101010101010101010101"), legacyClaimHtlcSuccess.htlcId, BlockHeight(0)) val txWithInputInfoCodecBin = txWithInputInfoCodec.encode(claimHtlcSuccess).require.bytes assert(txWithInputInfoCodecBin !== oldTxWithInputInfoCodecBin) val claimHtlcTxBin = claimHtlcTxCodec.encode(claimHtlcSuccess).require.bytes @@ -151,4 +151,52 @@ class ChannelCodecs3Spec extends AnyFunSuite { assert(claimHtlcSuccess === decoded2) } + test("backwards compatibility with transactions missing a confirmation target") { + { + val oldAnchorTxBin = hex"0011 24bd0be30e31c748c7afdde7d2c527d711fadf88500971a4a1136bca375dba07b8000000002b4a0100000000000022002036c067df8952dbcd5db347e7c152ca3fa4514f2072d27867837b1c2d319a7e01282103cc89f1459b5201cda08e08c6fb7b1968c54e8172c555896da27c6fdc10522ceeac736460b268330200000001bd0be30e31c748c7afdde7d2c527d711fadf88500971a4a1136bca375dba07b80000000000000000000000000000" + val oldAnchorTx = txWithInputInfoCodec.decode(oldAnchorTxBin.bits).require.value + assert(oldAnchorTx.isInstanceOf[ClaimLocalAnchorOutputTx]) + assert(oldAnchorTx.asInstanceOf[ClaimLocalAnchorOutputTx].confirmBefore === BlockHeight(0)) + val anchorTx = oldAnchorTx.asInstanceOf[ClaimLocalAnchorOutputTx].copy(confirmBefore = BlockHeight(1105)) + val anchorTx2 = txWithInputInfoCodec.decode(txWithInputInfoCodec.encode(anchorTx).require).require.value + assert(anchorTx === anchorTx2) + } + { + val oldHtlcSuccessTxBin = hex"0002 24f5580de0577271dce09d2de26e19ec58bf2373b0171473291a8f8be9b04fb289000000002bb0ad010000000000220020462cf8912ffc5f27764c109bed188950500011a2837ff8b9c8f9a39cffa395a58b76a91406b0950d9feded82239b3e6c9082308900f389de8763ac672102d65aa07658a7214ff129f91a1a22ade2ea4d1b07cc14b2f85a2842c34240836f7c8201208763a914c461c897e2165c7e44e14850dfcfd68f99127aed88527c21033c8d41cfbe1511a909b63fed68e75a29c3ce30418c39bbb8294c8b36c6a6c16a52ae677503101b06b175ac6868fd01a002000000000101f5580de0577271dce09d2de26e19ec58bf2373b0171473291a8f8be9b04fb289000000000000000000013a920100000000002200208742b16c9fd4e74854dcd84322dd1de06f7993fe627fd2ca0be4b996a936d56b050047304402201b4527c8f420852550af00bbd9149db9b31adcb7e1f127766e75e1e01746df0302202a57bb1e274ed7d3e8dbe5f205de721a23092c1e2ce2135f4750f18f6c0b51b001483045022100b6df309c8e5746a077b1f7c2f299528e164946bd514a5049475af7f5665805da0220392ae877112a3c52f74d190b354b4f5c020da9c1a71a7a08ced0a5363e795a27012017ea8f5afde8f708258d5669e1bbd454e82ddca8c6c480ec5302b4b1e8051d3d8b76a91406b0950d9feded82239b3e6c9082308900f389de8763ac672102d65aa07658a7214ff129f91a1a22ade2ea4d1b07cc14b2f85a2842c34240836f7c8201208763a914c461c897e2165c7e44e14850dfcfd68f99127aed88527c21033c8d41cfbe1511a909b63fed68e75a29c3ce30418c39bbb8294c8b36c6a6c16a52ae677503101b06b175ac686800000000dc7002a387673f17ebaf08545ccec712a9b6914813cdb83b4270932294f20f660000000000000000" + val oldHtlcSuccessTx = txWithInputInfoCodec.decode(oldHtlcSuccessTxBin.bits).require.value + assert(oldHtlcSuccessTx.isInstanceOf[HtlcSuccessTx]) + assert(oldHtlcSuccessTx.asInstanceOf[HtlcSuccessTx].confirmBefore === BlockHeight(0)) + val htlcSuccessTx = oldHtlcSuccessTx.asInstanceOf[HtlcSuccessTx].copy(confirmBefore = BlockHeight(1105)) + val htlcSuccessTx2 = txWithInputInfoCodec.decode(txWithInputInfoCodec.encode(htlcSuccessTx).require).require.value + assert(htlcSuccessTx === htlcSuccessTx2) + } + { + val oldHtlcTimeoutTxBin = hex"0003 248f0619a4b2a351977b3e5b0ddd700482e1d697d40deea2dd7356df99345d51d0000000002b50c300000000000022002005ff644937d7f5f32ec194424f551371e8d4bcf2cda3e1096cdd2fe88687fc408576a914c98707b6420ef3454f3bd10d663adcc04452baea8763ac672102fb20469287c8ade948011bd001440d74633d5e1a98574e4783dd38b76509d8f67c820120876475527c210279d3a1c2086a0968404a0160c8f8c6f88c0ce7184022bb7406f98fdb503ea51452ae67a9140550b2b1621e788d795fe3ae308e7dec06a6a1e088ac6868fd0179020000000001018f0619a4b2a351977b3e5b0ddd700482e1d697d40deea2dd7356df99345d51d0000000000000000000016aa9000000000000220020c980a1573ce6dc6a1bb8a1d60ecffdf2f0a7aa983c49786a2ab1ba8f0cd74a76050047304402200d2b631fb0e5a7f406f3de2b36fe3c0ab3337fe98f114dadf38de8f5548e87eb02203ad7c385c7cf62ac17ec15329dee071ee2c479aceb34a14d700dfeba80008574014730440220653b4ff490b03de06a9053aa7ab0b30e85c484c76900c586db65f7308f38ad86022019ac12ffb127e4a17a01dc6db730d3eccea837d2dc85a0275bdea8b393a2f11d01008576a914c98707b6420ef3454f3bd10d663adcc04452baea8763ac672102fb20469287c8ade948011bd001440d74633d5e1a98574e4783dd38b76509d8f67c820120876475527c210279d3a1c2086a0968404a0160c8f8c6f88c0ce7184022bb7406f98fdb503ea51452ae67a9140550b2b1621e788d795fe3ae308e7dec06a6a1e088ac6868101b06000000000000000003" + val oldHtlcTimeoutTx = txWithInputInfoCodec.decode(oldHtlcTimeoutTxBin.bits).require.value + assert(oldHtlcTimeoutTx.isInstanceOf[HtlcTimeoutTx]) + assert(oldHtlcTimeoutTx.asInstanceOf[HtlcTimeoutTx].confirmBefore === BlockHeight(0)) + val htlcTimeoutTx = oldHtlcTimeoutTx.asInstanceOf[HtlcTimeoutTx].copy(confirmBefore = BlockHeight(1105)) + val htlcTimeoutTx2 = txWithInputInfoCodec.decode(txWithInputInfoCodec.encode(htlcTimeoutTx).require).require.value + assert(htlcTimeoutTx === htlcTimeoutTx2) + } + { + val oldClaimHtlcSuccessTxBin = hex"0016 24e75b5236d1cdd482a6f540d5f08b9aa27b74a9ecae6e2622a67110b3ee1b3d89000000002bb0ad01000000000022002063e22369052a2bad9eb124737742690b8d1aba7693869d041da16443e2973e638576a91494957f4639ebc6f8a30e126552aff8429174dfb18763ac672102e1aa04ff55771238012edb958e6e0525af0415a01d52dd8d5f69fb391e586adc7c820120876475527c2102384e785d34b3b1fe35d3c093a750b234fbc79d8316c149e7845929f628a5baa052ae67a9145e3be49c9ace2d9eab11dc5fc29e40cd4148262e88ac6868fd014502000000000101e75b5236d1cdd482a6f540d5f08b9aa27b74a9ecae6e2622a67110b3ee1b3d890000000000000000000162970100000000001600140262586eef1a2c8f47ebc139a2733123d09e315603483045022100898f6b51361f044c8c54468dcb1b9decd75edcc1b69b62e584059b512eb075900220675f8b23aa402bb7d5bfdefbac4074088f82bd34d09b34c651c0dff48e6967930120efb38d645311af59c028cd8a9bf8ee21ff9e7c1a1cff1a0398a0315280247ac38576a91494957f4639ebc6f8a30e126552aff8429174dfb18763ac672102e1aa04ff55771238012edb958e6e0525af0415a01d52dd8d5f69fb391e586adc7c820120876475527c2102384e785d34b3b1fe35d3c093a750b234fbc79d8316c149e7845929f628a5baa052ae67a9145e3be49c9ace2d9eab11dc5fc29e40cd4148262e88ac686800000000ad02394dd18774f6f403f783afb518b6c69a89d531f718b29868ddca3e7905020000000000000000" + val oldClaimHtlcSuccessTx = txWithInputInfoCodec.decode(oldClaimHtlcSuccessTxBin.bits).require.value + assert(oldClaimHtlcSuccessTx.isInstanceOf[ClaimHtlcSuccessTx]) + assert(oldClaimHtlcSuccessTx.asInstanceOf[ClaimHtlcSuccessTx].confirmBefore === BlockHeight(0)) + val claimHtlcSuccessTx = oldClaimHtlcSuccessTx.asInstanceOf[ClaimHtlcSuccessTx].copy(confirmBefore = BlockHeight(1105)) + val claimHtlcSuccessTx2 = txWithInputInfoCodec.decode(txWithInputInfoCodec.encode(claimHtlcSuccessTx).require).require.value + assert(claimHtlcSuccessTx === claimHtlcSuccessTx2) + } + { + val oldClaimHtlcTimeoutTxBin = hex"0005 24df6aa4cd4e8877e4b3a363d6180951036d6f18ef214dabd50a6c05a60077d4d8000000002b50c30000000000002200205db892d76fb358ed508ec09c77b5a184cd9de3aa3e74a025a5c5d3f7adc221f78b76a914e0c241e0656088953f84475cbe5c70ded12e05b58763ac6721028876f3e23b21e07f889f10fc2aa0875b96021359a06d0b7f52a79fc284f6b2837c8201208763a9144ae5c96e8b7495fd35a5ca5681aa7c8f4ab6bc9b88527c2102b4d398ee7a42e87012de5a76832d9ebfa8532ef267490a7f3fe75b2c2e18cc9152ae677503981a06b175ac6868fd012b02000000000101df6aa4cd4e8877e4b3a363d6180951036d6f18ef214dabd50a6c05a60077d4d80000000000000000000106ae0000000000001600142b8e221121004b248f6551a0c6fb8ce219f4997e03483045022100ecdb8179f92c097594844756ac2bd7948f1c44ae74b54dfda86971800e59bf980220125edbe563f24cde15fa1fa25f4eac7cb938a3ace6f9329eb487938005c7621501008b76a914e0c241e0656088953f84475cbe5c70ded12e05b58763ac6721028876f3e23b21e07f889f10fc2aa0875b96021359a06d0b7f52a79fc284f6b2837c8201208763a9144ae5c96e8b7495fd35a5ca5681aa7c8f4ab6bc9b88527c2102b4d398ee7a42e87012de5a76832d9ebfa8532ef267490a7f3fe75b2c2e18cc9152ae677503981a06b175ac6868981a06000000000000000003" + val oldClaimHtlcTimeoutTx = txWithInputInfoCodec.decode(oldClaimHtlcTimeoutTxBin.bits).require.value + assert(oldClaimHtlcTimeoutTx.isInstanceOf[ClaimHtlcTimeoutTx]) + assert(oldClaimHtlcTimeoutTx.asInstanceOf[ClaimHtlcTimeoutTx].confirmBefore === BlockHeight(0)) + val claimHtlcTimeoutTx = oldClaimHtlcTimeoutTx.asInstanceOf[ClaimHtlcTimeoutTx].copy(confirmBefore = BlockHeight(1105)) + val claimHtlcTimeoutTx2 = txWithInputInfoCodec.decode(txWithInputInfoCodec.encode(claimHtlcTimeoutTx).require).require.value + assert(claimHtlcTimeoutTx === claimHtlcTimeoutTx2) + } + } + } From 29b5039bd3c99b8a87ce762ab49db382cf24d962 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 13 Jan 2022 09:32:04 +0100 Subject: [PATCH 08/23] fixup! Fix review comments for 9bfdd90 --- .../main/scala/fr/acinq/eclair/channel/Helpers.scala | 11 ++++++----- .../internal/channel/version0/ChannelCodecs0.scala | 5 ++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 1c699f7b2d..f00a0aee07 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -1082,11 +1082,12 @@ object Helpers { log.error(s"some htlcs don't have a corresponding timeout transaction: tx=$tx, htlcs=$matchingHtlcs, timeout-txs=$matchingTxs") } matchingHtlcs.zip(matchingTxs).collectFirst { - // HTLC transactions cannot change when anchor outputs is unused, so we directly check the txid - case (add, timeoutTx) if timeoutTx.txid == tx.txid => add - // Claim-HTLC transactions can be updated to pay more or less fees by changing the output amount, so we cannot - // rely on txid equality: we instead check that the input is the same and the output goes to the same address. - case (add, timeoutTx) if timeoutTx.txIn.head.outPoint == tx.txIn.head.outPoint && timeoutTx.txOut.head.publicKeyScript == tx.txOut.head.publicKeyScript => add + // HTLC transactions cannot change when anchor outputs is not used, so we could just check that the txids match. + // But claim-htlc transactions can be updated to pay more or less fees by changing the output amount, so we cannot + // rely on txid equality for them. + // We instead check that the mempool transaction spends exactly the same inputs and sends the funds to exactly + // the same addresses as our transaction. + case (add, timeoutTx) if timeoutTx.txIn.map(_.outPoint).toSet == tx.txIn.map(_.outPoint).toSet && timeoutTx.txOut.map(_.publicKeyScript).toSet == tx.txOut.map(_.publicKeyScript).toSet => add } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala index 730b0f287c..f8b35db9f8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala @@ -130,8 +130,11 @@ private[channel] object ChannelCodecs0 { ("txOut" | txOutCodec) :: ("redeemScript" | varsizebinarydata)).as[InputInfo].decodeOnly - // NB: we can safely set htlcId = 0 for htlc txs. This information is only used to find upstream htlcs to fail when a + // We can safely set htlcId = 0 for htlc txs. This information is only used to find upstream htlcs to fail when a // downstream htlc times out, and `Helpers.Closing.timedOutHtlcs` explicitly handles the case where htlcId is missing. + // We can also safely set confirmBefore = 0: we will simply use a high feerate to make these transactions confirm + // as quickly as possible. It's very unlikely that nodes will run into this, so it's a good trade-off between code + // complexity and real world impact. val txWithInputInfoCodec: Codec[TransactionWithInputInfo] = discriminated[TransactionWithInputInfo].by(uint16) .typecase(0x01, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[CommitTx]) .typecase(0x02, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcSuccessTx]) From 0a554f7ce6fbf74dbbb6620eacb2953b07995d23 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 13 Jan 2022 09:39:22 +0100 Subject: [PATCH 09/23] fixup! fixup! Fix review comments for 9bfdd90 --- .../src/main/scala/fr/acinq/eclair/channel/Helpers.scala | 6 +++--- .../test/scala/fr/acinq/eclair/channel/HelpersSpec.scala | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index f00a0aee07..dac0bdd376 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -1057,9 +1057,9 @@ object Helpers { } /** - * Before eclair v0.5.2, we didn't store the mapping between htlc txs and the htlc id. - * This function is only used for channels that were closing before upgrading to eclair v0.5.2 and can be removed - * once we're confident all eclair nodes on the network have been upgraded. + * Before eclair v0.6.0, we didn't store the mapping between htlc txs and the htlc id. + * This function is only used for channels that were closing before upgrading to eclair v0.6.0 (released in may 2021). + * TODO: remove once we're confident all eclair nodes on the network have been upgraded. * * We may have multiple HTLCs with the same payment hash because of MPP. * When a timeout transaction is confirmed, we need to find the best matching HTLC to fail upstream. diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala index a7d0c444f4..c00a71dfc5 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala @@ -181,7 +181,7 @@ class HelpersSpec extends TestKitBaseClass with AnyFunSuiteLike with ChannelStat val localCommit = alice.stateData.asInstanceOf[DATA_CLOSING].commitments.localCommit val remoteCommit = bob.stateData.asInstanceOf[DATA_CLOSING].commitments.remoteCommit - // Channels without anchor outputs that were closing before eclair v0.5.2 will not have their htlcId set after the + // Channels without anchor outputs that were closing before eclair v0.6.0 will not have their htlcId set after the // update, but still need to be able to identify timed out htlcs. val localCommitPublished = if (withoutHtlcId) aliceCommitPublished.copy(htlcTxs = removeHtlcIds(aliceCommitPublished.htlcTxs)) else aliceCommitPublished val remoteCommitPublished = if (withoutHtlcId) bobCommitPublished.copy(claimHtlcTxs = removeClaimHtlcIds(bobCommitPublished.claimHtlcTxs)) else bobCommitPublished From fa3e17d3319663377da63f3e5f7a5b52fae38b37 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 14 Jan 2022 12:13:04 +0100 Subject: [PATCH 10/23] Add more fields to actors private class This avoids passing around fields that are unchanged and repeating a long list of arguments in each behavior. --- .../channel/publish/FinalTxPublisher.scala | 34 +++++---- .../channel/publish/MempoolTxMonitor.scala | 62 ++++++++--------- .../channel/publish/ReplaceableTxFunder.scala | 56 +++++++-------- .../publish/ReplaceableTxPrePublisher.scala | 35 +++++----- .../publish/ReplaceableTxPublisher.scala | 69 ++++++++++--------- .../channel/publish/TxTimeLocksMonitor.scala | 34 ++++----- 6 files changed, 143 insertions(+), 147 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala index 0613d0c3c3..5ba4b4e957 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala @@ -54,7 +54,10 @@ object FinalTxPublisher { Behaviors.setup { context => Behaviors.withTimers { timers => Behaviors.withMdc(loggingInfo.mdc()) { - new FinalTxPublisher(nodeParams, bitcoinClient, watcher, context, timers, loggingInfo).start() + Behaviors.receiveMessagePartial { + case Publish(replyTo, cmd) => new FinalTxPublisher(nodeParams, replyTo, cmd, bitcoinClient, watcher, context, timers, loggingInfo).checkTimeLocks() + case Stop => Behaviors.stopped + } } } } @@ -63,6 +66,8 @@ object FinalTxPublisher { } private class FinalTxPublisher(nodeParams: NodeParams, + replyTo: ActorRef[TxPublisher.PublishTxResult], + cmd: TxPublisher.PublishFinalTx, bitcoinClient: BitcoinCoreClient, watcher: ActorRef[ZmqWatcher.Command], context: ActorContext[FinalTxPublisher.Command], @@ -73,25 +78,18 @@ private class FinalTxPublisher(nodeParams: NodeParams, private val log = context.log - def start(): Behavior[Command] = { - Behaviors.receiveMessagePartial { - case Publish(replyTo, cmd) => checkTimeLocks(replyTo, cmd) - case Stop => Behaviors.stopped - } - } - - def checkTimeLocks(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishFinalTx): Behavior[Command] = { + def checkTimeLocks(): Behavior[Command] = { val timeLocksChecker = context.spawn(TxTimeLocksMonitor(nodeParams, watcher, loggingInfo), "time-locks-monitor") timeLocksChecker ! CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.tx, cmd.desc) Behaviors.receiveMessagePartial { - case TimeLocksOk => checkParentPublished(replyTo, cmd) + case TimeLocksOk => checkParentPublished() case Stop => timeLocksChecker ! TxTimeLocksMonitor.Stop Behaviors.stopped } } - def checkParentPublished(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishFinalTx): Behavior[Command] = { + def checkParentPublished(): Behavior[Command] = { cmd.parentTx_opt match { case Some(parentTxId) => context.self ! CheckParentTx @@ -103,33 +101,33 @@ private class FinalTxPublisher(nodeParams: NodeParams, case Failure(reason) => UnknownFailure(reason) } Behaviors.same - case ParentTxOk => publish(replyTo, cmd) + case ParentTxOk => publish() case ParentTxMissing => log.debug("parent tx is missing, retrying after delay...") timers.startSingleTimer(CheckParentTx, (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis) Behaviors.same case UnknownFailure(reason) => log.error("could not check parent tx", reason) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.UnknownTxFailure)) + sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.UnknownTxFailure)) case Stop => Behaviors.stopped } - case None => publish(replyTo, cmd) + case None => publish() } } - def publish(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishFinalTx): Behavior[Command] = { + def publish(): Behavior[Command] = { val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), "mempool-tx-monitor") txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), cmd.tx, cmd.input, cmd.desc, cmd.fee) Behaviors.receiveMessagePartial { - case WrappedTxResult(MempoolTxMonitor.TxConfirmed(tx)) => sendResult(replyTo, TxPublisher.TxConfirmed(cmd, tx)) - case WrappedTxResult(MempoolTxMonitor.TxRejected(_, reason)) => sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) + case WrappedTxResult(MempoolTxMonitor.TxConfirmed(tx)) => sendResult(TxPublisher.TxConfirmed(cmd, tx)) + case WrappedTxResult(MempoolTxMonitor.TxRejected(_, reason)) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) case Stop => txMonitor ! MempoolTxMonitor.Stop Behaviors.stopped } } - def sendResult(replyTo: ActorRef[TxPublisher.PublishTxResult], result: TxPublisher.PublishTxResult): Behavior[Command] = { + def sendResult(result: TxPublisher.PublishTxResult): Behavior[Command] = { replyTo ! result Behaviors.receiveMessagePartial { case Stop => Behaviors.stopped diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala index 834de8d48d..44146d65ea 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala @@ -58,72 +58,68 @@ object MempoolTxMonitor { def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, loggingInfo: TxPublishLogContext): Behavior[Command] = { Behaviors.setup { context => Behaviors.withMdc(loggingInfo.mdc()) { - new MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo, context).start() + Behaviors.receiveMessagePartial { + case cmd: Publish => new MempoolTxMonitor(nodeParams, cmd, bitcoinClient, loggingInfo, context).publish() + case Stop => Behaviors.stopped + } } } } } -private class MempoolTxMonitor(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, loggingInfo: TxPublishLogContext, context: ActorContext[MempoolTxMonitor.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { +private class MempoolTxMonitor(nodeParams: NodeParams, cmd: MempoolTxMonitor.Publish, bitcoinClient: BitcoinCoreClient, loggingInfo: TxPublishLogContext, context: ActorContext[MempoolTxMonitor.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { import MempoolTxMonitor._ private val log = context.log - def start(): Behavior[Command] = { - Behaviors.receiveMessagePartial { - case Publish(replyTo, tx, input, desc, fee) => publish(replyTo, tx, input, desc, fee) - case Stop => Behaviors.stopped - } - } - - def publish(replyTo: ActorRef[TxResult], tx: Transaction, input: OutPoint, desc: String, fee: Satoshi): Behavior[Command] = { - context.pipeToSelf(bitcoinClient.publishTransaction(tx)) { + def publish(): Behavior[Command] = { + context.pipeToSelf(bitcoinClient.publishTransaction(cmd.tx)) { case Success(_) => PublishOk case Failure(reason) => PublishFailed(reason) } Behaviors.receiveMessagePartial { case PublishOk => - log.debug("txid={} was successfully published, waiting for confirmation...", tx.txid) - context.system.eventStream ! EventStream.Publish(TransactionPublished(loggingInfo.channelId_opt.getOrElse(ByteVector32.Zeroes), loggingInfo.remoteNodeId, tx, fee, desc)) - waitForConfirmation(replyTo, tx, input) + log.debug("txid={} was successfully published, waiting for confirmation...", cmd.tx.txid) + context.system.eventStream ! EventStream.Publish(TransactionPublished(loggingInfo.channelId_opt.getOrElse(ByteVector32.Zeroes), loggingInfo.remoteNodeId, cmd.tx, cmd.fee, cmd.desc)) + waitForConfirmation() case PublishFailed(reason) if reason.getMessage.contains("rejecting replacement") => log.info("could not publish tx: a conflicting mempool transaction is already in the mempool") - sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) + sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) case PublishFailed(reason) if reason.getMessage.contains("bad-txns-inputs-missingorspent") => // This can only happen if one of our inputs is already spent by a confirmed transaction or doesn't exist (e.g. // unconfirmed wallet input that has been replaced). - checkInputStatus(input) + checkInputStatus(cmd.input) Behaviors.same case PublishFailed(reason) => log.error("could not publish transaction", reason) - sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.UnknownTxFailure)) + sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.UnknownTxFailure)) case status: InputStatus => if (status.spentConfirmed) { log.info("could not publish tx: a conflicting transaction is already confirmed") - sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.ConflictingTxConfirmed)) + sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxConfirmed)) } else if (status.spentUnconfirmed) { log.info("could not publish tx: a conflicting mempool transaction is already in the mempool") - sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) + sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) } else { log.info("could not publish tx: one of our wallet inputs is not available") - sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.WalletInputGone)) + sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.WalletInputGone)) } case CheckInputFailed(reason) => log.error("could not check input status", reason) - sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true))) // we act as if the input is potentially still spendable + sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true))) // we act as if the input is potentially still spendable case Stop => Behaviors.stopped } } - def waitForConfirmation(replyTo: ActorRef[TxResult], tx: Transaction, input: OutPoint): Behavior[Command] = { + def waitForConfirmation(): Behavior[Command] = { val messageAdapter = context.messageAdapter[CurrentBlockCount](cbc => WrappedCurrentBlockCount(cbc.blockCount)) context.system.eventStream ! EventStream.Subscribe(messageAdapter) Behaviors.receiveMessagePartial { case WrappedCurrentBlockCount(_) => - context.pipeToSelf(bitcoinClient.getTxConfirmations(tx.txid)) { + context.pipeToSelf(bitcoinClient.getTxConfirmations(cmd.tx.txid)) { case Success(Some(confirmations)) => TxConfirmations(confirmations) case Success(None) => TxNotFound case Failure(reason) => GetTxConfirmationsFailed(reason) @@ -131,18 +127,18 @@ private class MempoolTxMonitor(nodeParams: NodeParams, bitcoinClient: BitcoinCor Behaviors.same case TxConfirmations(confirmations) => if (confirmations == 1) { - log.info("txid={} has been confirmed, waiting to reach min depth", tx.txid) + log.info("txid={} has been confirmed, waiting to reach min depth", cmd.tx.txid) } if (nodeParams.minDepthBlocks <= confirmations) { - log.info("txid={} has reached min depth", tx.txid) - context.system.eventStream ! EventStream.Publish(TransactionConfirmed(loggingInfo.channelId_opt.getOrElse(ByteVector32.Zeroes), loggingInfo.remoteNodeId, tx)) - sendResult(replyTo, TxConfirmed(tx), Some(messageAdapter)) + log.info("txid={} has reached min depth", cmd.tx.txid) + context.system.eventStream ! EventStream.Publish(TransactionConfirmed(loggingInfo.channelId_opt.getOrElse(ByteVector32.Zeroes), loggingInfo.remoteNodeId, cmd.tx)) + sendResult(cmd.replyTo, TxConfirmed(cmd.tx), Some(messageAdapter)) } else { Behaviors.same } case TxNotFound => - log.warn("txid={} has been evicted from the mempool", tx.txid) - checkInputStatus(input) + log.warn("txid={} has been evicted from the mempool", cmd.tx.txid) + checkInputStatus(cmd.input) Behaviors.same case GetTxConfirmationsFailed(reason) => log.error("could not get tx confirmations", reason) @@ -151,17 +147,17 @@ private class MempoolTxMonitor(nodeParams: NodeParams, bitcoinClient: BitcoinCor case status: InputStatus => if (status.spentConfirmed) { log.info("tx was evicted from the mempool: a conflicting transaction has been confirmed") - sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.ConflictingTxConfirmed)) + sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxConfirmed)) } else if (status.spentUnconfirmed) { log.info("tx was evicted from the mempool: a conflicting transaction replaced it") - sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) + sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) } else { log.info("tx was evicted from the mempool: one of our wallet inputs disappeared") - sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.WalletInputGone)) + sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.WalletInputGone)) } case CheckInputFailed(reason) => log.error("could not check input status", reason) - sendResult(replyTo, TxRejected(tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true)), Some(messageAdapter)) + sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true)), Some(messageAdapter)) case Stop => context.system.eventStream ! EventStream.Unsubscribe(messageAdapter) Behaviors.stopped diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala index 76a24575c6..338f333d6a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala @@ -71,7 +71,14 @@ object ReplaceableTxFunder { def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, loggingInfo: TxPublishLogContext): Behavior[Command] = { Behaviors.setup { context => Behaviors.withMdc(loggingInfo.mdc()) { - new ReplaceableTxFunder(nodeParams, bitcoinClient, context).start() + Behaviors.receiveMessagePartial { + case FundTransaction(replyTo, cmd, tx, targetFeerate) => + val txFunder = new ReplaceableTxFunder(nodeParams, replyTo, cmd, bitcoinClient, context) + tx match { + case Right(txWithWitnessData) => txFunder.fund(txWithWitnessData, targetFeerate) + case Left(previousTx) => txFunder.bump(previousTx, targetFeerate) + } + } } } } @@ -223,23 +230,18 @@ object ReplaceableTxFunder { } -private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, context: ActorContext[ReplaceableTxFunder.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { +private class ReplaceableTxFunder(nodeParams: NodeParams, + replyTo: ActorRef[ReplaceableTxFunder.FundingResult], + cmd: TxPublisher.PublishReplaceableTx, + bitcoinClient: BitcoinCoreClient, + context: ActorContext[ReplaceableTxFunder.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { import ReplaceableTxFunder._ import nodeParams.{channelKeyManager => keyManager} private val log = context.log - def start(): Behavior[Command] = { - Behaviors.receiveMessagePartial { - case FundTransaction(replyTo, cmd, tx, targetFeerate) => tx match { - case Right(txWithWitnessData) => fund(replyTo, cmd, txWithWitnessData, targetFeerate) - case Left(previousTx) => bump(replyTo, cmd, previousTx, targetFeerate) - } - } - } - - def fund(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, txWithWitnessData: ReplaceableTxWithWitnessData, targetFeerate: FeeratePerKw): Behavior[Command] = { + def fund(txWithWitnessData: ReplaceableTxWithWitnessData, targetFeerate: FeeratePerKw): Behavior[Command] = { txWithWitnessData match { case claimLocalAnchor: ClaimLocalAnchorWithWitnessData => val commitFeerate = cmd.commitments.localCommit.spec.commitTxFeerate @@ -250,15 +252,15 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin replyTo ! FundingFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) Behaviors.stopped } else { - addWalletInputs(replyTo, cmd, claimLocalAnchor, targetFeerate) + addWalletInputs(claimLocalAnchor, targetFeerate) } case htlcTx: HtlcWithWitnessData => val htlcFeerate = cmd.commitments.localCommit.spec.htlcTxFeerate(cmd.commitments.commitmentFormat) if (targetFeerate <= htlcFeerate) { log.info("publishing {} without adding inputs: txid={}", cmd.desc, htlcTx.txInfo.tx.txid) - sign(replyTo, cmd, txWithWitnessData, htlcFeerate, htlcTx.txInfo.amountIn) + sign(txWithWitnessData, htlcFeerate, htlcTx.txInfo.amountIn) } else { - addWalletInputs(replyTo, cmd, htlcTx, targetFeerate) + addWalletInputs(htlcTx, targetFeerate) } case claimHtlcTx: ClaimHtlcWithWitnessData => adjustClaimHtlcTxOutput(claimHtlcTx, targetFeerate, cmd.commitments.localParams.dustLimit) match { @@ -268,12 +270,12 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin replyTo ! FundingFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) Behaviors.stopped case Right(updatedClaimHtlcTx) => - sign(replyTo, cmd, updatedClaimHtlcTx, targetFeerate, updatedClaimHtlcTx.txInfo.amountIn) + sign(updatedClaimHtlcTx, targetFeerate, updatedClaimHtlcTx.txInfo.amountIn) } } } - def bump(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, previousTx: FundedTx, targetFeerate: FeeratePerKw): Behavior[Command] = { + def bump(previousTx: FundedTx, targetFeerate: FeeratePerKw): Behavior[Command] = { adjustPreviousTxOutput(previousTx, targetFeerate, cmd.commitments) match { case AdjustPreviousTxOutputResult.Skip(reason) => log.warn("skipping {} fee bumping: {} (feerate={})", cmd.desc, reason, targetFeerate) @@ -281,16 +283,16 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin Behaviors.stopped case AdjustPreviousTxOutputResult.TxOutputAdjusted(updatedTx) => log.debug("bumping {} fees without adding new inputs: txid={}", cmd.desc, updatedTx.txInfo.tx.txid) - sign(replyTo, cmd, updatedTx, targetFeerate, previousTx.totalAmountIn) + sign(updatedTx, targetFeerate, previousTx.totalAmountIn) case AdjustPreviousTxOutputResult.AddWalletInputs(tx) => log.debug("bumping {} fees requires adding new inputs (feerate={})", cmd.desc, targetFeerate) // We restore the original transaction (remove previous attempt's wallet inputs). val resetTx = tx.updateTx(cmd.txInfo.tx) - addWalletInputs(replyTo, cmd, resetTx, targetFeerate) + addWalletInputs(resetTx, targetFeerate) } } - def addWalletInputs(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, txWithWitnessData: ReplaceableTxWithWalletInputs, targetFeerate: FeeratePerKw): Behavior[Command] = { + def addWalletInputs(txWithWitnessData: ReplaceableTxWithWalletInputs, targetFeerate: FeeratePerKw): Behavior[Command] = { context.pipeToSelf(addInputs(txWithWitnessData, targetFeerate, cmd.commitments)) { case Success((fundedTx, totalAmountIn)) => AddInputsOk(fundedTx, totalAmountIn) case Failure(reason) => AddInputsFailed(reason) @@ -298,7 +300,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin Behaviors.receiveMessagePartial { case AddInputsOk(fundedTx, totalAmountIn) => log.info("added {} wallet input(s) and {} wallet output(s) to {}", fundedTx.txInfo.tx.txIn.length - 1, fundedTx.txInfo.tx.txOut.length - 1, cmd.desc) - sign(replyTo, cmd, fundedTx, targetFeerate, totalAmountIn) + sign(fundedTx, targetFeerate, totalAmountIn) case AddInputsFailed(reason) => if (reason.getMessage.contains("Insufficient funds")) { val nodeOperatorMessage = @@ -315,13 +317,13 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin } } - def sign(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, fundedTx: ReplaceableTxWithWitnessData, txFeerate: FeeratePerKw, amountIn: Satoshi): Behavior[Command] = { + def sign(fundedTx: ReplaceableTxWithWitnessData, txFeerate: FeeratePerKw, amountIn: Satoshi): Behavior[Command] = { val channelKeyPath = keyManager.keyPath(cmd.commitments.localParams, cmd.commitments.channelConfig) fundedTx match { case ClaimLocalAnchorWithWitnessData(anchorTx) => val localSig = keyManager.sign(anchorTx, keyManager.fundingPublicKey(cmd.commitments.localParams.fundingKeyPath), TxOwner.Local, cmd.commitments.commitmentFormat) val signedTx = ClaimLocalAnchorWithWitnessData(addSigs(anchorTx, localSig)) - signWalletInputs(replyTo, cmd, signedTx, txFeerate, amountIn) + signWalletInputs(signedTx, txFeerate, amountIn) case htlcTx: HtlcWithWitnessData => val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, cmd.commitments.localCommit.index) val localHtlcBasepoint = keyManager.htlcPoint(channelKeyPath) @@ -332,7 +334,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin } val hasWalletInputs = htlcTx.txInfo.tx.txIn.size > 1 if (hasWalletInputs) { - signWalletInputs(replyTo, cmd, signedTx, txFeerate, amountIn) + signWalletInputs(signedTx, txFeerate, amountIn) } else { replyTo ! TransactionReady(FundedTx(signedTx, amountIn, txFeerate)) Behaviors.stopped @@ -349,7 +351,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin } } - def signWalletInputs(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, locallySignedTx: ReplaceableTxWithWalletInputs, txFeerate: FeeratePerKw, amountIn: Satoshi): Behavior[Command] = { + def signWalletInputs(locallySignedTx: ReplaceableTxWithWalletInputs, txFeerate: FeeratePerKw, amountIn: Satoshi): Behavior[Command] = { locallySignedTx match { case ClaimLocalAnchorWithWitnessData(anchorTx) => val commitInfo = BitcoinCoreClient.PreviousTx(anchorTx.input, anchorTx.tx.txIn.head.witness) @@ -377,11 +379,11 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, bitcoinClient: Bitcoin log.error("cannot sign {}: {}", cmd.desc, reason) // We reply with the failure only once the utxos are unlocked, otherwise there is a risk that our parent stops // itself, which will automatically stop us before we had a chance to unlock them. - unlockAndStop(replyTo, locallySignedTx.txInfo.input.outPoint, locallySignedTx.txInfo.tx, TxPublisher.TxRejectedReason.UnknownTxFailure) + unlockAndStop(locallySignedTx.txInfo.input.outPoint, locallySignedTx.txInfo.tx, TxPublisher.TxRejectedReason.UnknownTxFailure) } } - def unlockAndStop(replyTo: ActorRef[FundingResult], input: OutPoint, tx: Transaction, failure: TxPublisher.TxRejectedReason): Behavior[Command] = { + def unlockAndStop(input: OutPoint, tx: Transaction, failure: TxPublisher.TxRejectedReason): Behavior[Command] = { val toUnlock = tx.txIn.filterNot(_.outPoint == input).map(_.outPoint) log.debug("unlocking utxos={}", toUnlock.mkString(", ")) context.pipeToSelf(bitcoinClient.unlockOutpoints(toUnlock))(_ => UtxosUnlocked) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala index f655c891c0..65f4787a9d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala @@ -99,32 +99,33 @@ object ReplaceableTxPrePublisher { def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, loggingInfo: TxPublishLogContext): Behavior[Command] = { Behaviors.setup { context => Behaviors.withMdc(loggingInfo.mdc()) { - new ReplaceableTxPrePublisher(nodeParams, bitcoinClient, context).start() + Behaviors.receiveMessagePartial { + case CheckPreconditions(replyTo, cmd) => + val prePublisher = new ReplaceableTxPrePublisher(nodeParams, replyTo, cmd, bitcoinClient, context) + cmd.txInfo match { + case localAnchorTx: Transactions.ClaimLocalAnchorOutputTx => prePublisher.checkAnchorPreconditions(localAnchorTx) + case htlcTx: Transactions.HtlcTx => prePublisher.checkHtlcPreconditions(htlcTx) + case claimHtlcTx: Transactions.ClaimHtlcTx => prePublisher.checkClaimHtlcPreconditions(claimHtlcTx) + } + case Stop => Behaviors.stopped + } } } } } -private class ReplaceableTxPrePublisher(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, context: ActorContext[ReplaceableTxPrePublisher.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { +private class ReplaceableTxPrePublisher(nodeParams: NodeParams, + replyTo: ActorRef[ReplaceableTxPrePublisher.PreconditionsResult], + cmd: TxPublisher.PublishReplaceableTx, + bitcoinClient: BitcoinCoreClient, + context: ActorContext[ReplaceableTxPrePublisher.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { import ReplaceableTxPrePublisher._ private val log = context.log - def start(): Behavior[Command] = { - Behaviors.receiveMessagePartial { - case CheckPreconditions(replyTo, cmd) => - cmd.txInfo match { - case localAnchorTx: Transactions.ClaimLocalAnchorOutputTx => checkAnchorPreconditions(replyTo, cmd, localAnchorTx) - case htlcTx: Transactions.HtlcTx => checkHtlcPreconditions(replyTo, cmd, htlcTx) - case claimHtlcTx: Transactions.ClaimHtlcTx => checkClaimHtlcPreconditions(replyTo, cmd, claimHtlcTx) - } - case Stop => Behaviors.stopped - } - } - - def checkAnchorPreconditions(replyTo: ActorRef[PreconditionsResult], cmd: TxPublisher.PublishReplaceableTx, localAnchorTx: ClaimLocalAnchorOutputTx): Behavior[Command] = { + def checkAnchorPreconditions(localAnchorTx: ClaimLocalAnchorOutputTx): Behavior[Command] = { // We verify that: // - our commit is not confirmed (if it is, no need to claim our anchor) // - their commit is not confirmed (if it is, no need to claim our anchor either) @@ -165,7 +166,7 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, bitcoinClient: B } } - def checkHtlcPreconditions(replyTo: ActorRef[PreconditionsResult], cmd: TxPublisher.PublishReplaceableTx, htlcTx: HtlcTx): Behavior[Command] = { + def checkHtlcPreconditions(htlcTx: HtlcTx): Behavior[Command] = { // We verify that: // - their commit is not confirmed: if it is, there is no need to publish our htlc transactions // - if this is an htlc-success transaction, we have the preimage @@ -228,7 +229,7 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, bitcoinClient: B } } - def checkClaimHtlcPreconditions(replyTo: ActorRef[PreconditionsResult], cmd: TxPublisher.PublishReplaceableTx, claimHtlcTx: ClaimHtlcTx): Behavior[Command] = { + def checkClaimHtlcPreconditions(claimHtlcTx: ClaimHtlcTx): Behavior[Command] = { // We verify that: // - our commit is not confirmed: if it is, there is no need to publish our claim-htlc transactions // - if this is a claim-htlc-success transaction, we have the preimage diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index 343c5166fb..93da429795 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -69,7 +69,10 @@ object ReplaceableTxPublisher { Behaviors.setup { context => Behaviors.withTimers { timers => Behaviors.withMdc(loggingInfo.mdc()) { - new ReplaceableTxPublisher(nodeParams, bitcoinClient, watcher, context, timers, loggingInfo).start() + Behaviors.receiveMessagePartial { + case Publish(replyTo, cmd) => new ReplaceableTxPublisher(nodeParams, replyTo, cmd, bitcoinClient, watcher, context, timers, loggingInfo).checkPreconditions() + case Stop => Behaviors.stopped + } } } } @@ -93,27 +96,27 @@ object ReplaceableTxPublisher { } -private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, watcher: ActorRef[ZmqWatcher.Command], context: ActorContext[ReplaceableTxPublisher.Command], timers: TimerScheduler[ReplaceableTxPublisher.Command], loggingInfo: TxPublishLogContext)(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { +private class ReplaceableTxPublisher(nodeParams: NodeParams, + replyTo: ActorRef[TxPublisher.PublishTxResult], + cmd: TxPublisher.PublishReplaceableTx, + bitcoinClient: BitcoinCoreClient, + watcher: ActorRef[ZmqWatcher.Command], + context: ActorContext[ReplaceableTxPublisher.Command], + timers: TimerScheduler[ReplaceableTxPublisher.Command], + loggingInfo: TxPublishLogContext)(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { import ReplaceableTxPublisher._ private val log = context.log - def start(): Behavior[Command] = { - Behaviors.receiveMessagePartial { - case Publish(replyTo, cmd) => checkPreconditions(replyTo, cmd) - case Stop => Behaviors.stopped - } - } - - def checkPreconditions(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx): Behavior[Command] = { + def checkPreconditions(): Behavior[Command] = { val prePublisher = context.spawn(ReplaceableTxPrePublisher(nodeParams, bitcoinClient, loggingInfo), "pre-publisher") prePublisher ! ReplaceableTxPrePublisher.CheckPreconditions(context.messageAdapter[ReplaceableTxPrePublisher.PreconditionsResult](WrappedPreconditionsResult), cmd) Behaviors.receiveMessagePartial { case WrappedPreconditionsResult(result) => result match { - case ReplaceableTxPrePublisher.PreconditionsOk(txWithWitnessData) => checkTimeLocks(replyTo, cmd, txWithWitnessData) - case ReplaceableTxPrePublisher.PreconditionsFailed(reason) => sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) + case ReplaceableTxPrePublisher.PreconditionsOk(txWithWitnessData) => checkTimeLocks(txWithWitnessData) + case ReplaceableTxPrePublisher.PreconditionsFailed(reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) } case Stop => prePublisher ! ReplaceableTxPrePublisher.Stop @@ -121,15 +124,15 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc } } - def checkTimeLocks(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = { + def checkTimeLocks(txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = { txWithWitnessData match { // There are no time locks on anchor transactions, we can claim them right away. - case _: ClaimLocalAnchorWithWitnessData => fund(replyTo, cmd, txWithWitnessData) + case _: ClaimLocalAnchorWithWitnessData => fund(txWithWitnessData) case _ => val timeLocksChecker = context.spawn(TxTimeLocksMonitor(nodeParams, watcher, loggingInfo), "time-locks-monitor") timeLocksChecker ! TxTimeLocksMonitor.CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.txInfo.tx, cmd.desc) Behaviors.receiveMessagePartial { - case TimeLocksOk => fund(replyTo, cmd, txWithWitnessData) + case TimeLocksOk => fund(txWithWitnessData) case Stop => timeLocksChecker ! TxTimeLocksMonitor.Stop Behaviors.stopped @@ -137,15 +140,15 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc } } - def fund(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = { + def fund(txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = { val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.txInfo.confirmBefore, BlockHeight(nodeParams.currentBlockHeight)) val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, loggingInfo), "tx-funder") txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, Right(txWithWitnessData), targetFeerate) Behaviors.receiveMessagePartial { case WrappedFundingResult(result) => result match { - case success: ReplaceableTxFunder.TransactionReady => publish(replyTo, cmd, success.fundedTx) - case ReplaceableTxFunder.FundingFailed(reason) => sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) + case success: ReplaceableTxFunder.TransactionReady => publish(success.fundedTx) + case ReplaceableTxFunder.FundingFailed(reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) } case Stop => // We can't stop right now, the child actor is currently funding the transaction and will send its result soon. @@ -155,19 +158,19 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc } } - def publish(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, tx: FundedTx): Behavior[Command] = { + def publish(tx: FundedTx): Behavior[Command] = { val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), "mempool-tx-monitor") txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), tx.signedTx, cmd.input, cmd.desc, tx.fee) // We register to new blocks: if the transaction doesn't confirm, we will replace it with one that pays more fees. context.system.eventStream ! EventStream.Subscribe(context.messageAdapter[CurrentBlockCount](cbc => WrappedCurrentBlockCount(cbc.blockCount))) - wait(replyTo, cmd, txMonitor, tx) + wait(txMonitor, tx) } // Wait for our transaction to be confirmed or rejected from the mempool. // If we get close to the confirmation target and our transaction is stuck in the mempool, we will initiate an RBF attempt. - def wait(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, txMonitor: ActorRef[MempoolTxMonitor.Command], tx: FundedTx): Behavior[Command] = { + def wait(txMonitor: ActorRef[MempoolTxMonitor.Command], tx: FundedTx): Behavior[Command] = { Behaviors.receiveMessagePartial { - case WrappedTxResult(MempoolTxMonitor.TxConfirmed(confirmedTx)) => sendResult(replyTo, TxPublisher.TxConfirmed(cmd, confirmedTx)) + case WrappedTxResult(MempoolTxMonitor.TxConfirmed(confirmedTx)) => sendResult(TxPublisher.TxConfirmed(cmd, confirmedTx)) case WrappedTxResult(MempoolTxMonitor.TxRejected(_, reason)) => replyTo ! TxPublisher.TxRejected(loggingInfo.id, cmd, reason) // We wait for our parent to stop us: when that happens we will unlock utxos. @@ -198,7 +201,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc } }) Behaviors.same - case BumpFee(targetFeerate) => fundReplacement(replyTo, cmd, targetFeerate, txMonitor, tx) + case BumpFee(targetFeerate) => fundReplacement(targetFeerate, txMonitor, tx) case Stay => Behaviors.same case Stop => txMonitor ! MempoolTxMonitor.Stop @@ -207,17 +210,17 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc } // Fund a replacement transaction because our previous attempt seems to be stuck in the mempool. - def fundReplacement(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, targetFeerate: FeeratePerKw, previousTxMonitor: ActorRef[MempoolTxMonitor.Command], previousTx: FundedTx): Behavior[Command] = { + def fundReplacement(targetFeerate: FeeratePerKw, previousTxMonitor: ActorRef[MempoolTxMonitor.Command], previousTx: FundedTx): Behavior[Command] = { log.info("bumping {} fees: previous feerate={}, next feerate={}", cmd.desc, previousTx.feerate, targetFeerate) val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, loggingInfo), "tx-funder-rbf") txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, Left(previousTx), targetFeerate) Behaviors.receiveMessagePartial { case WrappedFundingResult(result) => result match { - case success: ReplaceableTxFunder.TransactionReady => publishReplacement(replyTo, cmd, previousTx, previousTxMonitor, success.fundedTx) + case success: ReplaceableTxFunder.TransactionReady => publishReplacement(previousTx, previousTxMonitor, success.fundedTx) case ReplaceableTxFunder.FundingFailed(_) => log.warn("could not fund {} replacement transaction (target feerate={})", cmd.desc, targetFeerate) - wait(replyTo, cmd, previousTxMonitor, previousTx) + wait(previousTxMonitor, previousTx) } case txResult: WrappedTxResult => // This is the result of the previous publishing attempt. @@ -238,7 +241,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc // Publish an RBF attempt. We then have two concurrent transactions: the previous one and the updated one. // Only one of them can be in the mempool, so we wait for the other to be rejected. Once that's done, we're back to a // situation where we have one transaction in the mempool and wait for it to confirm. - def publishReplacement(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, previousTx: FundedTx, previousTxMonitor: ActorRef[MempoolTxMonitor.Command], bumpedTx: FundedTx): Behavior[Command] = { + def publishReplacement(previousTx: FundedTx, previousTxMonitor: ActorRef[MempoolTxMonitor.Command], bumpedTx: FundedTx): Behavior[Command] = { val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), s"mempool-tx-monitor-rbf-${bumpedTx.signedTx.txid}") txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), bumpedTx.signedTx, cmd.input, cmd.desc, bumpedTx.fee) Behaviors.receiveMessagePartial { @@ -246,14 +249,14 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc // Since our transactions conflict, we should always receive a failure from the evicted transaction before one // of them confirms: this case should not happen, so we don't bother unlocking utxos. log.warn("{} was confirmed while we're publishing an RBF attempt", cmd.desc) - sendResult(replyTo, TxPublisher.TxConfirmed(cmd, confirmedTx)) + sendResult(TxPublisher.TxConfirmed(cmd, confirmedTx)) case WrappedTxResult(MempoolTxMonitor.TxRejected(txid, _)) => if (txid == bumpedTx.signedTx.txid) { log.warn("{} transaction paying more fees (txid={}) failed to replace previous transaction", cmd.desc, txid) - cleanUpFailedTxAndWait(replyTo, cmd, bumpedTx.signedTx, previousTxMonitor, previousTx) + cleanUpFailedTxAndWait(bumpedTx.signedTx, previousTxMonitor, previousTx) } else { log.info("previous {} replaced by new transaction paying more fees (txid={})", cmd.desc, bumpedTx.signedTx.txid) - cleanUpFailedTxAndWait(replyTo, cmd, previousTx.signedTx, txMonitor, bumpedTx) + cleanUpFailedTxAndWait(previousTx.signedTx, txMonitor, bumpedTx) } case cbc: WrappedCurrentBlockCount => timers.startSingleTimer(CurrentBlockCountKey, cbc, 1 second) @@ -268,7 +271,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc } // Clean up the failed transaction attempt. Once that's done, go back to the waiting state with the new transaction. - def cleanUpFailedTxAndWait(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, failedTx: Transaction, txMonitor: ActorRef[MempoolTxMonitor.Command], mempoolTx: FundedTx): Behavior[Command] = { + def cleanUpFailedTxAndWait(failedTx: Transaction, txMonitor: ActorRef[MempoolTxMonitor.Command], mempoolTx: FundedTx): Behavior[Command] = { context.pipeToSelf(bitcoinClient.abandonTransaction(failedTx.txid))(_ => UnlockUtxos) Behaviors.receiveMessagePartial { case UnlockUtxos => @@ -283,7 +286,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc case UtxosUnlocked => // Now that we've cleaned up the failed transaction, we can go back to waiting for the current mempool transaction // or bump it if it doesn't confirm fast enough either. - wait(replyTo, cmd, txMonitor, mempoolTx) + wait(txMonitor, mempoolTx) case txResult: WrappedTxResult => // This is the result of the current mempool tx: we will handle this command once we're back in the waiting // state for this transaction. @@ -330,7 +333,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, bitcoinClient: Bitc } /** Use this function to send the result upstream and stop without stopping child actors. */ - def sendResult(replyTo: ActorRef[TxPublisher.PublishTxResult], result: TxPublisher.PublishTxResult): Behavior[Command] = { + def sendResult(result: TxPublisher.PublishTxResult): Behavior[Command] = { replyTo ! result Behaviors.receiveMessagePartial { case WrappedCurrentBlockCount(_) => diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala index 8b4bf22a1d..55da6bb8b8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala @@ -50,27 +50,23 @@ object TxTimeLocksMonitor { def apply(nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], loggingInfo: TxPublishLogContext): Behavior[Command] = { Behaviors.setup { context => Behaviors.withMdc(loggingInfo.mdc()) { - new TxTimeLocksMonitor(nodeParams, watcher, context).start() + Behaviors.receiveMessagePartial { + case cmd: CheckTx => new TxTimeLocksMonitor(nodeParams, cmd, watcher, context).checkAbsoluteTimeLock() + case Stop => Behaviors.stopped + } } } } } -private class TxTimeLocksMonitor(nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], context: ActorContext[TxTimeLocksMonitor.Command]) { +private class TxTimeLocksMonitor(nodeParams: NodeParams, cmd: TxTimeLocksMonitor.CheckTx, watcher: ActorRef[ZmqWatcher.Command], context: ActorContext[TxTimeLocksMonitor.Command]) { import TxTimeLocksMonitor._ private val log = context.log - def start(): Behavior[Command] = { - Behaviors.receiveMessagePartial { - case cmd: CheckTx => checkAbsoluteTimeLock(cmd) - case Stop => Behaviors.stopped - } - } - - def checkAbsoluteTimeLock(cmd: CheckTx): Behavior[Command] = { + def checkAbsoluteTimeLock(): Behavior[Command] = { val blockCount = nodeParams.currentBlockHeight val cltvTimeout = Scripts.cltvTimeout(cmd.tx) if (blockCount < cltvTimeout) { @@ -81,7 +77,7 @@ private class TxTimeLocksMonitor(nodeParams: NodeParams, watcher: ActorRef[ZmqWa case WrappedCurrentBlockCount(currentBlockCount) => if (cltvTimeout <= currentBlockCount) { context.system.eventStream ! EventStream.Unsubscribe(messageAdapter) - checkRelativeTimeLocks(cmd) + checkRelativeTimeLocks() } else { Behaviors.same } @@ -90,11 +86,11 @@ private class TxTimeLocksMonitor(nodeParams: NodeParams, watcher: ActorRef[ZmqWa Behaviors.stopped } } else { - checkRelativeTimeLocks(cmd) + checkRelativeTimeLocks() } } - def checkRelativeTimeLocks(cmd: CheckTx): Behavior[Command] = { + def checkRelativeTimeLocks(): Behavior[Command] = { val csvTimeouts = Scripts.csvTimeouts(cmd.tx) if (csvTimeouts.nonEmpty) { val watchConfirmedResponseMapper: ActorRef[WatchParentTxConfirmedTriggered] = context.messageAdapter(w => ParentTxConfirmed(w.tx.txid)) @@ -103,29 +99,29 @@ private class TxTimeLocksMonitor(nodeParams: NodeParams, watcher: ActorRef[ZmqWa log.info("{} has a relative timeout of {} blocks, watching parentTxId={}", cmd.desc, csvTimeout, parentTxId) watcher ! WatchParentTxConfirmed(watchConfirmedResponseMapper, parentTxId, minDepth = csvTimeout) } - waitForParentsToConfirm(cmd, csvTimeouts.keySet) + waitForParentsToConfirm(csvTimeouts.keySet) } else { - notifySender(cmd) + notifySender() } } - def waitForParentsToConfirm(cmd: CheckTx, parentTxIds: Set[ByteVector32]): Behavior[Command] = { + def waitForParentsToConfirm(parentTxIds: Set[ByteVector32]): Behavior[Command] = { Behaviors.receiveMessagePartial { case ParentTxConfirmed(parentTxId) => log.info("parent tx of {} has been confirmed (parent txid={})", cmd.desc, parentTxId) val remainingParentTxIds = parentTxIds - parentTxId if (remainingParentTxIds.isEmpty) { log.info("all parent txs of {} have been confirmed", cmd.desc) - notifySender(cmd) + notifySender() } else { log.debug("some parent txs of {} are still unconfirmed (parent txids={})", cmd.desc, remainingParentTxIds.mkString(",")) - waitForParentsToConfirm(cmd, remainingParentTxIds) + waitForParentsToConfirm(remainingParentTxIds) } case Stop => Behaviors.stopped } } - def notifySender(cmd: CheckTx): Behavior[Command] = { + def notifySender(): Behavior[Command] = { log.debug("time locks satisfied for {}", cmd.desc) cmd.replyTo ! TimeLocksOk() Behaviors.stopped From 441a8ee752d0ec285aca9db236e11cea1d69f199 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 14 Jan 2022 13:05:06 +0100 Subject: [PATCH 11/23] fixup! Add more fields to actors private class --- .../channel/publish/MempoolTxMonitor.scala | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala index 44146d65ea..20d49227f2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala @@ -86,7 +86,7 @@ private class MempoolTxMonitor(nodeParams: NodeParams, cmd: MempoolTxMonitor.Pub waitForConfirmation() case PublishFailed(reason) if reason.getMessage.contains("rejecting replacement") => log.info("could not publish tx: a conflicting mempool transaction is already in the mempool") - sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) + sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) case PublishFailed(reason) if reason.getMessage.contains("bad-txns-inputs-missingorspent") => // This can only happen if one of our inputs is already spent by a confirmed transaction or doesn't exist (e.g. // unconfirmed wallet input that has been replaced). @@ -94,21 +94,21 @@ private class MempoolTxMonitor(nodeParams: NodeParams, cmd: MempoolTxMonitor.Pub Behaviors.same case PublishFailed(reason) => log.error("could not publish transaction", reason) - sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.UnknownTxFailure)) + sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.UnknownTxFailure)) case status: InputStatus => if (status.spentConfirmed) { log.info("could not publish tx: a conflicting transaction is already confirmed") - sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxConfirmed)) + sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxConfirmed)) } else if (status.spentUnconfirmed) { log.info("could not publish tx: a conflicting mempool transaction is already in the mempool") - sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) + sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) } else { log.info("could not publish tx: one of our wallet inputs is not available") - sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.WalletInputGone)) + sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.WalletInputGone)) } case CheckInputFailed(reason) => log.error("could not check input status", reason) - sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true))) // we act as if the input is potentially still spendable + sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true))) // we act as if the input is potentially still spendable case Stop => Behaviors.stopped } @@ -132,7 +132,7 @@ private class MempoolTxMonitor(nodeParams: NodeParams, cmd: MempoolTxMonitor.Pub if (nodeParams.minDepthBlocks <= confirmations) { log.info("txid={} has reached min depth", cmd.tx.txid) context.system.eventStream ! EventStream.Publish(TransactionConfirmed(loggingInfo.channelId_opt.getOrElse(ByteVector32.Zeroes), loggingInfo.remoteNodeId, cmd.tx)) - sendResult(cmd.replyTo, TxConfirmed(cmd.tx), Some(messageAdapter)) + sendResult(TxConfirmed(cmd.tx), Some(messageAdapter)) } else { Behaviors.same } @@ -147,26 +147,26 @@ private class MempoolTxMonitor(nodeParams: NodeParams, cmd: MempoolTxMonitor.Pub case status: InputStatus => if (status.spentConfirmed) { log.info("tx was evicted from the mempool: a conflicting transaction has been confirmed") - sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxConfirmed)) + sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxConfirmed)) } else if (status.spentUnconfirmed) { log.info("tx was evicted from the mempool: a conflicting transaction replaced it") - sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) + sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) } else { log.info("tx was evicted from the mempool: one of our wallet inputs disappeared") - sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.WalletInputGone)) + sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.WalletInputGone)) } case CheckInputFailed(reason) => log.error("could not check input status", reason) - sendResult(cmd.replyTo, TxRejected(cmd.tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true)), Some(messageAdapter)) + sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true)), Some(messageAdapter)) case Stop => context.system.eventStream ! EventStream.Unsubscribe(messageAdapter) Behaviors.stopped } } - def sendResult(replyTo: ActorRef[TxResult], result: TxResult, blockSubscriber_opt: Option[ActorRef[CurrentBlockCount]] = None): Behavior[Command] = { + def sendResult(result: TxResult, blockSubscriber_opt: Option[ActorRef[CurrentBlockCount]] = None): Behavior[Command] = { blockSubscriber_opt.foreach(actor => context.system.eventStream ! EventStream.Unsubscribe(actor)) - replyTo ! result + cmd.replyTo ! result Behaviors.stopped } From 8d13b911e5c6743bdc9c51909e9901bc093adb04 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 14 Jan 2022 15:55:00 +0100 Subject: [PATCH 12/23] Fix first pass review comments for d1209ad * Protect against herd effect on new blocks * MempoolTxMonitor broadcasts tx status at every block --- .../channel/publish/FinalTxPublisher.scala | 8 +- .../channel/publish/MempoolTxMonitor.scala | 79 ++++++++---- .../publish/ReplaceableTxPublisher.scala | 116 ++++++++---------- .../channel/publish/TxTimeLocksMonitor.scala | 26 ++-- .../publish/MempoolTxMonitorSpec.scala | 12 +- 5 files changed, 136 insertions(+), 105 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala index 5ba4b4e957..36a38e0b49 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala @@ -119,8 +119,12 @@ private class FinalTxPublisher(nodeParams: NodeParams, val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), "mempool-tx-monitor") txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), cmd.tx, cmd.input, cmd.desc, cmd.fee) Behaviors.receiveMessagePartial { - case WrappedTxResult(MempoolTxMonitor.TxConfirmed(tx)) => sendResult(TxPublisher.TxConfirmed(cmd, tx)) - case WrappedTxResult(MempoolTxMonitor.TxRejected(_, reason)) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) + case WrappedTxResult(txResult) => + txResult match { + case _: MempoolTxMonitor.IntermediateTxResult => Behaviors.same + case MempoolTxMonitor.TxRejected(_, reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) + case MempoolTxMonitor.TxDeeplyBuried(tx) => sendResult(TxPublisher.TxConfirmed(cmd, tx)) + } case Stop => txMonitor ! MempoolTxMonitor.Stop Behaviors.stopped diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala index 20d49227f2..88dee4160e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala @@ -17,7 +17,7 @@ package fr.acinq.eclair.channel.publish import akka.actor.typed.eventstream.EventStream -import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler} import akka.actor.typed.{ActorRef, Behavior} import fr.acinq.bitcoin.{ByteVector32, OutPoint, Satoshi, Transaction} import fr.acinq.eclair.NodeParams @@ -27,7 +27,8 @@ import fr.acinq.eclair.channel.publish.TxPublisher.{TxPublishLogContext, TxRejec import fr.acinq.eclair.channel.{TransactionConfirmed, TransactionPublished} import scala.concurrent.ExecutionContext -import scala.util.{Failure, Success} +import scala.concurrent.duration.DurationLong +import scala.util.{Failure, Random, Success} /** * This actor publishes a fully signed transaction and monitors its status. @@ -42,25 +43,40 @@ object MempoolTxMonitor { private case class PublishFailed(reason: Throwable) extends Command private case class InputStatus(spentConfirmed: Boolean, spentUnconfirmed: Boolean) extends Command private case class CheckInputFailed(reason: Throwable) extends Command - private case class TxConfirmations(count: Int) extends Command + private case class TxConfirmations(confirmations: Int, currentBlockCount: Long) extends Command private case object TxNotFound extends Command private case class GetTxConfirmationsFailed(reason: Throwable) extends Command private case class WrappedCurrentBlockCount(currentBlockCount: Long) extends Command + private case class CheckTxConfirmations(currentBlockCount: Long) extends Command case object Stop extends Command // @formatter:on + // Timer key to ensure we don't have multiple concurrent timers running. + private case object CheckTxConfirmationsKey + // @formatter:off + /** Once the transaction is published, we notify the sender of its confirmation status at every block. */ sealed trait TxResult - case class TxConfirmed(tx: Transaction) extends TxResult - case class TxRejected(txid: ByteVector32, reason: TxPublisher.TxRejectedReason) extends TxResult + sealed trait IntermediateTxResult extends TxResult + /** The transaction is still unconfirmed and available in the mempool. */ + case class TxInMempool(txid: ByteVector32, currentBlockCount: Long) extends IntermediateTxResult + /** The transaction is confirmed, but hasn't reached min depth yet, we should wait for more confirmations. */ + case class TxRecentlyConfirmed(txid: ByteVector32, confirmations: Int) extends IntermediateTxResult + sealed trait FinalTxResult extends TxResult + /** The transaction is confirmed and has reached min depth. */ + case class TxDeeplyBuried(tx: Transaction) extends FinalTxResult + /** The transaction has been evicted from the mempool. */ + case class TxRejected(txid: ByteVector32, reason: TxPublisher.TxRejectedReason) extends FinalTxResult // @formatter:on def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, loggingInfo: TxPublishLogContext): Behavior[Command] = { Behaviors.setup { context => - Behaviors.withMdc(loggingInfo.mdc()) { - Behaviors.receiveMessagePartial { - case cmd: Publish => new MempoolTxMonitor(nodeParams, cmd, bitcoinClient, loggingInfo, context).publish() - case Stop => Behaviors.stopped + Behaviors.withTimers { timers => + Behaviors.withMdc(loggingInfo.mdc()) { + Behaviors.receiveMessagePartial { + case cmd: Publish => new MempoolTxMonitor(nodeParams, cmd, bitcoinClient, loggingInfo, context, timers).publish() + case Stop => Behaviors.stopped + } } } } @@ -68,7 +84,12 @@ object MempoolTxMonitor { } -private class MempoolTxMonitor(nodeParams: NodeParams, cmd: MempoolTxMonitor.Publish, bitcoinClient: BitcoinCoreClient, loggingInfo: TxPublishLogContext, context: ActorContext[MempoolTxMonitor.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { +private class MempoolTxMonitor(nodeParams: NodeParams, + cmd: MempoolTxMonitor.Publish, + bitcoinClient: BitcoinCoreClient, + loggingInfo: TxPublishLogContext, + context: ActorContext[MempoolTxMonitor.Command], + timers: TimerScheduler[MempoolTxMonitor.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { import MempoolTxMonitor._ @@ -86,7 +107,7 @@ private class MempoolTxMonitor(nodeParams: NodeParams, cmd: MempoolTxMonitor.Pub waitForConfirmation() case PublishFailed(reason) if reason.getMessage.contains("rejecting replacement") => log.info("could not publish tx: a conflicting mempool transaction is already in the mempool") - sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) case PublishFailed(reason) if reason.getMessage.contains("bad-txns-inputs-missingorspent") => // This can only happen if one of our inputs is already spent by a confirmed transaction or doesn't exist (e.g. // unconfirmed wallet input that has been replaced). @@ -94,21 +115,21 @@ private class MempoolTxMonitor(nodeParams: NodeParams, cmd: MempoolTxMonitor.Pub Behaviors.same case PublishFailed(reason) => log.error("could not publish transaction", reason) - sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.UnknownTxFailure)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.UnknownTxFailure)) case status: InputStatus => if (status.spentConfirmed) { log.info("could not publish tx: a conflicting transaction is already confirmed") - sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxConfirmed)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxConfirmed)) } else if (status.spentUnconfirmed) { log.info("could not publish tx: a conflicting mempool transaction is already in the mempool") - sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) } else { log.info("could not publish tx: one of our wallet inputs is not available") - sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.WalletInputGone)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.WalletInputGone)) } case CheckInputFailed(reason) => log.error("could not check input status", reason) - sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true))) // we act as if the input is potentially still spendable + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true))) // we act as if the input is potentially still spendable case Stop => Behaviors.stopped } @@ -118,22 +139,30 @@ private class MempoolTxMonitor(nodeParams: NodeParams, cmd: MempoolTxMonitor.Pub val messageAdapter = context.messageAdapter[CurrentBlockCount](cbc => WrappedCurrentBlockCount(cbc.blockCount)) context.system.eventStream ! EventStream.Subscribe(messageAdapter) Behaviors.receiveMessagePartial { - case WrappedCurrentBlockCount(_) => + case WrappedCurrentBlockCount(currentBlockCount) => + timers.startSingleTimer(CheckTxConfirmationsKey, CheckTxConfirmations(currentBlockCount), (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis) + Behaviors.same + case CheckTxConfirmations(currentBlockCount) => context.pipeToSelf(bitcoinClient.getTxConfirmations(cmd.tx.txid)) { - case Success(Some(confirmations)) => TxConfirmations(confirmations) + case Success(Some(confirmations)) => TxConfirmations(confirmations, currentBlockCount) case Success(None) => TxNotFound case Failure(reason) => GetTxConfirmationsFailed(reason) } Behaviors.same - case TxConfirmations(confirmations) => + case TxConfirmations(confirmations, currentBlockCount) => if (confirmations == 1) { log.info("txid={} has been confirmed, waiting to reach min depth", cmd.tx.txid) } if (nodeParams.minDepthBlocks <= confirmations) { log.info("txid={} has reached min depth", cmd.tx.txid) context.system.eventStream ! EventStream.Publish(TransactionConfirmed(loggingInfo.channelId_opt.getOrElse(ByteVector32.Zeroes), loggingInfo.remoteNodeId, cmd.tx)) - sendResult(TxConfirmed(cmd.tx), Some(messageAdapter)) + sendFinalResult(TxDeeplyBuried(cmd.tx), Some(messageAdapter)) } else { + if (confirmations == 0) { + cmd.replyTo ! TxInMempool(cmd.tx.txid, currentBlockCount) + } else { + cmd.replyTo ! TxRecentlyConfirmed(cmd.tx.txid, confirmations) + } Behaviors.same } case TxNotFound => @@ -147,24 +176,24 @@ private class MempoolTxMonitor(nodeParams: NodeParams, cmd: MempoolTxMonitor.Pub case status: InputStatus => if (status.spentConfirmed) { log.info("tx was evicted from the mempool: a conflicting transaction has been confirmed") - sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxConfirmed)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxConfirmed)) } else if (status.spentUnconfirmed) { log.info("tx was evicted from the mempool: a conflicting transaction replaced it") - sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) } else { log.info("tx was evicted from the mempool: one of our wallet inputs disappeared") - sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.WalletInputGone)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.WalletInputGone)) } case CheckInputFailed(reason) => log.error("could not check input status", reason) - sendResult(TxRejected(cmd.tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true)), Some(messageAdapter)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true)), Some(messageAdapter)) case Stop => context.system.eventStream ! EventStream.Unsubscribe(messageAdapter) Behaviors.stopped } } - def sendResult(result: TxResult, blockSubscriber_opt: Option[ActorRef[CurrentBlockCount]] = None): Behavior[Command] = { + def sendFinalResult(result: FinalTxResult, blockSubscriber_opt: Option[ActorRef[CurrentBlockCount]] = None): Behavior[Command] = { blockSubscriber_opt.foreach(actor => context.system.eventStream ! EventStream.Unsubscribe(actor)) cmd.replyTo ! result Behaviors.stopped diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index 93da429795..7572b16c9e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -16,11 +16,9 @@ package fr.acinq.eclair.channel.publish -import akka.actor.typed.eventstream.EventStream import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler} import akka.actor.typed.{ActorRef, Behavior} import fr.acinq.bitcoin.{OutPoint, Transaction} -import fr.acinq.eclair.blockchain.CurrentBlockCount import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.blockchain.fee.{FeeEstimator, FeeratePerKw} @@ -31,7 +29,7 @@ import fr.acinq.eclair.{BlockHeight, NodeParams} import scala.concurrent.duration.{DurationInt, DurationLong} import scala.concurrent.{ExecutionContext, Future} -import scala.util.{Random, Success} +import scala.util.Random /** * Created by t-bast on 10/06/2021. @@ -53,17 +51,14 @@ object ReplaceableTxPublisher { private case object TimeLocksOk extends Command private case class WrappedFundingResult(result: ReplaceableTxFunder.FundingResult) extends Command private case class WrappedTxResult(result: MempoolTxMonitor.TxResult) extends Command - private case class WrappedCurrentBlockCount(currentBlockCount: Long) extends Command private case class CheckFee(currentBlockCount: Long) extends Command private case class BumpFee(targetFeerate: FeeratePerKw) extends Command - private case object Stay extends Command private case object UnlockUtxos extends Command private case object UtxosUnlocked extends Command + // @formatter:on - // Keys to ensure we don't have multiple concurrent timers running. + // Timer key to ensure we don't have multiple concurrent timers running. private case object CheckFeeKey - private case object CurrentBlockCountKey - // @formatter:on def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, watcher: ActorRef[ZmqWatcher.Command], loggingInfo: TxPublishLogContext): Behavior[Command] = { Behaviors.setup { context => @@ -147,7 +142,10 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, Behaviors.receiveMessagePartial { case WrappedFundingResult(result) => result match { - case success: ReplaceableTxFunder.TransactionReady => publish(success.fundedTx) + case ReplaceableTxFunder.TransactionReady(tx) => + val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), "mempool-tx-monitor") + txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), tx.signedTx, cmd.input, cmd.desc, tx.fee) + wait(txMonitor, tx) case ReplaceableTxFunder.FundingFailed(reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) } case Stop => @@ -158,51 +156,38 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } - def publish(tx: FundedTx): Behavior[Command] = { - val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), "mempool-tx-monitor") - txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), tx.signedTx, cmd.input, cmd.desc, tx.fee) - // We register to new blocks: if the transaction doesn't confirm, we will replace it with one that pays more fees. - context.system.eventStream ! EventStream.Subscribe(context.messageAdapter[CurrentBlockCount](cbc => WrappedCurrentBlockCount(cbc.blockCount))) - wait(txMonitor, tx) - } - // Wait for our transaction to be confirmed or rejected from the mempool. // If we get close to the confirmation target and our transaction is stuck in the mempool, we will initiate an RBF attempt. def wait(txMonitor: ActorRef[MempoolTxMonitor.Command], tx: FundedTx): Behavior[Command] = { Behaviors.receiveMessagePartial { - case WrappedTxResult(MempoolTxMonitor.TxConfirmed(confirmedTx)) => sendResult(TxPublisher.TxConfirmed(cmd, confirmedTx)) - case WrappedTxResult(MempoolTxMonitor.TxRejected(_, reason)) => - replyTo ! TxPublisher.TxRejected(loggingInfo.id, cmd, reason) - // We wait for our parent to stop us: when that happens we will unlock utxos. - Behaviors.same - case WrappedCurrentBlockCount(currentBlockCount) => - // We avoid a herd effect whenever a new block is found. - timers.startSingleTimer(CheckFeeKey, CheckFee(currentBlockCount), (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis) - Behaviors.same + case WrappedTxResult(txResult) => + txResult match { + case MempoolTxMonitor.TxInMempool(_, currentBlockCount) => + // We avoid a herd effect whenever we fee bump transactions. + timers.startSingleTimer(CheckFeeKey, CheckFee(currentBlockCount), (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis) + Behaviors.same + case MempoolTxMonitor.TxRecentlyConfirmed(_, _) => Behaviors.same // just wait for the tx to be deeply buried + case MempoolTxMonitor.TxDeeplyBuried(confirmedTx) => sendResult(TxPublisher.TxConfirmed(cmd, confirmedTx)) + case MempoolTxMonitor.TxRejected(_, reason) => + replyTo ! TxPublisher.TxRejected(loggingInfo.id, cmd, reason) + // We wait for our parent to stop us: when that happens we will unlock utxos. + Behaviors.same + } case CheckFee(currentBlockCount) => + // We make sure we increase the fees by at least 20% as we get closer to the confirmation target. + val bumpRatio = 1.2 val currentFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.txInfo.confirmBefore, BlockHeight(currentBlockCount)) - val targetFeerate_opt = if (cmd.txInfo.confirmBefore.toLong <= currentBlockCount + 6) { + if (cmd.txInfo.confirmBefore.toLong <= currentBlockCount + 6) { log.debug("{} confirmation target is close (in {} blocks): bumping fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) - // We make sure we increase the fees by at least 20% as we get close to the confirmation target. - Some(currentFeerate.max(tx.feerate * 1.2)) - } else if (tx.feerate * 1.2 <= currentFeerate) { + context.self ! BumpFee(currentFeerate.max(tx.feerate * bumpRatio)) + } else if (tx.feerate * bumpRatio <= currentFeerate) { log.debug("{} confirmation target is in {} blocks: bumping fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) - Some(currentFeerate) + context.self ! BumpFee(currentFeerate) } else { log.debug("{} confirmation target is in {} blocks: no need to bump fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) - None } - targetFeerate_opt.foreach(targetFeerate => { - // We check whether our currently published transaction is confirmed: if it is, it doesn't make sense to bump - // the fee, we're just waiting for enough confirmations to report the result back. - context.pipeToSelf(bitcoinClient.getTxConfirmations(tx.signedTx.txid)) { - case Success(Some(confirmations)) if confirmations > 0 => Stay - case _ => BumpFee(targetFeerate) - } - }) Behaviors.same case BumpFee(targetFeerate) => fundReplacement(targetFeerate, txMonitor, tx) - case Stay => Behaviors.same case Stop => txMonitor ! MempoolTxMonitor.Stop unlockAndStop(cmd.input, Seq(tx.signedTx)) @@ -227,9 +212,6 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, // We don't need to handle it now that we're in the middle of funding, we can defer it to the next state. timers.startSingleTimer(txResult, 1 second) Behaviors.same - case cbc: WrappedCurrentBlockCount => - timers.startSingleTimer(CurrentBlockCountKey, cbc, 1 second) - Behaviors.same case Stop => // We can't stop right away, because the child actor may need to unlock utxos first. // We just wait for the funding process to finish before stopping. @@ -245,22 +227,29 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), s"mempool-tx-monitor-rbf-${bumpedTx.signedTx.txid}") txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), bumpedTx.signedTx, cmd.input, cmd.desc, bumpedTx.fee) Behaviors.receiveMessagePartial { - case WrappedTxResult(MempoolTxMonitor.TxConfirmed(confirmedTx)) => - // Since our transactions conflict, we should always receive a failure from the evicted transaction before one - // of them confirms: this case should not happen, so we don't bother unlocking utxos. - log.warn("{} was confirmed while we're publishing an RBF attempt", cmd.desc) - sendResult(TxPublisher.TxConfirmed(cmd, confirmedTx)) - case WrappedTxResult(MempoolTxMonitor.TxRejected(txid, _)) => - if (txid == bumpedTx.signedTx.txid) { - log.warn("{} transaction paying more fees (txid={}) failed to replace previous transaction", cmd.desc, txid) - cleanUpFailedTxAndWait(bumpedTx.signedTx, previousTxMonitor, previousTx) - } else { - log.info("previous {} replaced by new transaction paying more fees (txid={})", cmd.desc, bumpedTx.signedTx.txid) - cleanUpFailedTxAndWait(previousTx.signedTx, txMonitor, bumpedTx) + case WrappedTxResult(txResult) => + txResult match { + case MempoolTxMonitor.TxDeeplyBuried(confirmedTx) => + // Since our transactions conflict, we should always receive a failure from the evicted transaction before + // one of them confirms: this case should not happen, so we don't bother unlocking utxos. + log.warn("{} was confirmed while we're publishing an RBF attempt", cmd.desc) + sendResult(TxPublisher.TxConfirmed(cmd, confirmedTx)) + case MempoolTxMonitor.TxRejected(txid, _) => + if (txid == bumpedTx.signedTx.txid) { + log.warn("{} transaction paying more fees (txid={}) failed to replace previous transaction", cmd.desc, txid) + cleanUpFailedTxAndWait(bumpedTx.signedTx, previousTxMonitor, previousTx) + } else { + log.info("previous {} replaced by new transaction paying more fees (txid={})", cmd.desc, bumpedTx.signedTx.txid) + cleanUpFailedTxAndWait(previousTx.signedTx, txMonitor, bumpedTx) + } + case _: MempoolTxMonitor.IntermediateTxResult => + // If a new block is found before our replacement transaction reaches the MempoolTxMonitor, we may receive + // an intermediate result for the previous transaction. We want to handle this event once we're back in the + // waiting state, because we may want to fee-bump even more aggressively if we're getting too close to the + // confirmation target. + timers.startSingleTimer(WrappedTxResult(txResult), 1 second) + Behaviors.same } - case cbc: WrappedCurrentBlockCount => - timers.startSingleTimer(CurrentBlockCountKey, cbc, 1 second) - Behaviors.same case Stop => previousTxMonitor ! MempoolTxMonitor.Stop txMonitor ! MempoolTxMonitor.Stop @@ -292,9 +281,6 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, // state for this transaction. timers.startSingleTimer(txResult, 1 second) Behaviors.same - case cbc: WrappedCurrentBlockCount => - timers.startSingleTimer(CurrentBlockCountKey, cbc, 1 second) - Behaviors.same case Stop => // We don't stop right away, because we're cleaning up the failed transaction. // This shouldn't take long so we'll handle this command once we're back in the waiting state. @@ -323,9 +309,6 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case UtxosUnlocked => log.debug("utxos unlocked") Behaviors.stopped - case WrappedCurrentBlockCount(_) => - log.debug("ignoring new block while stopping") - Behaviors.same case Stop => log.debug("waiting for utxos to be unlocked before stopping") Behaviors.same @@ -336,9 +319,6 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, def sendResult(result: TxPublisher.PublishTxResult): Behavior[Command] = { replyTo ! result Behaviors.receiveMessagePartial { - case WrappedCurrentBlockCount(_) => - log.debug("ignoring new block while stopping") - Behaviors.same case Stop => Behaviors.stopped } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala index 55da6bb8b8..9010b4ace0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala @@ -17,7 +17,7 @@ package fr.acinq.eclair.channel.publish import akka.actor.typed.eventstream.EventStream -import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler} import akka.actor.typed.{ActorRef, Behavior} import fr.acinq.bitcoin.{ByteVector32, Transaction} import fr.acinq.eclair.NodeParams @@ -27,6 +27,9 @@ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchParentTxConfirmed, W import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishLogContext import fr.acinq.eclair.transactions.Scripts +import scala.concurrent.duration.DurationLong +import scala.util.Random + /** * Created by t-bast on 10/06/2021. */ @@ -43,16 +46,19 @@ object TxTimeLocksMonitor { sealed trait Command case class CheckTx(replyTo: ActorRef[TimeLocksOk], tx: Transaction, desc: String) extends Command private case class WrappedCurrentBlockCount(currentBlockCount: Long) extends Command + private case object CheckRelativeTimeLock extends Command private case class ParentTxConfirmed(parentTxId: ByteVector32) extends Command case object Stop extends Command // @formatter:on def apply(nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], loggingInfo: TxPublishLogContext): Behavior[Command] = { Behaviors.setup { context => - Behaviors.withMdc(loggingInfo.mdc()) { - Behaviors.receiveMessagePartial { - case cmd: CheckTx => new TxTimeLocksMonitor(nodeParams, cmd, watcher, context).checkAbsoluteTimeLock() - case Stop => Behaviors.stopped + Behaviors.withTimers { timers => + Behaviors.withMdc(loggingInfo.mdc()) { + Behaviors.receiveMessagePartial { + case cmd: CheckTx => new TxTimeLocksMonitor(nodeParams, cmd, watcher, context, timers).checkAbsoluteTimeLock() + case Stop => Behaviors.stopped + } } } } @@ -60,7 +66,11 @@ object TxTimeLocksMonitor { } -private class TxTimeLocksMonitor(nodeParams: NodeParams, cmd: TxTimeLocksMonitor.CheckTx, watcher: ActorRef[ZmqWatcher.Command], context: ActorContext[TxTimeLocksMonitor.Command]) { +private class TxTimeLocksMonitor(nodeParams: NodeParams, + cmd: TxTimeLocksMonitor.CheckTx, + watcher: ActorRef[ZmqWatcher.Command], + context: ActorContext[TxTimeLocksMonitor.Command], + timers: TimerScheduler[TxTimeLocksMonitor.Command]) { import TxTimeLocksMonitor._ @@ -77,10 +87,12 @@ private class TxTimeLocksMonitor(nodeParams: NodeParams, cmd: TxTimeLocksMonitor case WrappedCurrentBlockCount(currentBlockCount) => if (cltvTimeout <= currentBlockCount) { context.system.eventStream ! EventStream.Unsubscribe(messageAdapter) - checkRelativeTimeLocks() + timers.startSingleTimer(CheckRelativeTimeLock, (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis) + Behaviors.same } else { Behaviors.same } + case CheckRelativeTimeLock => checkRelativeTimeLocks() case Stop => context.system.eventStream ! EventStream.Unsubscribe(messageAdapter) Behaviors.stopped diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala index bd71870dc2..355b351aa8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala @@ -26,7 +26,7 @@ import fr.acinq.eclair.blockchain.CurrentBlockCount import fr.acinq.eclair.blockchain.WatcherSpec.{createSpendManyP2WPKH, createSpendP2WPKH} import fr.acinq.eclair.blockchain.bitcoind.BitcoindService import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient -import fr.acinq.eclair.channel.publish.MempoolTxMonitor.{Publish, Stop, TxConfirmed, TxRejected} +import fr.acinq.eclair.channel.publish.MempoolTxMonitor._ import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishLogContext import fr.acinq.eclair.channel.publish.TxPublisher.TxRejectedReason._ import fr.acinq.eclair.channel.{TransactionConfirmed, TransactionPublished} @@ -81,14 +81,20 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi monitor ! Publish(probe.ref, tx, tx.txIn.head.outPoint, "test-tx", 50 sat) waitTxInMempool(bitcoinClient, tx.txid, probe) + // NB: we don't really generate a block, we're testing the case where the tx is still in the mempool. + system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) + probe.expectMsg(TxInMempool(tx.txid, currentBlockHeight(probe))) + probe.expectNoMessage(100 millis) + assert(TestConstants.Alice.nodeParams.minDepthBlocks > 1) generateBlocks(1) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) + probe.expectMsg(TxRecentlyConfirmed(tx.txid, 1)) probe.expectNoMessage(100 millis) // we wait for more than one confirmation to protect against reorgs generateBlocks(TestConstants.Alice.nodeParams.minDepthBlocks - 1) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) - probe.expectMsg(TxConfirmed(tx)) + probe.expectMsg(TxDeeplyBuried(tx)) } test("transaction confirmed after replacing existing mempool transaction") { @@ -105,7 +111,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi generateBlocks(TestConstants.Alice.nodeParams.minDepthBlocks) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) - probe.expectMsg(TxConfirmed(tx2)) + probe.expectMsg(TxDeeplyBuried(tx2)) } test("publish failed (conflicting mempool transaction)") { From 25fa5e60396c16df77f2a0fedb431d283c4a54ca Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 17 Jan 2022 09:42:49 +0100 Subject: [PATCH 13/23] Remove explicit stopping of leaf actors We only need a Stop message at the `FinalTxPublisher` and `ReplaceableTxPublisher` level, their child actors will be automatically stopped whenever these parent actors stop themselves. --- .../channel/publish/FinalTxPublisher.scala | 8 +--- .../channel/publish/MempoolTxMonitor.scala | 26 ++++------ .../publish/ReplaceableTxPrePublisher.scala | 5 -- .../publish/ReplaceableTxPublisher.scala | 48 ++++++++----------- .../channel/publish/TxTimeLocksMonitor.scala | 6 --- .../publish/MempoolTxMonitorSpec.scala | 15 ------ .../publish/TxTimeLocksMonitorSpec.scala | 16 +------ 7 files changed, 33 insertions(+), 91 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala index 36a38e0b49..d3ca8cd043 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala @@ -83,9 +83,7 @@ private class FinalTxPublisher(nodeParams: NodeParams, timeLocksChecker ! CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.tx, cmd.desc) Behaviors.receiveMessagePartial { case TimeLocksOk => checkParentPublished() - case Stop => - timeLocksChecker ! TxTimeLocksMonitor.Stop - Behaviors.stopped + case Stop => Behaviors.stopped } } @@ -125,9 +123,7 @@ private class FinalTxPublisher(nodeParams: NodeParams, case MempoolTxMonitor.TxRejected(_, reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) case MempoolTxMonitor.TxDeeplyBuried(tx) => sendResult(TxPublisher.TxConfirmed(cmd, tx)) } - case Stop => - txMonitor ! MempoolTxMonitor.Stop - Behaviors.stopped + case Stop => Behaviors.stopped } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala index 88dee4160e..0a3f0d0b24 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala @@ -48,7 +48,6 @@ object MempoolTxMonitor { private case class GetTxConfirmationsFailed(reason: Throwable) extends Command private case class WrappedCurrentBlockCount(currentBlockCount: Long) extends Command private case class CheckTxConfirmations(currentBlockCount: Long) extends Command - case object Stop extends Command // @formatter:on // Timer key to ensure we don't have multiple concurrent timers running. @@ -75,7 +74,6 @@ object MempoolTxMonitor { Behaviors.withMdc(loggingInfo.mdc()) { Behaviors.receiveMessagePartial { case cmd: Publish => new MempoolTxMonitor(nodeParams, cmd, bitcoinClient, loggingInfo, context, timers).publish() - case Stop => Behaviors.stopped } } } @@ -130,8 +128,6 @@ private class MempoolTxMonitor(nodeParams: NodeParams, case CheckInputFailed(reason) => log.error("could not check input status", reason) sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true))) // we act as if the input is potentially still spendable - case Stop => - Behaviors.stopped } } @@ -150,20 +146,17 @@ private class MempoolTxMonitor(nodeParams: NodeParams, } Behaviors.same case TxConfirmations(confirmations, currentBlockCount) => - if (confirmations == 1) { - log.info("txid={} has been confirmed, waiting to reach min depth", cmd.tx.txid) - } - if (nodeParams.minDepthBlocks <= confirmations) { + if (confirmations == 0) { + cmd.replyTo ! TxInMempool(cmd.tx.txid, currentBlockCount) + Behaviors.same + } else if (confirmations < nodeParams.minDepthBlocks) { + log.info("txid={} has {} confirmations, waiting to reach min depth", cmd.tx.txid, confirmations) + cmd.replyTo ! TxRecentlyConfirmed(cmd.tx.txid, confirmations) + Behaviors.same + } else { log.info("txid={} has reached min depth", cmd.tx.txid) context.system.eventStream ! EventStream.Publish(TransactionConfirmed(loggingInfo.channelId_opt.getOrElse(ByteVector32.Zeroes), loggingInfo.remoteNodeId, cmd.tx)) sendFinalResult(TxDeeplyBuried(cmd.tx), Some(messageAdapter)) - } else { - if (confirmations == 0) { - cmd.replyTo ! TxInMempool(cmd.tx.txid, currentBlockCount) - } else { - cmd.replyTo ! TxRecentlyConfirmed(cmd.tx.txid, confirmations) - } - Behaviors.same } case TxNotFound => log.warn("txid={} has been evicted from the mempool", cmd.tx.txid) @@ -187,9 +180,6 @@ private class MempoolTxMonitor(nodeParams: NodeParams, case CheckInputFailed(reason) => log.error("could not check input status", reason) sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true)), Some(messageAdapter)) - case Stop => - context.system.eventStream ! EventStream.Unsubscribe(messageAdapter) - Behaviors.stopped } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala index 65f4787a9d..651876f654 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala @@ -44,7 +44,6 @@ object ReplaceableTxPrePublisher { // @formatter:off sealed trait Command case class CheckPreconditions(replyTo: ActorRef[PreconditionsResult], cmd: TxPublisher.PublishReplaceableTx) extends Command - case object Stop extends Command private case object ParentTxOk extends Command private case object CommitTxAlreadyConfirmed extends RuntimeException with Command @@ -107,7 +106,6 @@ object ReplaceableTxPrePublisher { case htlcTx: Transactions.HtlcTx => prePublisher.checkHtlcPreconditions(htlcTx) case claimHtlcTx: Transactions.ClaimHtlcTx => prePublisher.checkClaimHtlcPreconditions(claimHtlcTx) } - case Stop => Behaviors.stopped } } } @@ -162,7 +160,6 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, // If our checks fail, we don't want it to prevent us from trying to publish our commit tx. replyTo ! PreconditionsOk(ClaimLocalAnchorWithWitnessData(localAnchorTx)) Behaviors.stopped - case Stop => Behaviors.stopped } } @@ -194,7 +191,6 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, case None => replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) } Behaviors.stopped - case Stop => Behaviors.stopped } } @@ -257,7 +253,6 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, case None => replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) } Behaviors.stopped - case Stop => Behaviors.stopped } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index 7572b16c9e..1a4888a147 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -113,9 +113,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case ReplaceableTxPrePublisher.PreconditionsOk(txWithWitnessData) => checkTimeLocks(txWithWitnessData) case ReplaceableTxPrePublisher.PreconditionsFailed(reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) } - case Stop => - prePublisher ! ReplaceableTxPrePublisher.Stop - Behaviors.stopped + case Stop => Behaviors.stopped } } @@ -128,9 +126,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, timeLocksChecker ! TxTimeLocksMonitor.CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.txInfo.tx, cmd.desc) Behaviors.receiveMessagePartial { case TimeLocksOk => fund(txWithWitnessData) - case Stop => - timeLocksChecker ! TxTimeLocksMonitor.Stop - Behaviors.stopped + case Stop => Behaviors.stopped } } } @@ -143,9 +139,9 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case WrappedFundingResult(result) => result match { case ReplaceableTxFunder.TransactionReady(tx) => - val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), "mempool-tx-monitor") + val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), s"mempool-tx-monitor-${tx.signedTx.txid}") txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), tx.signedTx, cmd.input, cmd.desc, tx.fee) - wait(txMonitor, tx) + wait(tx) case ReplaceableTxFunder.FundingFailed(reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) } case Stop => @@ -158,7 +154,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, // Wait for our transaction to be confirmed or rejected from the mempool. // If we get close to the confirmation target and our transaction is stuck in the mempool, we will initiate an RBF attempt. - def wait(txMonitor: ActorRef[MempoolTxMonitor.Command], tx: FundedTx): Behavior[Command] = { + def wait(tx: FundedTx): Behavior[Command] = { Behaviors.receiveMessagePartial { case WrappedTxResult(txResult) => txResult match { @@ -170,8 +166,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case MempoolTxMonitor.TxDeeplyBuried(confirmedTx) => sendResult(TxPublisher.TxConfirmed(cmd, confirmedTx)) case MempoolTxMonitor.TxRejected(_, reason) => replyTo ! TxPublisher.TxRejected(loggingInfo.id, cmd, reason) - // We wait for our parent to stop us: when that happens we will unlock utxos. - Behaviors.same + unlockAndStop(cmd.input, Seq(tx.signedTx)) } case CheckFee(currentBlockCount) => // We make sure we increase the fees by at least 20% as we get closer to the confirmation target. @@ -187,25 +182,23 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, log.debug("{} confirmation target is in {} blocks: no need to bump fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) } Behaviors.same - case BumpFee(targetFeerate) => fundReplacement(targetFeerate, txMonitor, tx) - case Stop => - txMonitor ! MempoolTxMonitor.Stop - unlockAndStop(cmd.input, Seq(tx.signedTx)) + case BumpFee(targetFeerate) => fundReplacement(targetFeerate, tx) + case Stop => unlockAndStop(cmd.input, Seq(tx.signedTx)) } } // Fund a replacement transaction because our previous attempt seems to be stuck in the mempool. - def fundReplacement(targetFeerate: FeeratePerKw, previousTxMonitor: ActorRef[MempoolTxMonitor.Command], previousTx: FundedTx): Behavior[Command] = { + def fundReplacement(targetFeerate: FeeratePerKw, previousTx: FundedTx): Behavior[Command] = { log.info("bumping {} fees: previous feerate={}, next feerate={}", cmd.desc, previousTx.feerate, targetFeerate) val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, loggingInfo), "tx-funder-rbf") txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, Left(previousTx), targetFeerate) Behaviors.receiveMessagePartial { case WrappedFundingResult(result) => result match { - case success: ReplaceableTxFunder.TransactionReady => publishReplacement(previousTx, previousTxMonitor, success.fundedTx) + case success: ReplaceableTxFunder.TransactionReady => publishReplacement(previousTx, success.fundedTx) case ReplaceableTxFunder.FundingFailed(_) => log.warn("could not fund {} replacement transaction (target feerate={})", cmd.desc, targetFeerate) - wait(previousTxMonitor, previousTx) + wait(previousTx) } case txResult: WrappedTxResult => // This is the result of the previous publishing attempt. @@ -223,8 +216,8 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, // Publish an RBF attempt. We then have two concurrent transactions: the previous one and the updated one. // Only one of them can be in the mempool, so we wait for the other to be rejected. Once that's done, we're back to a // situation where we have one transaction in the mempool and wait for it to confirm. - def publishReplacement(previousTx: FundedTx, previousTxMonitor: ActorRef[MempoolTxMonitor.Command], bumpedTx: FundedTx): Behavior[Command] = { - val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), s"mempool-tx-monitor-rbf-${bumpedTx.signedTx.txid}") + def publishReplacement(previousTx: FundedTx, bumpedTx: FundedTx): Behavior[Command] = { + val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), s"mempool-tx-monitor-${bumpedTx.signedTx.txid}") txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), bumpedTx.signedTx, cmd.input, cmd.desc, bumpedTx.fee) Behaviors.receiveMessagePartial { case WrappedTxResult(txResult) => @@ -237,10 +230,10 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case MempoolTxMonitor.TxRejected(txid, _) => if (txid == bumpedTx.signedTx.txid) { log.warn("{} transaction paying more fees (txid={}) failed to replace previous transaction", cmd.desc, txid) - cleanUpFailedTxAndWait(bumpedTx.signedTx, previousTxMonitor, previousTx) + cleanUpFailedTxAndWait(bumpedTx.signedTx, previousTx) } else { log.info("previous {} replaced by new transaction paying more fees (txid={})", cmd.desc, bumpedTx.signedTx.txid) - cleanUpFailedTxAndWait(previousTx.signedTx, txMonitor, bumpedTx) + cleanUpFailedTxAndWait(previousTx.signedTx, bumpedTx) } case _: MempoolTxMonitor.IntermediateTxResult => // If a new block is found before our replacement transaction reaches the MempoolTxMonitor, we may receive @@ -251,8 +244,6 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, Behaviors.same } case Stop => - previousTxMonitor ! MempoolTxMonitor.Stop - txMonitor ! MempoolTxMonitor.Stop // We don't know yet which transaction won, so we try abandoning both and unlocking their utxos. // One of the calls will fail (for the transaction that is in the mempool), but we will simply ignore that failure. unlockAndStop(cmd.input, Seq(previousTx.signedTx, bumpedTx.signedTx)) @@ -260,7 +251,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } // Clean up the failed transaction attempt. Once that's done, go back to the waiting state with the new transaction. - def cleanUpFailedTxAndWait(failedTx: Transaction, txMonitor: ActorRef[MempoolTxMonitor.Command], mempoolTx: FundedTx): Behavior[Command] = { + def cleanUpFailedTxAndWait(failedTx: Transaction, mempoolTx: FundedTx): Behavior[Command] = { context.pipeToSelf(bitcoinClient.abandonTransaction(failedTx.txid))(_ => UnlockUtxos) Behaviors.receiveMessagePartial { case UnlockUtxos => @@ -275,7 +266,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case UtxosUnlocked => // Now that we've cleaned up the failed transaction, we can go back to waiting for the current mempool transaction // or bump it if it doesn't confirm fast enough either. - wait(txMonitor, mempoolTx) + wait(mempoolTx) case txResult: WrappedTxResult => // This is the result of the current mempool tx: we will handle this command once we're back in the waiting // state for this transaction. @@ -309,13 +300,16 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case UtxosUnlocked => log.debug("utxos unlocked") Behaviors.stopped + case _: WrappedTxResult => + log.debug("ignoring transaction result while stopping") + Behaviors.same case Stop => log.debug("waiting for utxos to be unlocked before stopping") Behaviors.same } } - /** Use this function to send the result upstream and stop without stopping child actors. */ + /** Use this function to send the result upstream and stop. */ def sendResult(result: TxPublisher.PublishTxResult): Behavior[Command] = { replyTo ! result Behaviors.receiveMessagePartial { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala index 9010b4ace0..66281af1b8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala @@ -48,7 +48,6 @@ object TxTimeLocksMonitor { private case class WrappedCurrentBlockCount(currentBlockCount: Long) extends Command private case object CheckRelativeTimeLock extends Command private case class ParentTxConfirmed(parentTxId: ByteVector32) extends Command - case object Stop extends Command // @formatter:on def apply(nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], loggingInfo: TxPublishLogContext): Behavior[Command] = { @@ -57,7 +56,6 @@ object TxTimeLocksMonitor { Behaviors.withMdc(loggingInfo.mdc()) { Behaviors.receiveMessagePartial { case cmd: CheckTx => new TxTimeLocksMonitor(nodeParams, cmd, watcher, context, timers).checkAbsoluteTimeLock() - case Stop => Behaviors.stopped } } } @@ -93,9 +91,6 @@ private class TxTimeLocksMonitor(nodeParams: NodeParams, Behaviors.same } case CheckRelativeTimeLock => checkRelativeTimeLocks() - case Stop => - context.system.eventStream ! EventStream.Unsubscribe(messageAdapter) - Behaviors.stopped } } else { checkRelativeTimeLocks() @@ -129,7 +124,6 @@ private class TxTimeLocksMonitor(nodeParams: NodeParams, log.debug("some parent txs of {} are still unconfirmed (parent txids={})", cmd.desc, remainingParentTxIds.mkString(",")) waitForParentsToConfirm(remainingParentTxIds) } - case Stop => Behaviors.stopped } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala index 355b351aa8..16c9044289 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala @@ -262,19 +262,4 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi eventListener.expectMsg(TransactionConfirmed(txPublished.channelId, txPublished.remoteNodeId, tx)) } - test("stop actor before transaction confirms") { - val f = createFixture() - import f._ - - val tx = createSpendP2WPKH(parentTx, priv, priv.publicKey, 1_000 sat, 0, 0) - monitor ! Publish(probe.ref, tx, tx.txIn.head.outPoint, "test-tx", 10 sat) - waitTxInMempool(bitcoinClient, tx.txid, probe) - - probe.watch(monitor.toClassic) - probe.expectNoMessage(100 millis) - - monitor ! Stop - probe.expectTerminated(monitor.toClassic, max = 5 seconds) - } - } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitorSpec.scala index 9bf97974e9..e2f70fe0af 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitorSpec.scala @@ -17,13 +17,13 @@ package fr.acinq.eclair.channel.publish import akka.actor.typed.ActorRef -import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, TypedActorRefOps, actorRefAdapter} +import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, actorRefAdapter} import akka.testkit.TestProbe import fr.acinq.bitcoin.{OutPoint, SatoshiLong, Script, Transaction, TxIn, TxOut} import fr.acinq.eclair.blockchain.CurrentBlockCount import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchParentTxConfirmed, WatchParentTxConfirmedTriggered} import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishLogContext -import fr.acinq.eclair.channel.publish.TxTimeLocksMonitor.{CheckTx, Stop, TimeLocksOk} +import fr.acinq.eclair.channel.publish.TxTimeLocksMonitor.{CheckTx, TimeLocksOk} import fr.acinq.eclair.{NodeParams, TestConstants, TestKitBaseClass, randomKey} import org.scalatest.Outcome import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -131,16 +131,4 @@ class TxTimeLocksMonitorSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik probe.expectMsg(TimeLocksOk()) } - test("stop actor before time locks") { f => - import f._ - - val tx = Transaction(2, Nil, TxOut(100_000 sat, Script.pay2wpkh(randomKey().publicKey)) :: Nil, nodeParams.currentBlockHeight + 3) - monitor ! CheckTx(probe.ref, tx, "absolute-delay") - probe.watch(monitor.toClassic) - probe.expectNoMessage(100 millis) - - monitor ! Stop - probe.expectTerminated(monitor.toClassic, max = 5 seconds) - } - } From 6d1b34ece0f7ffaa7dda1c89ee7adf7ade5eb072 Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 17 Jan 2022 09:56:42 +0100 Subject: [PATCH 14/23] Remove intermediate CheckFee message --- .../publish/ReplaceableTxPublisher.scala | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index 1a4888a147..c6cf1a5d99 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -51,14 +51,13 @@ object ReplaceableTxPublisher { private case object TimeLocksOk extends Command private case class WrappedFundingResult(result: ReplaceableTxFunder.FundingResult) extends Command private case class WrappedTxResult(result: MempoolTxMonitor.TxResult) extends Command - private case class CheckFee(currentBlockCount: Long) extends Command private case class BumpFee(targetFeerate: FeeratePerKw) extends Command private case object UnlockUtxos extends Command private case object UtxosUnlocked extends Command // @formatter:on // Timer key to ensure we don't have multiple concurrent timers running. - private case object CheckFeeKey + private case object BumpFeeKey def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, watcher: ActorRef[ZmqWatcher.Command], loggingInfo: TxPublishLogContext): Behavior[Command] = { Behaviors.setup { context => @@ -159,8 +158,21 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case WrappedTxResult(txResult) => txResult match { case MempoolTxMonitor.TxInMempool(_, currentBlockCount) => + // We make sure we increase the fees by at least 20% as we get closer to the confirmation target. + val bumpRatio = 1.2 + val currentFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.txInfo.confirmBefore, BlockHeight(currentBlockCount)) + val targetFeerate_opt = if (cmd.txInfo.confirmBefore.toLong <= currentBlockCount + 6) { + log.debug("{} confirmation target is close (in {} blocks): bumping fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) + Some(currentFeerate.max(tx.feerate * bumpRatio)) + } else if (tx.feerate * bumpRatio <= currentFeerate) { + log.debug("{} confirmation target is in {} blocks: bumping fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) + Some(currentFeerate) + } else { + log.debug("{} confirmation target is in {} blocks: no need to bump fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) + None + } // We avoid a herd effect whenever we fee bump transactions. - timers.startSingleTimer(CheckFeeKey, CheckFee(currentBlockCount), (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis) + targetFeerate_opt.foreach(targetFeerate => timers.startSingleTimer(BumpFeeKey, BumpFee(targetFeerate), (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis)) Behaviors.same case MempoolTxMonitor.TxRecentlyConfirmed(_, _) => Behaviors.same // just wait for the tx to be deeply buried case MempoolTxMonitor.TxDeeplyBuried(confirmedTx) => sendResult(TxPublisher.TxConfirmed(cmd, confirmedTx)) @@ -168,20 +180,6 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, replyTo ! TxPublisher.TxRejected(loggingInfo.id, cmd, reason) unlockAndStop(cmd.input, Seq(tx.signedTx)) } - case CheckFee(currentBlockCount) => - // We make sure we increase the fees by at least 20% as we get closer to the confirmation target. - val bumpRatio = 1.2 - val currentFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.txInfo.confirmBefore, BlockHeight(currentBlockCount)) - if (cmd.txInfo.confirmBefore.toLong <= currentBlockCount + 6) { - log.debug("{} confirmation target is close (in {} blocks): bumping fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) - context.self ! BumpFee(currentFeerate.max(tx.feerate * bumpRatio)) - } else if (tx.feerate * bumpRatio <= currentFeerate) { - log.debug("{} confirmation target is in {} blocks: bumping fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) - context.self ! BumpFee(currentFeerate) - } else { - log.debug("{} confirmation target is in {} blocks: no need to bump fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) - } - Behaviors.same case BumpFee(targetFeerate) => fundReplacement(targetFeerate, tx) case Stop => unlockAndStop(cmd.input, Seq(tx.signedTx)) } From 315b0a63b7e794504ac8908181ea680b4018161f Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 17 Jan 2022 12:18:57 +0100 Subject: [PATCH 15/23] Harmonize sendResult --- .../publish/ReplaceableTxPublisher.scala | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index c6cf1a5d99..43c8ce8afb 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -110,7 +110,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case WrappedPreconditionsResult(result) => result match { case ReplaceableTxPrePublisher.PreconditionsOk(txWithWitnessData) => checkTimeLocks(txWithWitnessData) - case ReplaceableTxPrePublisher.PreconditionsFailed(reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) + case ReplaceableTxPrePublisher.PreconditionsFailed(reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason), None) } case Stop => Behaviors.stopped } @@ -141,7 +141,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), s"mempool-tx-monitor-${tx.signedTx.txid}") txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), tx.signedTx, cmd.input, cmd.desc, tx.fee) wait(tx) - case ReplaceableTxFunder.FundingFailed(reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) + case ReplaceableTxFunder.FundingFailed(reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason), None) } case Stop => // We can't stop right now, the child actor is currently funding the transaction and will send its result soon. @@ -175,10 +175,8 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, targetFeerate_opt.foreach(targetFeerate => timers.startSingleTimer(BumpFeeKey, BumpFee(targetFeerate), (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis)) Behaviors.same case MempoolTxMonitor.TxRecentlyConfirmed(_, _) => Behaviors.same // just wait for the tx to be deeply buried - case MempoolTxMonitor.TxDeeplyBuried(confirmedTx) => sendResult(TxPublisher.TxConfirmed(cmd, confirmedTx)) - case MempoolTxMonitor.TxRejected(_, reason) => - replyTo ! TxPublisher.TxRejected(loggingInfo.id, cmd, reason) - unlockAndStop(cmd.input, Seq(tx.signedTx)) + case MempoolTxMonitor.TxDeeplyBuried(confirmedTx) => sendResult(TxPublisher.TxConfirmed(cmd, confirmedTx), None) + case MempoolTxMonitor.TxRejected(_, reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason), Some(Seq(tx.signedTx))) } case BumpFee(targetFeerate) => fundReplacement(targetFeerate, tx) case Stop => unlockAndStop(cmd.input, Seq(tx.signedTx)) @@ -224,7 +222,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, // Since our transactions conflict, we should always receive a failure from the evicted transaction before // one of them confirms: this case should not happen, so we don't bother unlocking utxos. log.warn("{} was confirmed while we're publishing an RBF attempt", cmd.desc) - sendResult(TxPublisher.TxConfirmed(cmd, confirmedTx)) + sendResult(TxPublisher.TxConfirmed(cmd, confirmedTx), None) case MempoolTxMonitor.TxRejected(txid, _) => if (txid == bumpedTx.signedTx.txid) { log.warn("{} transaction paying more fees (txid={}) failed to replace previous transaction", cmd.desc, txid) @@ -278,6 +276,14 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } + def sendResult(result: TxPublisher.PublishTxResult, toUnlock_opt: Option[Seq[Transaction]]): Behavior[Command] = { + replyTo ! result + toUnlock_opt match { + case Some(txs) => unlockAndStop(cmd.input, txs) + case None => stop() + } + } + def unlockAndStop(input: OutPoint, txs: Seq[Transaction]): Behavior[Command] = { // The bitcoind wallet will keep transactions around even when they can't be published (e.g. one of their inputs has // disappeared but bitcoind thinks it may reappear later), hoping that it will be able to automatically republish @@ -307,9 +313,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } - /** Use this function to send the result upstream and stop. */ - def sendResult(result: TxPublisher.PublishTxResult): Behavior[Command] = { - replyTo ! result + def stop(): Behavior[Command] = { Behaviors.receiveMessagePartial { case Stop => Behaviors.stopped } From 4e635defaf90dde317e00bf6833658cb6c5890ac Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 17 Jan 2022 17:57:19 +0100 Subject: [PATCH 16/23] ReplaceableTxPublisher update confirmation target Instead of letting the TxPublisher create a new, competing replaceable attempt, we can simply tell the existing one to update its confirmation target. --- .../publish/ReplaceableTxPublisher.scala | 40 ++++++++++++++---- .../eclair/channel/publish/TxPublisher.scala | 31 ++++++++------ .../publish/ReplaceableTxPublisherSpec.scala | 38 ++++++++++++++++- .../channel/publish/TxPublisherSpec.scala | 41 +++++++++---------- 4 files changed, 109 insertions(+), 41 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index 43c8ce8afb..cd171f17fb 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -45,6 +45,7 @@ object ReplaceableTxPublisher { // @formatter:off sealed trait Command case class Publish(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx) extends Command + case class UpdateConfirmationTarget(confirmBefore: BlockHeight) extends Command case object Stop extends Command private case class WrappedPreconditionsResult(result: ReplaceableTxPrePublisher.PreconditionsResult) extends Command @@ -64,7 +65,7 @@ object ReplaceableTxPublisher { Behaviors.withTimers { timers => Behaviors.withMdc(loggingInfo.mdc()) { Behaviors.receiveMessagePartial { - case Publish(replyTo, cmd) => new ReplaceableTxPublisher(nodeParams, replyTo, cmd, bitcoinClient, watcher, context, timers, loggingInfo).checkPreconditions() + case Publish(replyTo, cmd) => new ReplaceableTxPublisher(nodeParams, replyTo, cmd, cmd.txInfo.confirmBefore, bitcoinClient, watcher, context, timers, loggingInfo).checkPreconditions() case Stop => Behaviors.stopped } } @@ -93,6 +94,7 @@ object ReplaceableTxPublisher { private class ReplaceableTxPublisher(nodeParams: NodeParams, replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, + var confirmBefore: BlockHeight, bitcoinClient: BitcoinCoreClient, watcher: ActorRef[ZmqWatcher.Command], context: ActorContext[ReplaceableTxPublisher.Command], @@ -112,6 +114,9 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case ReplaceableTxPrePublisher.PreconditionsOk(txWithWitnessData) => checkTimeLocks(txWithWitnessData) case ReplaceableTxPrePublisher.PreconditionsFailed(reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason), None) } + case UpdateConfirmationTarget(target) => + confirmBefore = target + Behaviors.same case Stop => Behaviors.stopped } } @@ -125,13 +130,16 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, timeLocksChecker ! TxTimeLocksMonitor.CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.txInfo.tx, cmd.desc) Behaviors.receiveMessagePartial { case TimeLocksOk => fund(txWithWitnessData) + case UpdateConfirmationTarget(target) => + confirmBefore = target + Behaviors.same case Stop => Behaviors.stopped } } } def fund(txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = { - val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.txInfo.confirmBefore, BlockHeight(nodeParams.currentBlockHeight)) + val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, confirmBefore, BlockHeight(nodeParams.currentBlockHeight)) val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, loggingInfo), "tx-funder") txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, Right(txWithWitnessData), targetFeerate) Behaviors.receiveMessagePartial { @@ -143,6 +151,9 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, wait(tx) case ReplaceableTxFunder.FundingFailed(reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason), None) } + case UpdateConfirmationTarget(target) => + confirmBefore = target + Behaviors.same case Stop => // We can't stop right now, the child actor is currently funding the transaction and will send its result soon. // We just wait for the funding process to finish before stopping (in the next state). @@ -160,15 +171,15 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case MempoolTxMonitor.TxInMempool(_, currentBlockCount) => // We make sure we increase the fees by at least 20% as we get closer to the confirmation target. val bumpRatio = 1.2 - val currentFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, cmd.txInfo.confirmBefore, BlockHeight(currentBlockCount)) - val targetFeerate_opt = if (cmd.txInfo.confirmBefore.toLong <= currentBlockCount + 6) { - log.debug("{} confirmation target is close (in {} blocks): bumping fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) + val currentFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, confirmBefore, BlockHeight(currentBlockCount)) + val targetFeerate_opt = if (confirmBefore.toLong <= currentBlockCount + 6) { + log.debug("{} confirmation target is close (in {} blocks): bumping fees", cmd.desc, confirmBefore.toLong - currentBlockCount) Some(currentFeerate.max(tx.feerate * bumpRatio)) } else if (tx.feerate * bumpRatio <= currentFeerate) { - log.debug("{} confirmation target is in {} blocks: bumping fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) + log.debug("{} confirmation target is in {} blocks: bumping fees", cmd.desc, confirmBefore.toLong - currentBlockCount) Some(currentFeerate) } else { - log.debug("{} confirmation target is in {} blocks: no need to bump fees", cmd.desc, cmd.txInfo.confirmBefore.toLong - currentBlockCount) + log.debug("{} confirmation target is in {} blocks: no need to bump fees", cmd.desc, confirmBefore.toLong - currentBlockCount) None } // We avoid a herd effect whenever we fee bump transactions. @@ -179,6 +190,9 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case MempoolTxMonitor.TxRejected(_, reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason), Some(Seq(tx.signedTx))) } case BumpFee(targetFeerate) => fundReplacement(targetFeerate, tx) + case UpdateConfirmationTarget(target) => + confirmBefore = target + Behaviors.same case Stop => unlockAndStop(cmd.input, Seq(tx.signedTx)) } } @@ -201,6 +215,9 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, // We don't need to handle it now that we're in the middle of funding, we can defer it to the next state. timers.startSingleTimer(txResult, 1 second) Behaviors.same + case UpdateConfirmationTarget(target) => + confirmBefore = target + Behaviors.same case Stop => // We can't stop right away, because the child actor may need to unlock utxos first. // We just wait for the funding process to finish before stopping. @@ -239,6 +256,9 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, timers.startSingleTimer(WrappedTxResult(txResult), 1 second) Behaviors.same } + case UpdateConfirmationTarget(target) => + confirmBefore = target + Behaviors.same case Stop => // We don't know yet which transaction won, so we try abandoning both and unlocking their utxos. // One of the calls will fail (for the transaction that is in the mempool), but we will simply ignore that failure. @@ -268,6 +288,9 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, // state for this transaction. timers.startSingleTimer(txResult, 1 second) Behaviors.same + case UpdateConfirmationTarget(target) => + confirmBefore = target + Behaviors.same case Stop => // We don't stop right away, because we're cleaning up the failed transaction. // This shouldn't take long so we'll handle this command once we're back in the waiting state. @@ -307,6 +330,9 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case _: WrappedTxResult => log.debug("ignoring transaction result while stopping") Behaviors.same + case _: UpdateConfirmationTarget => + log.debug("ignoring confirmation target update while stopping") + Behaviors.same case Stop => log.debug("waiting for utxos to be unlocked before stopping") Behaviors.same diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala index 567887c420..bcf0e4a075 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala @@ -26,7 +26,7 @@ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.channel.Commitments import fr.acinq.eclair.transactions.Transactions.{ReplaceableTransactionWithInputInfo, TransactionWithInputInfo} -import fr.acinq.eclair.{Logs, NodeParams} +import fr.acinq.eclair.{BlockHeight, Logs, NodeParams} import java.util.UUID import scala.concurrent.duration.DurationLong @@ -171,7 +171,7 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact def cmd: PublishTx } private case class FinalAttempt(id: UUID, cmd: PublishFinalTx, actor: ActorRef[FinalTxPublisher.Command]) extends PublishAttempt - private case class ReplaceableAttempt(id: UUID, cmd: PublishReplaceableTx, actor: ActorRef[ReplaceableTxPublisher.Command]) extends PublishAttempt + private case class ReplaceableAttempt(id: UUID, cmd: PublishReplaceableTx, confirmBefore: BlockHeight, actor: ActorRef[ReplaceableTxPublisher.Command]) extends PublishAttempt // @formatter:on private def run(pending: Map[OutPoint, Seq[PublishAttempt]], retryNextBlock: Seq[PublishTx], channelInfo: ChannelLogContext): Behavior[Command] = { @@ -194,21 +194,28 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact } case cmd: PublishReplaceableTx => + val proposedConfirmationTarget = cmd.txInfo.confirmBefore val attempts = pending.getOrElse(cmd.input, Seq.empty) - val alreadyPublished = attempts.collectFirst { - // If there is already an attempt at spending this outpoint with a more aggressive confirmation target, there is no point in publishing again. - case a: ReplaceableAttempt if a.cmd.txInfo.confirmBefore <= cmd.txInfo.confirmBefore => a.cmd.txInfo.confirmBefore - } - alreadyPublished match { - case Some(currentConfirmationTarget) => - log.info("not publishing replaceable {} spending {}:{} with confirmation target={}, publishing is already in progress with confirmation target={}", cmd.desc, cmd.input.txid, cmd.input.index, cmd.txInfo.confirmBefore, currentConfirmationTarget) - Behaviors.same + attempts.collectFirst { + case a: ReplaceableAttempt => a + } match { + case Some(currentAttempt) => + val currentConfirmationTarget = currentAttempt.confirmBefore + if (currentConfirmationTarget <= proposedConfirmationTarget) { + log.info("not publishing replaceable {} spending {}:{} with confirmation target={}, publishing is already in progress with confirmation target={}", cmd.desc, cmd.input.txid, cmd.input.index, proposedConfirmationTarget, currentConfirmationTarget) + Behaviors.same + } else { + log.info("replaceable {} spending {}:{} has new confirmation target={} (previous={})", cmd.desc, cmd.input.txid, cmd.input.index, proposedConfirmationTarget, currentConfirmationTarget) + currentAttempt.actor ! ReplaceableTxPublisher.UpdateConfirmationTarget(proposedConfirmationTarget) + val attempts2 = attempts.filterNot(_.isInstanceOf[ReplaceableAttempt]).appended(currentAttempt.copy(confirmBefore = proposedConfirmationTarget)) + run(pending + (cmd.input -> attempts2), retryNextBlock, channelInfo) + } case None => val publishId = UUID.randomUUID() log.info("publishing replaceable {} spending {}:{} with id={} ({} other attempts)", cmd.desc, cmd.input.txid, cmd.input.index, publishId, attempts.length) val actor = factory.spawnReplaceableTxPublisher(context, TxPublishLogContext(publishId, channelInfo.remoteNodeId, channelInfo.channelId_opt)) actor ! ReplaceableTxPublisher.Publish(context.self, cmd) - run(pending + (cmd.input -> attempts.appended(ReplaceableAttempt(publishId, cmd, actor))), retryNextBlock, channelInfo) + run(pending + (cmd.input -> attempts.appended(ReplaceableAttempt(publishId, cmd, proposedConfirmationTarget, actor))), retryNextBlock, channelInfo) } case result: PublishTxResult => result match { @@ -274,7 +281,7 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact private def stopAttempt(attempt: PublishAttempt): Unit = attempt match { case FinalAttempt(_, _, actor) => actor ! FinalTxPublisher.Stop - case ReplaceableAttempt(_, _, actor) => actor ! ReplaceableTxPublisher.Stop + case ReplaceableAttempt(_, _, _, actor) => actor ! ReplaceableTxPublisher.Stop } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala index 7dbd179ed7..3b195f1678 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala @@ -30,7 +30,7 @@ import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.MempoolTx import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinCoreClient, BitcoinJsonRPCClient} import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.channel._ -import fr.acinq.eclair.channel.publish.ReplaceableTxPublisher.{Publish, Stop} +import fr.acinq.eclair.channel.publish.ReplaceableTxPublisher.{Publish, Stop, UpdateConfirmationTarget} import fr.acinq.eclair.channel.publish.TxPublisher.TxRejectedReason._ import fr.acinq.eclair.channel.publish.TxPublisher._ import fr.acinq.eclair.channel.states.{ChannelStateTestsHelperMethods, ChannelStateTestsTags} @@ -540,6 +540,42 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w }) } + test("commit tx not confirming, updating confirmation target") { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + import f._ + + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) + wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) + probe.expectMsg(commitTx.tx.txid) + + val feerateLow = FeeratePerKw(3000 sat) + val feerateHigh = FeeratePerKw(5000 sat) + setFeerate(feerateLow) + setFeerate(feerateHigh, blockTarget = 6) + // With the initial confirmation target, this will use the low feerate. + publisher ! Publish(probe.ref, anchorTx) + val mempoolTxs1 = getMempoolTxs(2) + assert(mempoolTxs1.map(_.txid).contains(commitTx.tx.txid)) + val mempoolAnchorTx1 = mempoolTxs1.filter(_.txid != commitTx.tx.txid).head + val targetFee1 = Transactions.weight2fee(feerateLow, mempoolTxs1.map(_.weight).sum.toInt) + val actualFee1 = mempoolTxs1.map(_.fees).sum + assert(targetFee1 * 0.9 <= actualFee1 && actualFee1 <= targetFee1 * 1.1, s"actualFee=$actualFee1 targetFee=$targetFee1") + + // The confirmation target has changed (probably because we learnt a payment preimage). + // We should now use the high feerate, which corresponds to that new target. + publisher ! UpdateConfirmationTarget(aliceBlockHeight() + 15) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong)) + awaitCond(!isInMempool(mempoolAnchorTx1.txid), interval = 200 millis, max = 30 seconds) + val mempoolTxs2 = getMempoolTxs(2) + val mempoolAnchorTx2 = mempoolTxs2.filter(_.txid != commitTx.tx.txid).head + assert(mempoolAnchorTx1.fees < mempoolAnchorTx2.fees) + + val targetFee2 = Transactions.weight2fee(feerateHigh, mempoolTxs2.map(_.weight).sum.toInt) + val actualFee2 = mempoolTxs2.map(_.fees).sum + assert(targetFee2 * 0.9 <= actualFee2 && actualFee2 <= targetFee2 * 1.1, s"actualFee=$actualFee2 targetFee=$targetFee2") + }) + } + test("unlock utxos when anchor tx cannot be published") { withFixture(Seq(500 millibtc, 200 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { import f._ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala index c9b9e0792b..ad37b65cb4 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala @@ -118,23 +118,29 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val anchorTx = ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), confirmBefore) val cmd = PublishReplaceableTx(anchorTx, null) txPublisher ! cmd - val child1 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor - val p1 = child1.expectMsgType[ReplaceableTxPublisher.Publish] - assert(p1.cmd === cmd) + val child = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor + assert(child.expectMsgType[ReplaceableTxPublisher.Publish].cmd === cmd) // We ignore duplicates that don't use a more aggressive confirmation target: txPublisher ! PublishReplaceableTx(anchorTx, null) + child.expectNoMessage(100 millis) factory.expectNoMessage(100 millis) val cmdHigherTarget = cmd.copy(txInfo = anchorTx.copy(confirmBefore = confirmBefore + 1)) txPublisher ! cmdHigherTarget + child.expectNoMessage(100 millis) factory.expectNoMessage(100 millis) - // But we retry publishing if the confirmation target is more aggressive than previous attempts: + // But we update the confirmation target when it is more aggressive than previous attempts: val cmdLowerTarget = cmd.copy(txInfo = anchorTx.copy(confirmBefore = confirmBefore - 6)) txPublisher ! cmdLowerTarget - val child2 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor - val p2 = child2.expectMsgType[ReplaceableTxPublisher.Publish] - assert(p2.cmd === cmdLowerTarget) + child.expectMsg(ReplaceableTxPublisher.UpdateConfirmationTarget(confirmBefore - 6)) + factory.expectNoMessage(100 millis) + + // And we update our internal threshold accordingly: + val cmdInBetween = cmd.copy(txInfo = anchorTx.copy(confirmBefore = confirmBefore - 3)) + txPublisher ! cmdInBetween + child.expectNoMessage(100 millis) + factory.expectNoMessage(100 millis) } test("stop publishing attempts when transaction confirms") { f => @@ -192,29 +198,22 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { test("publishing attempt fails (not enough funds)") { f => import f._ - val target1 = BlockHeight(nodeParams.currentBlockHeight + 12) + val target = BlockHeight(nodeParams.currentBlockHeight + 12) val input = OutPoint(randomBytes32(), 7) val paymentHash = randomBytes32() - val cmd1 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3, target1), null) - txPublisher ! cmd1 + val cmd = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3, target), null) + txPublisher ! cmd val attempt1 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt1.actor.expectMsgType[ReplaceableTxPublisher.Publish] - val target2 = BlockHeight(nodeParams.currentBlockHeight + 6) - val cmd2 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3, target2), null) - txPublisher ! cmd2 - val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] - attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish] - - txPublisher ! TxRejected(attempt2.id, cmd2, CouldNotFund) - attempt2.actor.expectMsg(ReplaceableTxPublisher.Stop) - attempt1.actor.expectNoMessage(100 millis) // this error doesn't impact other publishing attempts + txPublisher ! TxRejected(attempt1.id, cmd, CouldNotFund) + attempt1.actor.expectMsg(ReplaceableTxPublisher.Stop) // We automatically retry the failed attempt once a new block is found (we may have more funds now): factory.expectNoMessage(100 millis) system.eventStream.publish(CurrentBlockCount(8200)) - val attempt3 = factory.expectMsgType[ReplaceableTxPublisherSpawned] - assert(attempt3.actor.expectMsgType[ReplaceableTxPublisher.Publish].cmd === cmd2) + val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] + assert(attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish].cmd === cmd) } test("publishing attempt fails (transaction skipped)") { f => From 0bfeb53bde3c8e81b793c352e781391e5b77364e Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 17 Jan 2022 18:01:24 +0100 Subject: [PATCH 17/23] Clarify comment about tx fee --- .../scala/fr/acinq/eclair/channel/publish/TxPublisher.scala | 3 +++ 1 file changed, 3 insertions(+) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala index bcf0e4a075..5dcd7fdb75 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala @@ -79,6 +79,9 @@ object TxPublisher { * Publish a fully signed transaction without modifying it. * NB: the parent tx should only be provided when it's being concurrently published, it's unnecessary when it is * confirmed or when the tx has a relative delay. + * + * @param fee the fee that we're actually paying: it must be set to the mining fee, unless our peer is paying it (in + * which case it must be set to zero here). */ case class PublishFinalTx(tx: Transaction, input: OutPoint, desc: String, fee: Satoshi, parentTx_opt: Option[ByteVector32]) extends PublishTx object PublishFinalTx { From 63b7cde66ff3330b8e302d6cb9a49748ec676c6b Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 17 Jan 2022 18:10:03 +0100 Subject: [PATCH 18/23] fixup! ReplaceableTxPublisher update confirmation target --- .../eclair/channel/publish/ReplaceableTxPublisher.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index cd171f17fb..bd6238d423 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -65,7 +65,7 @@ object ReplaceableTxPublisher { Behaviors.withTimers { timers => Behaviors.withMdc(loggingInfo.mdc()) { Behaviors.receiveMessagePartial { - case Publish(replyTo, cmd) => new ReplaceableTxPublisher(nodeParams, replyTo, cmd, cmd.txInfo.confirmBefore, bitcoinClient, watcher, context, timers, loggingInfo).checkPreconditions() + case Publish(replyTo, cmd) => new ReplaceableTxPublisher(nodeParams, replyTo, cmd, bitcoinClient, watcher, context, timers, loggingInfo).checkPreconditions() case Stop => Behaviors.stopped } } @@ -94,7 +94,6 @@ object ReplaceableTxPublisher { private class ReplaceableTxPublisher(nodeParams: NodeParams, replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, - var confirmBefore: BlockHeight, bitcoinClient: BitcoinCoreClient, watcher: ActorRef[ZmqWatcher.Command], context: ActorContext[ReplaceableTxPublisher.Command], @@ -105,6 +104,8 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, private val log = context.log + private var confirmBefore: BlockHeight = cmd.txInfo.confirmBefore + def checkPreconditions(): Behavior[Command] = { val prePublisher = context.spawn(ReplaceableTxPrePublisher(nodeParams, bitcoinClient, loggingInfo), "pre-publisher") prePublisher ! ReplaceableTxPrePublisher.CheckPreconditions(context.messageAdapter[ReplaceableTxPrePublisher.PreconditionsResult](WrappedPreconditionsResult), cmd) From 7e863ba88bb1a362b599d58747c94455d590cb2c Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 18 Jan 2022 13:59:43 +0100 Subject: [PATCH 19/23] Create case class to hold publish attempts --- .../eclair/channel/publish/TxPublisher.scala | 59 +++++-- .../scala/fr/acinq/eclair/StartupSpec.scala | 2 +- .../scala/fr/acinq/eclair/TestConstants.scala | 27 --- .../fr/acinq/eclair/TestFeeEstimator.scala | 47 ++++++ .../blockchain/fee/FeeEstimatorSpec.scala | 3 +- .../eclair/channel/CommitmentsSpec.scala | 1 - .../publish/ReplaceableTxPublisherSpec.scala | 157 +++++++++--------- .../ChannelStateTestsHelperMethods.scala | 2 +- .../channel/states/e/OfflineStateSpec.scala | 4 +- .../states/g/NegotiatingStateSpec.scala | 4 +- .../interop/rustytests/RustyTestsSpec.scala | 6 +- 11 files changed, 177 insertions(+), 135 deletions(-) create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/TestFeeEstimator.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala index 5dcd7fdb75..4e86c6344a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala @@ -177,32 +177,56 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact private case class ReplaceableAttempt(id: UUID, cmd: PublishReplaceableTx, confirmBefore: BlockHeight, actor: ActorRef[ReplaceableTxPublisher.Command]) extends PublishAttempt // @formatter:on - private def run(pending: Map[OutPoint, Seq[PublishAttempt]], retryNextBlock: Seq[PublishTx], channelInfo: ChannelLogContext): Behavior[Command] = { + /** + * There can be multiple attempts to spend the same [[OutPoint]]. + * Only one of them will work, but we keep track of all of them. + * There is only one [[ReplaceableAttempt]] because we will replace the existing attempt instead of creating a new one. + */ + private case class PublishAttempts(finalAttempts: Seq[FinalAttempt], replaceableAttempt_opt: Option[ReplaceableAttempt]) { + val attempts: Seq[PublishAttempt] = finalAttempts ++ replaceableAttempt_opt.toSeq + val count: Int = attempts.length + + def add(finalAttempt: FinalAttempt): PublishAttempts = copy(finalAttempts = finalAttempts :+ finalAttempt) + + def remove(id: UUID): (Seq[PublishAttempt], PublishAttempts) = { + val (removed, remaining) = finalAttempts.partition(_.id == id) + replaceableAttempt_opt match { + case Some(replaceableAttempt) if replaceableAttempt.id == id => (removed :+ replaceableAttempt, PublishAttempts(remaining, None)) + case _ => (removed, PublishAttempts(remaining, replaceableAttempt_opt)) + } + } + + def isEmpty: Boolean = replaceableAttempt_opt.isEmpty && finalAttempts.isEmpty + } + + private object PublishAttempts { + val empty: PublishAttempts = PublishAttempts(Nil, None) + } + + private def run(pending: Map[OutPoint, PublishAttempts], retryNextBlock: Seq[PublishTx], channelInfo: ChannelLogContext): Behavior[Command] = { Behaviors.receiveMessage { case cmd: PublishFinalTx => - val attempts = pending.getOrElse(cmd.input, Seq.empty) - val alreadyPublished = attempts.exists { - case a: FinalAttempt => a.cmd.tx.txid == cmd.tx.txid - case _ => false - } + val attempts = pending.getOrElse(cmd.input, PublishAttempts.empty) + val alreadyPublished = attempts.finalAttempts.exists(_.cmd.tx.txid == cmd.tx.txid) if (alreadyPublished) { log.info("not publishing {} txid={} spending {}:{}, publishing is already in progress", cmd.desc, cmd.tx.txid, cmd.input.txid, cmd.input.index) Behaviors.same } else { val publishId = UUID.randomUUID() - log.info("publishing {} txid={} spending {}:{} with id={} ({} other attempts)", cmd.desc, cmd.tx.txid, cmd.input.txid, cmd.input.index, publishId, attempts.length) + log.info("publishing {} txid={} spending {}:{} with id={} ({} other attempts)", cmd.desc, cmd.tx.txid, cmd.input.txid, cmd.input.index, publishId, attempts.count) val actor = factory.spawnFinalTxPublisher(context, TxPublishLogContext(publishId, channelInfo.remoteNodeId, channelInfo.channelId_opt)) actor ! FinalTxPublisher.Publish(context.self, cmd) - run(pending + (cmd.input -> attempts.appended(FinalAttempt(publishId, cmd, actor))), retryNextBlock, channelInfo) + run(pending + (cmd.input -> attempts.add(FinalAttempt(publishId, cmd, actor))), retryNextBlock, channelInfo) } case cmd: PublishReplaceableTx => val proposedConfirmationTarget = cmd.txInfo.confirmBefore - val attempts = pending.getOrElse(cmd.input, Seq.empty) - attempts.collectFirst { - case a: ReplaceableAttempt => a - } match { + val attempts = pending.getOrElse(cmd.input, PublishAttempts.empty) + attempts.replaceableAttempt_opt match { case Some(currentAttempt) => + if (currentAttempt.cmd.txInfo.tx.txOut.headOption.map(_.publicKeyScript) != cmd.txInfo.tx.txOut.headOption.map(_.publicKeyScript)) { + log.error("replaceable {} sends to a different address than the previous attempt, this should not happen: proposed={}, previous={}", currentAttempt.cmd.desc, cmd.txInfo, currentAttempt.cmd.txInfo) + } val currentConfirmationTarget = currentAttempt.confirmBefore if (currentConfirmationTarget <= proposedConfirmationTarget) { log.info("not publishing replaceable {} spending {}:{} with confirmation target={}, publishing is already in progress with confirmation target={}", cmd.desc, cmd.input.txid, cmd.input.index, proposedConfirmationTarget, currentConfirmationTarget) @@ -210,23 +234,24 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact } else { log.info("replaceable {} spending {}:{} has new confirmation target={} (previous={})", cmd.desc, cmd.input.txid, cmd.input.index, proposedConfirmationTarget, currentConfirmationTarget) currentAttempt.actor ! ReplaceableTxPublisher.UpdateConfirmationTarget(proposedConfirmationTarget) - val attempts2 = attempts.filterNot(_.isInstanceOf[ReplaceableAttempt]).appended(currentAttempt.copy(confirmBefore = proposedConfirmationTarget)) + val attempts2 = attempts.copy(replaceableAttempt_opt = Some(currentAttempt.copy(confirmBefore = proposedConfirmationTarget))) run(pending + (cmd.input -> attempts2), retryNextBlock, channelInfo) } case None => val publishId = UUID.randomUUID() - log.info("publishing replaceable {} spending {}:{} with id={} ({} other attempts)", cmd.desc, cmd.input.txid, cmd.input.index, publishId, attempts.length) + log.info("publishing replaceable {} spending {}:{} with id={} ({} other attempts)", cmd.desc, cmd.input.txid, cmd.input.index, publishId, attempts.count) val actor = factory.spawnReplaceableTxPublisher(context, TxPublishLogContext(publishId, channelInfo.remoteNodeId, channelInfo.channelId_opt)) actor ! ReplaceableTxPublisher.Publish(context.self, cmd) - run(pending + (cmd.input -> attempts.appended(ReplaceableAttempt(publishId, cmd, proposedConfirmationTarget, actor))), retryNextBlock, channelInfo) + val attempts2 = attempts.copy(replaceableAttempt_opt = Some(ReplaceableAttempt(publishId, cmd, proposedConfirmationTarget, actor))) + run(pending + (cmd.input -> attempts2), retryNextBlock, channelInfo) } case result: PublishTxResult => result match { case TxConfirmed(cmd, _) => - pending.get(cmd.input).foreach(stopAttempts) + pending.get(cmd.input).foreach(a => stopAttempts(a.attempts)) run(pending - cmd.input, retryNextBlock, channelInfo) case TxRejected(id, cmd, reason) => - val (rejectedAttempts, remainingAttempts) = pending.getOrElse(cmd.input, Seq.empty).partition(_.id == id) + val (rejectedAttempts, remainingAttempts) = pending.getOrElse(cmd.input, PublishAttempts.empty).remove(id) stopAttempts(rejectedAttempts) val pending2 = if (remainingAttempts.isEmpty) pending - cmd.input else pending + (cmd.input -> remainingAttempts) reason match { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala index 9e788109a4..a0ecbccf93 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala @@ -39,7 +39,7 @@ class StartupSpec extends AnyFunSuite { val blockCount = new AtomicLong(0) val nodeKeyManager = new LocalNodeKeyManager(randomBytes32(), chainHash = Block.TestnetGenesisBlock.hash) val channelKeyManager = new LocalChannelKeyManager(randomBytes32(), chainHash = Block.TestnetGenesisBlock.hash) - val feeEstimator = new TestConstants.TestFeeEstimator + val feeEstimator = new TestFeeEstimator() val db = TestDatabases.inMemoryDb() NodeParams.makeNodeParams(conf, UUID.fromString("01234567-0123-4567-89ab-0123456789ab"), nodeKeyManager, channelKeyManager, None, db, blockCount, feeEstimator) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 2b7ccf23bb..a19ea16aa0 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -50,33 +50,6 @@ object TestConstants { val anchorOutputsFeeratePerKw: FeeratePerKw = FeeratePerKw(2500 sat) val emptyOnionPacket: OnionRoutingPacket = OnionRoutingPacket(0, ByteVector.fill(33)(0), ByteVector.fill(1300)(0), ByteVector32.Zeroes) - class TestFeeEstimator extends FeeEstimator { - private var currentFeerates = FeeratesPerKw.single(feeratePerKw) - - // @formatter:off - override def getFeeratePerKb(target: Int): FeeratePerKB = FeeratePerKB(currentFeerates.feePerBlock(target)) - override def getFeeratePerKw(target: Int): FeeratePerKw = currentFeerates.feePerBlock(target) - override def getMempoolMinFeeratePerKw(): FeeratePerKw = currentFeerates.mempoolMinFee - // @formatter:on - - def setFeerate(target: Int, feerate: FeeratePerKw): Unit = { - target match { - case 1 => currentFeerates = currentFeerates.copy(block_1 = feerate) - case 2 => currentFeerates = currentFeerates.copy(blocks_2 = feerate) - case t if t <= 6 => currentFeerates = currentFeerates.copy(blocks_6 = feerate) - case t if t <= 12 => currentFeerates = currentFeerates.copy(blocks_12 = feerate) - case t if t <= 36 => currentFeerates = currentFeerates.copy(blocks_36 = feerate) - case t if t <= 72 => currentFeerates = currentFeerates.copy(blocks_72 = feerate) - case t if t <= 144 => currentFeerates = currentFeerates.copy(blocks_144 = feerate) - case _ => currentFeerates = currentFeerates.copy(blocks_1008 = feerate) - } - } - - def setFeerate(feeratesPerKw: FeeratesPerKw): Unit = { - currentFeerates = feeratesPerKw - } - } - case object TestFeature extends Feature { val rfcName = "test_feature" val mandatory = 50000 diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestFeeEstimator.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestFeeEstimator.scala new file mode 100644 index 0000000000..d72046a1d4 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestFeeEstimator.scala @@ -0,0 +1,47 @@ +/* + * Copyright 2019 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair + +import com.softwaremill.quicklens.ModifyPimp +import fr.acinq.eclair.TestConstants.feeratePerKw +import fr.acinq.eclair.blockchain.fee.{FeeEstimator, FeeratePerKB, FeeratePerKw, FeeratesPerKw} + +class TestFeeEstimator extends FeeEstimator { + private var currentFeerates = FeeratesPerKw.single(feeratePerKw) + + // @formatter:off + override def getFeeratePerKb(target: Int): FeeratePerKB = FeeratePerKB(currentFeerates.feePerBlock(target)) + override def getFeeratePerKw(target: Int): FeeratePerKw = currentFeerates.feePerBlock(target) + override def getMempoolMinFeeratePerKw(): FeeratePerKw = currentFeerates.mempoolMinFee + // @formatter:on + + def setFeerate(target: Int, feerate: FeeratePerKw): Unit = { + currentFeerates = currentFeerates + .modify(_.block_1).setToIf(target == 1)(feerate) + .modify(_.blocks_2).setToIf(target == 2)(feerate) + .modify(_.blocks_6).setToIf(target <= 6)(feerate) + .modify(_.blocks_12).setToIf(target <= 12)(feerate) + .modify(_.blocks_36).setToIf(target <= 36)(feerate) + .modify(_.blocks_72).setToIf(target <= 72)(feerate) + .modify(_.blocks_144).setToIf(target <= 144)(feerate) + .modify(_.blocks_1008).setToIf(target > 144)(feerate) + } + + def setFeerate(feeratesPerKw: FeeratesPerKw): Unit = { + currentFeerates = feeratesPerKw + } +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala index be6818a5e6..13bccc9b11 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala @@ -17,10 +17,9 @@ package fr.acinq.eclair.blockchain.fee import fr.acinq.bitcoin.SatoshiLong -import fr.acinq.eclair.TestConstants.TestFeeEstimator import fr.acinq.eclair.blockchain.CurrentFeerates import fr.acinq.eclair.channel.ChannelTypes -import fr.acinq.eclair.randomKey +import fr.acinq.eclair.{TestFeeEstimator, randomKey} import org.scalatest.funsuite.AnyFunSuite class FeeEstimatorSpec extends AnyFunSuite { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala index cc16270f77..5c49bae4f7 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala @@ -18,7 +18,6 @@ package fr.acinq.eclair.channel import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.bitcoin.{ByteVector64, DeterministicWallet, Satoshi, SatoshiLong, Transaction} -import fr.acinq.eclair.TestConstants.TestFeeEstimator import fr.acinq.eclair.blockchain.fee._ import fr.acinq.eclair.channel.Commitments._ import fr.acinq.eclair.channel.Helpers.Funding diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala index 3b195f1678..173a2fe707 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala @@ -22,7 +22,6 @@ import akka.pattern.pipe import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.{BtcAmount, ByteVector32, MilliBtcDouble, SatoshiLong, Transaction} import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator -import fr.acinq.eclair.TestConstants.TestFeeEstimator import fr.acinq.eclair.blockchain.CurrentBlockCount import fr.acinq.eclair.blockchain.bitcoind.BitcoindService import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ @@ -36,7 +35,7 @@ import fr.acinq.eclair.channel.publish.TxPublisher._ import fr.acinq.eclair.channel.states.{ChannelStateTestsHelperMethods, ChannelStateTestsTags} import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.{BlockHeight, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomKey} +import fr.acinq.eclair.{BlockHeight, MilliSatoshiLong, TestConstants, TestFeeEstimator, TestKitBaseClass, randomKey} import org.scalatest.BeforeAndAfterAll import org.scalatest.funsuite.AnyFunSuiteLike @@ -107,7 +106,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } // NB: we can't use ScalaTest's fixtures, they would see uninitialized bitcoind fields because they sandbox each test. - private def withFixture(utxos: Seq[BtcAmount], channelType: SupportedChannelType, testFun: Fixture => Any): Unit = { + private def withFixture(utxos: Seq[BtcAmount], channelType: SupportedChannelType)(testFun: Fixture => Any): Unit = { // Create a unique wallet for this test and ensure it has some btc. val testId = UUID.randomUUID() val walletRpcClient = createWallet(s"lightning-$testId") @@ -169,7 +168,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } test("commit tx feerate high enough, not spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val commitFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate @@ -180,11 +179,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) assert(result.reason === TxSkipped(retryNextBlock = true)) - }) + } } test("commit tx confirmed, not spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 12) @@ -197,11 +196,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) assert(result.reason === TxSkipped(retryNextBlock = false)) - }) + } } test("commit tx feerate high enough and commit tx confirmed, not spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val commitFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate @@ -215,11 +214,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) assert(result.reason === TxSkipped(retryNextBlock = false)) - }) + } } test("remote commit tx confirmed, not spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val remoteCommit = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.fullySignedLocalCommitTx(bob.underlyingActor.nodeParams.channelKeyManager) @@ -233,11 +232,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) assert(result.reason === TxSkipped(retryNextBlock = false)) - }) + } } test("remote commit tx published, not spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val remoteCommit = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.fullySignedLocalCommitTx(bob.underlyingActor.nodeParams.channelKeyManager) @@ -252,11 +251,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // When the remote commit tx is still unconfirmed, we want to retry in case it is evicted from the mempool and our // commit is then published. assert(result.reason === TxSkipped(retryNextBlock = true)) - }) + } } test("remote commit tx replaces local commit tx, not spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val remoteCommit = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.fullySignedLocalCommitTx(bob.underlyingActor.nodeParams.channelKeyManager) @@ -281,11 +280,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) assert(result.reason === WalletInputGone) - }) + } } test("not enough funds to increase commit tx feerate") { - withFixture(Seq(10.5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(10.5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ // close channel and wait for the commit tx to be published, anchor will not be published because we don't have enough funds @@ -300,11 +299,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // When the remote commit tx is still unconfirmed, we want to retry in case it is evicted from the mempool and our // commit is then published. assert(result.reason === CouldNotFund) - }) + } } test("commit tx feerate too low, spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) @@ -330,11 +329,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(result.cmd === anchorTx) assert(result.tx.txIn.map(_.outPoint.txid).contains(commitTx.tx.txid)) assert(mempoolTxs.map(_.txid).contains(result.tx.txid)) - }) + } } test("commit tx not published, publishing it and spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 32) @@ -358,7 +357,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(result.cmd === anchorTx) assert(result.tx.txIn.map(_.outPoint.txid).contains(commitTx.tx.txid)) assert(mempoolTxs.map(_.txid).contains(result.tx.txid)) - }) + } } test("commit tx feerate too low, spending anchor outputs with multiple wallet inputs") { @@ -370,7 +369,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w 22000 sat, 15000 sat ) - withFixture(utxos, ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(utxos, ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ // NB: we try to get transactions confirmed *before* their confirmation target, so we aim for a more aggressive block target than what's provided. @@ -394,11 +393,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(result.tx.txIn.map(_.outPoint.txid).contains(commitTx.tx.txid)) assert(result.tx.txIn.length > 2) // we added more than 1 wallet input assert(mempoolTxs.map(_.txid).contains(result.tx.txid)) - }) + } } test("commit tx fees not increased when confirmation target is far and feerate hasn't changed") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) @@ -417,11 +416,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w probe.expectNoMessage(500 millis) val mempoolTxs2 = getMempool() assert(mempoolTxs.map(_.txid).toSet === mempoolTxs2.map(_.txid).toSet) - }) + } } test("commit tx not confirming, lowering anchor output amount") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) @@ -448,11 +447,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val targetFee = Transactions.weight2fee(newFeerate, mempoolTxs2.map(_.weight).sum.toInt) val actualFee = mempoolTxs2.map(_.fees).sum assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee") - }) + } } test("commit tx not confirming, adding other wallet inputs") { - withFixture(Seq(10.5 millibtc, 5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(10.5 millibtc, 5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) @@ -480,11 +479,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val targetFee = Transactions.weight2fee(targetFeerate, mempoolTxs2.map(_.weight).sum.toInt) val actualFee = mempoolTxs2.map(_.fees).sum assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee") - }) + } } test("commit tx not confirming, not enough funds to increase fees") { - withFixture(Seq(10.2 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(10.2 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) @@ -506,11 +505,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w probe.expectMsgType[NotifyNodeOperator] val mempoolTxs2 = getMempool() assert(mempoolTxs1.map(_.txid).toSet === mempoolTxs2.map(_.txid).toSet) - }) + } } test("commit tx not confirming, cannot use new unconfirmed inputs to increase fees") { - withFixture(Seq(10.2 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(10.2 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) @@ -537,11 +536,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w probe.expectMsgType[NotifyNodeOperator] val mempoolTxs2 = getMempool() assert(mempoolTxs1.map(_.txid).toSet + walletTx.txid === mempoolTxs2.map(_.txid).toSet) - }) + } } test("commit tx not confirming, updating confirmation target") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) @@ -573,11 +572,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val targetFee2 = Transactions.weight2fee(feerateHigh, mempoolTxs2.map(_.weight).sum.toInt) val actualFee2 = mempoolTxs2.map(_.fees).sum assert(targetFee2 * 0.9 <= actualFee2 && actualFee2 <= targetFee2 * 1.1, s"actualFee=$actualFee2 targetFee=$targetFee2") - }) + } } test("unlock utxos when anchor tx cannot be published") { - withFixture(Seq(500 millibtc, 200 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc, 200 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val targetFeerate = FeeratePerKw(3000 sat) @@ -605,11 +604,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(5) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) assert(probe.expectMsgType[TxConfirmed].cmd === anchorTx) - }) + } } test("unlock anchor utxos when stopped before completion") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val targetFeerate = FeeratePerKw(3000 sat) @@ -624,11 +623,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // we unlock utxos before stopping publisher ! Stop awaitCond(getLocks(probe, walletRpcClient).isEmpty) - }) + } } test("remote commit tx confirmed, not publishing htlc tx") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ // Add htlcs in both directions and ensure that preimages are available. @@ -671,7 +670,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(result2.cmd === htlcTimeout) assert(result2.reason === ConflictingTxConfirmed) htlcTimeoutPublisher ! Stop - }) + } } def closeChannelWithHtlcs(f: Fixture, confirmHtlcBefore: BlockHeight): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { @@ -717,7 +716,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } test("not enough funds to increase htlc tx feerate") { - withFixture(Seq(10.5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(10.5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f, aliceBlockHeight()) @@ -731,7 +730,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(result.cmd === htlcSuccess) assert(result.reason === CouldNotFund) htlcSuccessPublisher ! Stop - }) + } } private def testPublishHtlcSuccess(f: Fixture, commitTx: Transaction, htlcSuccess: PublishReplaceableTx, targetFeerate: FeeratePerKw): Transaction = { @@ -784,7 +783,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } test("htlc tx feerate high enough, not adding wallet inputs") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputs, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputs) { f => import f._ val currentFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate @@ -796,11 +795,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, currentFeerate) assert(htlcTimeout.txInfo.fee > 0.sat) assert(htlcTimeoutTx.txIn.length === 1) - }) + } } test("htlc tx feerate too low, adding wallet inputs") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputs, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputs) { f => import f._ val targetFeerate = FeeratePerKw(15_000 sat) @@ -811,11 +810,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(htlcSuccessTx.txIn.length > 1) val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, targetFeerate) assert(htlcTimeoutTx.txIn.length > 1) - }) + } } test("htlc tx feerate zero, adding wallet inputs") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val targetFeerate = FeeratePerKw(15_000 sat) @@ -828,11 +827,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(htlcTimeout.txInfo.fee === 0.sat) val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, targetFeerate) assert(htlcTimeoutTx.txIn.length > 1) - }) + } } test("htlc tx feerate zero, high commit feerate, adding wallet inputs") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val commitFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate @@ -845,7 +844,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(htlcTimeout.txInfo.fee === 0.sat) val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, targetFeerate) assert(htlcTimeoutTx.txIn.length > 1) - }) + } } test("htlc tx feerate too low, adding multiple wallet inputs") { @@ -864,7 +863,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w 5200 sat, 5100 sat ) - withFixture(utxos, ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(utxos, ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val targetFeerate = FeeratePerKw(8_000 sat) @@ -875,11 +874,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(htlcSuccessTx.txIn.length > 2) val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, targetFeerate) assert(htlcTimeoutTx.txIn.length > 2) - }) + } } test("htlc success tx not confirming, lowering output amount") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val initialFeerate = FeeratePerKw(15_000 sat) @@ -904,11 +903,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(htlcSuccessInputs1 === htlcSuccessInputs2) val htlcSuccessTargetFee = Transactions.weight2fee(targetFeerate, htlcSuccessTx2.weight.toInt) assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx2.fees && htlcSuccessTx2.fees <= htlcSuccessTargetFee * 1.4, s"actualFee=${htlcSuccessTx2.fees} targetFee=$htlcSuccessTargetFee") - }) + } } test("htlc success tx not confirming, adding other wallet inputs") { - withFixture(Seq(10.2 millibtc, 2 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(10.2 millibtc, 2 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val initialFeerate = FeeratePerKw(15_000 sat) @@ -933,11 +932,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(htlcSuccessInputs1 !== htlcSuccessInputs2) val htlcSuccessTargetFee = Transactions.weight2fee(targetFeerate, htlcSuccessTx2.weight.toInt) assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx2.fees && htlcSuccessTx2.fees <= htlcSuccessTargetFee * 1.4, s"actualFee=${htlcSuccessTx2.fees} targetFee=$htlcSuccessTargetFee") - }) + } } test("htlc success tx confirmation target reached, increasing fees") { - withFixture(Seq(50 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(50 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val initialFeerate = FeeratePerKw(10_000 sat) @@ -958,11 +957,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(htlcSuccessTx.fees < bumpedHtlcSuccessTx.fees) htlcSuccessTx = bumpedHtlcSuccessTx }) - }) + } } test("htlc timeout tx not confirming, increasing fees") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val feerate = FeeratePerKw(15_000 sat) @@ -989,11 +988,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // Once the confirmation target is reach, we should raise the feerate by at least 20% at every block. val htlcTimeoutTargetFee = Transactions.weight2fee(feerate * 1.2, htlcTimeoutTx2.weight.toInt) assert(htlcTimeoutTargetFee * 0.9 <= htlcTimeoutTx2.fees && htlcTimeoutTx2.fees <= htlcTimeoutTargetFee * 1.1, s"actualFee=${htlcTimeoutTx2.fees} targetFee=$htlcTimeoutTargetFee") - }) + } } test("unlock utxos when htlc tx cannot be published") { - withFixture(Seq(500 millibtc, 200 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc, 200 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val targetFeerate = FeeratePerKw(5_000 sat) @@ -1024,11 +1023,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) assert(probe.expectMsgType[TxConfirmed].cmd === htlcSuccess) publisher1 ! Stop - }) + } } test("unlock htlc utxos when stopped before completion") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ setFeerate(FeeratePerKw(5_000 sat)) @@ -1041,11 +1040,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // We unlock utxos before stopping. publisher ! Stop awaitCond(getLocks(probe, walletRpcClient).isEmpty) - }) + } } test("local commit tx confirmed, not publishing claim htlc tx") { - withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ // Add htlcs in both directions and ensure that preimages are available. @@ -1087,7 +1086,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(result2.cmd === claimHtlcTimeout) assert(result2.reason === ConflictingTxConfirmed) claimHtlcTimeoutPublisher ! Stop - }) + } } def remoteCloseChannelWithHtlcs(f: Fixture, confirmHtlcBefore: BlockHeight): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { @@ -1182,7 +1181,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } test("claim htlc tx feerate high enough, not changing output amount") { - withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val currentFeerate = alice.underlyingActor.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(2) @@ -1193,11 +1192,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val claimHtlcTimeoutTx = testPublishClaimHtlcTimeout(f, remoteCommitTx, claimHtlcTimeout, currentFeerate) assert(claimHtlcTimeout.txInfo.fee > 0.sat) assert(claimHtlcTimeoutTx.txIn.length === 1) - }) + } } test("claim htlc tx feerate too low, lowering output amount") { - withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputs, f => { + withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputs) { f => import f._ val targetFeerate = FeeratePerKw(15_000 sat) @@ -1212,11 +1211,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(claimHtlcTimeoutTx.txIn.length === 1) assert(claimHtlcTimeoutTx.txOut.length === 1) assert(claimHtlcTimeoutTx.txOut.head.amount < claimHtlcTimeout.txInfo.tx.txOut.head.amount) - }) + } } test("claim htlc tx feerate too low, lowering output amount (standard commitment format)") { - withFixture(Seq(11 millibtc), ChannelTypes.Standard, f => { + withFixture(Seq(11 millibtc), ChannelTypes.Standard) { f => import f._ val targetFeerate = FeeratePerKw(15_000 sat) @@ -1252,11 +1251,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(claimHtlcTimeoutResult.cmd === claimHtlcTimeout) assert(claimHtlcTimeoutResult.tx.txIn.map(_.outPoint.txid).contains(remoteCommitTx.txid)) claimHtlcTimeoutPublisher ! Stop - }) + } } test("claim htlc tx feerate way too low, skipping output") { - withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputs, f => { + withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputs) { f => import f._ val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 300) @@ -1281,11 +1280,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(result2.cmd === claimHtlcTimeout) assert(result2.reason === TxSkipped(retryNextBlock = true)) claimHtlcTimeoutPublisher ! Stop - }) + } } test("claim htlc tx not confirming, lowering output amount again (standard commitment format)") { - withFixture(Seq(11 millibtc), ChannelTypes.Standard, f => { + withFixture(Seq(11 millibtc), ChannelTypes.Standard) { f => import f._ val initialFeerate = FeeratePerKw(15_000 sat) @@ -1331,11 +1330,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(finalHtlcTimeoutTx.txIn.length === 1) assert(finalHtlcTimeoutTx.txOut.length === 1) assert(finalHtlcTimeoutTx.txIn.head.outPoint.txid === remoteCommitTx.txid) - }) + } } test("claim htlc tx not confirming, but cannot lower output amount again") { - withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputs, f => { + withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputs) { f => import f._ val (remoteCommitTx, claimHtlcSuccess, _) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 300) @@ -1353,7 +1352,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w probe.expectNoMessage(500 millis) val mempoolTxs = getMempool() assert(mempoolTxs.map(_.txid).toSet === Set(claimHtlcSuccessTx.txid)) - }) + } } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala index 67ededf4c9..b017bc84ee 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala @@ -22,7 +22,7 @@ import akka.testkit.{TestFSMRef, TestKitBase, TestProbe} import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.bitcoin.{ByteVector32, Crypto, SatoshiLong, ScriptFlags, Transaction} -import fr.acinq.eclair.TestConstants.{Alice, Bob, TestFeeEstimator} +import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.{FeeTargets, FeeratePerKw} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala index 3a4e87e6b6..40180d8ee0 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala @@ -20,7 +20,7 @@ import akka.actor.ActorRef import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.{ByteVector32, ScriptFlags, Transaction} -import fr.acinq.eclair.TestConstants.{Alice, Bob, TestFeeEstimator} +import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratesPerKw import fr.acinq.eclair.blockchain.{CurrentBlockCount, CurrentFeerates} @@ -29,7 +29,7 @@ import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx} import fr.acinq.eclair.channel.states.ChannelStateTestsBase import fr.acinq.eclair.transactions.Transactions.HtlcSuccessTx import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32} +import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestFeeEstimator, TestKitBaseClass, randomBytes32} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala index d8c9a3a603..9c4d508936 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala @@ -27,7 +27,7 @@ import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsT import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.wire.protocol.ClosingSignedTlv.FeeRange import fr.acinq.eclair.wire.protocol.{ClosingSigned, Error, Shutdown, TlvStream, Warning} -import fr.acinq.eclair.{CltvExpiry, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32} +import fr.acinq.eclair.{CltvExpiry, Features, MilliSatoshiLong, TestConstants, TestFeeEstimator, TestKitBaseClass, randomBytes32} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -89,7 +89,7 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike } } - def setFeerate(feeEstimator: TestConstants.TestFeeEstimator, feerate: FeeratePerKw, minFeerate: FeeratePerKw = FeeratePerKw(250 sat)): Unit = { + def setFeerate(feeEstimator: TestFeeEstimator, feerate: FeeratePerKw, minFeerate: FeeratePerKw = FeeratePerKw(250 sat)): Unit = { feeEstimator.setFeerate(FeeratesPerKw.single(feerate).copy(mempoolMinFee = minFeerate, blocks_1008 = minFeerate)) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/interop/rustytests/RustyTestsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/interop/rustytests/RustyTestsSpec.scala index 0865e8f048..6f969cc1e2 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/interop/rustytests/RustyTestsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/interop/rustytests/RustyTestsSpec.scala @@ -20,7 +20,7 @@ import akka.actor.typed.scaladsl.adapter.actorRefAdapter import akka.actor.{ActorRef, Props} import akka.testkit.{TestFSMRef, TestKit, TestProbe} import fr.acinq.bitcoin.{ByteVector32, SatoshiLong} -import fr.acinq.eclair.TestConstants.{Alice, Bob, TestFeeEstimator} +import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair.blockchain.DummyOnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} @@ -29,7 +29,7 @@ import fr.acinq.eclair.channel.publish.TxPublisher import fr.acinq.eclair.channel.states.ChannelStateTestsHelperMethods.FakeTxPublisherFactory import fr.acinq.eclair.payment.receive.{ForwardHandler, PaymentHandler} import fr.acinq.eclair.wire.protocol.Init -import fr.acinq.eclair.{MilliSatoshiLong, TestKitBaseClass, TestUtils} +import fr.acinq.eclair.{MilliSatoshiLong, TestFeeEstimator, TestKitBaseClass, TestUtils} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.matchers.should.Matchers import org.scalatest.{BeforeAndAfterAll, Outcome} @@ -63,7 +63,7 @@ class RustyTestsSpec extends TestKitBaseClass with Matchers with FixtureAnyFunSu // we just bypass the relayer for this test val relayer = paymentHandler val wallet = new DummyOnChainWallet() - val feeEstimator = new TestFeeEstimator + val feeEstimator = new TestFeeEstimator() val aliceNodeParams = Alice.nodeParams.copy(blockCount = blockCount, onChainFeeConf = Alice.nodeParams.onChainFeeConf.copy(feeEstimator = feeEstimator)) val bobNodeParams = Bob.nodeParams.copy(blockCount = blockCount, onChainFeeConf = Bob.nodeParams.onChainFeeConf.copy(feeEstimator = feeEstimator)) val channelConfig = ChannelConfig.standard From e389af61d928d9fa921ba90d400709b1d8ea4543 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 18 Jan 2022 14:40:53 +0100 Subject: [PATCH 20/23] Handle funding tx not found --- .../publish/ReplaceableTxPrePublisher.scala | 25 ++++++++++++++----- .../fr/acinq/eclair/TestFeeEstimator.scala | 20 +++++++-------- .../publish/ReplaceableTxPublisherSpec.scala | 20 +++++++++++++-- 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala index 651876f654..fa3f008af4 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala @@ -46,6 +46,7 @@ object ReplaceableTxPrePublisher { case class CheckPreconditions(replyTo: ActorRef[PreconditionsResult], cmd: TxPublisher.PublishReplaceableTx) extends Command private case object ParentTxOk extends Command + private case object FundingTxNotFound extends RuntimeException with Command private case object CommitTxAlreadyConfirmed extends RuntimeException with Command private case object LocalCommitTxConfirmed extends Command private case object RemoteCommitTxConfirmed extends Command @@ -130,14 +131,22 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, // - our commit tx is in the mempool (otherwise we can't claim our anchor) val commitTx = cmd.commitments.fullySignedLocalCommitTx(nodeParams.channelKeyManager).tx val fundingOutpoint = cmd.commitments.commitInput.outPoint - context.pipeToSelf(bitcoinClient.isTransactionOutputSpendable(fundingOutpoint.txid, fundingOutpoint.index.toInt, includeMempool = false).flatMap { - case false => Future.failed(CommitTxAlreadyConfirmed) - case true => - // We must ensure our local commit tx is in the mempool before publishing the anchor transaction. - // If it's already published, this call will be a no-op. - bitcoinClient.publishTransaction(commitTx) + context.pipeToSelf(bitcoinClient.getTxConfirmations(fundingOutpoint.txid).flatMap { + case Some(_) => + // The funding transaction was found, let's see if we can still spend it. + bitcoinClient.isTransactionOutputSpendable(fundingOutpoint.txid, fundingOutpoint.index.toInt, includeMempool = false).flatMap { + case false => Future.failed(CommitTxAlreadyConfirmed) + case true => + // We must ensure our local commit tx is in the mempool before publishing the anchor transaction. + // If it's already published, this call will be a no-op. + bitcoinClient.publishTransaction(commitTx) + } + case None => + // If the funding transaction cannot be found (e.g. when using 0-conf), we should retry later. + Future.failed(FundingTxNotFound) }) { case Success(_) => ParentTxOk + case Failure(FundingTxNotFound) => FundingTxNotFound case Failure(CommitTxAlreadyConfirmed) => CommitTxAlreadyConfirmed case Failure(reason) if reason.getMessage.contains("rejecting replacement") => RemoteCommitTxPublished case Failure(reason) => UnknownFailure(reason) @@ -146,6 +155,10 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, case ParentTxOk => replyTo ! PreconditionsOk(ClaimLocalAnchorWithWitnessData(localAnchorTx)) Behaviors.stopped + case FundingTxNotFound => + log.debug("funding tx could not be found, we don't know yet if we need to claim our anchor") + replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) + Behaviors.stopped case CommitTxAlreadyConfirmed => log.debug("commit tx is already confirmed, no need to claim our anchor") replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestFeeEstimator.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestFeeEstimator.scala index d72046a1d4..ac33b31650 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestFeeEstimator.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestFeeEstimator.scala @@ -16,7 +16,6 @@ package fr.acinq.eclair -import com.softwaremill.quicklens.ModifyPimp import fr.acinq.eclair.TestConstants.feeratePerKw import fr.acinq.eclair.blockchain.fee.{FeeEstimator, FeeratePerKB, FeeratePerKw, FeeratesPerKw} @@ -30,15 +29,16 @@ class TestFeeEstimator extends FeeEstimator { // @formatter:on def setFeerate(target: Int, feerate: FeeratePerKw): Unit = { - currentFeerates = currentFeerates - .modify(_.block_1).setToIf(target == 1)(feerate) - .modify(_.blocks_2).setToIf(target == 2)(feerate) - .modify(_.blocks_6).setToIf(target <= 6)(feerate) - .modify(_.blocks_12).setToIf(target <= 12)(feerate) - .modify(_.blocks_36).setToIf(target <= 36)(feerate) - .modify(_.blocks_72).setToIf(target <= 72)(feerate) - .modify(_.blocks_144).setToIf(target <= 144)(feerate) - .modify(_.blocks_1008).setToIf(target > 144)(feerate) + target match { + case 1 => currentFeerates = currentFeerates.copy(block_1 = feerate) + case 2 => currentFeerates = currentFeerates.copy(blocks_2 = feerate) + case t if t <= 6 => currentFeerates = currentFeerates.copy(blocks_6 = feerate) + case t if t <= 12 => currentFeerates = currentFeerates.copy(blocks_12 = feerate) + case t if t <= 36 => currentFeerates = currentFeerates.copy(blocks_36 = feerate) + case t if t <= 72 => currentFeerates = currentFeerates.copy(blocks_72 = feerate) + case t if t <= 144 => currentFeerates = currentFeerates.copy(blocks_144 = feerate) + case _ => currentFeerates = currentFeerates.copy(blocks_1008 = feerate) + } } def setFeerate(feeratesPerKw: FeeratesPerKw): Unit = { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala index 173a2fe707..df430fe963 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala @@ -20,7 +20,7 @@ import akka.actor.typed.ActorRef import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, actorRefAdapter} import akka.pattern.pipe import akka.testkit.{TestFSMRef, TestProbe} -import fr.acinq.bitcoin.{BtcAmount, ByteVector32, MilliBtcDouble, SatoshiLong, Transaction} +import fr.acinq.bitcoin.{BtcAmount, ByteVector32, MilliBtcDouble, OutPoint, SatoshiLong, Transaction, TxOut} import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator import fr.acinq.eclair.blockchain.CurrentBlockCount import fr.acinq.eclair.blockchain.bitcoind.BitcoindService @@ -35,7 +35,7 @@ import fr.acinq.eclair.channel.publish.TxPublisher._ import fr.acinq.eclair.channel.states.{ChannelStateTestsHelperMethods, ChannelStateTestsTags} import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.{BlockHeight, MilliSatoshiLong, TestConstants, TestFeeEstimator, TestKitBaseClass, randomKey} +import fr.acinq.eclair.{BlockHeight, MilliSatoshiLong, TestConstants, TestFeeEstimator, TestKitBaseClass, randomBytes32, randomKey} import org.scalatest.BeforeAndAfterAll import org.scalatest.funsuite.AnyFunSuiteLike @@ -283,6 +283,22 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } } + test("funding tx not found, skipping anchor output") { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => + import f._ + + val (_, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 12) + // We simulate an unconfirmed funding transaction that cannot be found in the mempool either. + // This may happen when using 0-conf channels if the funding transaction is evicted from the mempool for some reason. + val cmd = anchorTx.copy(commitments = anchorTx.commitments.copy(commitInput = InputInfo(OutPoint(randomBytes32(), 1), TxOut(0 sat, Nil), Nil))) + publisher ! Publish(probe.ref, cmd) + val result = probe.expectMsgType[TxRejected] + assert(result.cmd === cmd) + // We should keep retrying until the funding transaction is available. + assert(result.reason === TxSkipped(retryNextBlock = true)) + } + } + test("not enough funds to increase commit tx feerate") { withFixture(Seq(10.5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ From d76ae31e988df8bb1fec00413fcf773540f07080 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 18 Jan 2022 15:09:45 +0100 Subject: [PATCH 21/23] Test the PublishAttempts case class --- .../eclair/channel/publish/TxPublisher.scala | 46 +++++++++---------- .../channel/publish/TxPublisherSpec.scala | 42 +++++++++++++++++ 2 files changed, 65 insertions(+), 23 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala index 4e86c6344a..aa28dec5d6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala @@ -150,31 +150,13 @@ object TxPublisher { // @formatter:on } - def apply(nodeParams: NodeParams, remoteNodeId: PublicKey, factory: ChildFactory): Behavior[Command] = - Behaviors.setup { context => - Behaviors.withTimers { timers => - Behaviors.withMdc(Logs.mdc(remoteNodeId_opt = Some(remoteNodeId))) { - context.system.eventStream ! EventStream.Subscribe(context.messageAdapter[CurrentBlockCount](cbc => WrappedCurrentBlockCount(cbc.blockCount))) - new TxPublisher(nodeParams, factory, context, timers).run(Map.empty, Seq.empty, ChannelLogContext(remoteNodeId, None)) - } - } - } - -} - -private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFactory, context: ActorContext[TxPublisher.Command], timers: TimerScheduler[TxPublisher.Command]) { - - import TxPublisher._ - - private val log = context.log - // @formatter:off - private sealed trait PublishAttempt { + sealed trait PublishAttempt { def id: UUID def cmd: PublishTx } - private case class FinalAttempt(id: UUID, cmd: PublishFinalTx, actor: ActorRef[FinalTxPublisher.Command]) extends PublishAttempt - private case class ReplaceableAttempt(id: UUID, cmd: PublishReplaceableTx, confirmBefore: BlockHeight, actor: ActorRef[ReplaceableTxPublisher.Command]) extends PublishAttempt + case class FinalAttempt(id: UUID, cmd: PublishFinalTx, actor: ActorRef[FinalTxPublisher.Command]) extends PublishAttempt + case class ReplaceableAttempt(id: UUID, cmd: PublishReplaceableTx, confirmBefore: BlockHeight, actor: ActorRef[ReplaceableTxPublisher.Command]) extends PublishAttempt // @formatter:on /** @@ -182,7 +164,7 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact * Only one of them will work, but we keep track of all of them. * There is only one [[ReplaceableAttempt]] because we will replace the existing attempt instead of creating a new one. */ - private case class PublishAttempts(finalAttempts: Seq[FinalAttempt], replaceableAttempt_opt: Option[ReplaceableAttempt]) { + case class PublishAttempts(finalAttempts: Seq[FinalAttempt], replaceableAttempt_opt: Option[ReplaceableAttempt]) { val attempts: Seq[PublishAttempt] = finalAttempts ++ replaceableAttempt_opt.toSeq val count: Int = attempts.length @@ -199,10 +181,28 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact def isEmpty: Boolean = replaceableAttempt_opt.isEmpty && finalAttempts.isEmpty } - private object PublishAttempts { + object PublishAttempts { val empty: PublishAttempts = PublishAttempts(Nil, None) } + def apply(nodeParams: NodeParams, remoteNodeId: PublicKey, factory: ChildFactory): Behavior[Command] = + Behaviors.setup { context => + Behaviors.withTimers { timers => + Behaviors.withMdc(Logs.mdc(remoteNodeId_opt = Some(remoteNodeId))) { + context.system.eventStream ! EventStream.Subscribe(context.messageAdapter[CurrentBlockCount](cbc => WrappedCurrentBlockCount(cbc.blockCount))) + new TxPublisher(nodeParams, factory, context, timers).run(Map.empty, Seq.empty, ChannelLogContext(remoteNodeId, None)) + } + } + } + +} + +private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFactory, context: ActorContext[TxPublisher.Command], timers: TimerScheduler[TxPublisher.Command]) { + + import TxPublisher._ + + private val log = context.log + private def run(pending: Map[OutPoint, PublishAttempts], retryNextBlock: Seq[PublishTx], channelInfo: ChannelLogContext): Behavior[Command] = { Behaviors.receiveMessage { case cmd: PublishFinalTx => diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala index ad37b65cb4..9940960867 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala @@ -314,4 +314,46 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { factory.expectNoMessage(100 millis) } + test("update publishing attempts") { _ => + { + // No attempts. + val attempts = PublishAttempts.empty + assert(attempts.isEmpty) + assert(attempts.count === 0) + assert(attempts.attempts.isEmpty) + assert(attempts.remove(UUID.randomUUID()) === (Nil, attempts)) + } + { + // Only final attempts. + val attempt1 = FinalAttempt(UUID.randomUUID(), null, null) + val attempt2 = FinalAttempt(UUID.randomUUID(), null, null) + val attempts = PublishAttempts.empty.add(attempt1).add(attempt2) + assert(!attempts.isEmpty) + assert(attempts.count === 2) + assert(attempts.replaceableAttempt_opt.isEmpty) + assert(attempts.remove(UUID.randomUUID()) === (Nil, attempts)) + assert(attempts.remove(attempt1.id) === (Seq(attempt1), PublishAttempts(Seq(attempt2), None))) + } + { + // Only replaceable attempts. + val attempt = ReplaceableAttempt(UUID.randomUUID(), null, BlockHeight(0), null) + val attempts = PublishAttempts(Nil, Some(attempt)) + assert(!attempts.isEmpty) + assert(attempts.count === 1) + assert(attempts.remove(UUID.randomUUID()) === (Nil, attempts)) + assert(attempts.remove(attempt.id) === (Seq(attempt), PublishAttempts.empty)) + } + { + // Mix of final and replaceable attempts with the same id. + val attempt1 = ReplaceableAttempt(UUID.randomUUID(), null, BlockHeight(0), null) + val attempt2 = FinalAttempt(attempt1.id, null, null) + val attempt3 = FinalAttempt(UUID.randomUUID(), null, null) + val attempts = PublishAttempts(Seq(attempt2), Some(attempt1)).add(attempt3) + assert(!attempts.isEmpty) + assert(attempts.count === 3) + assert(attempts.remove(attempt3.id) === (Seq(attempt3), PublishAttempts(Seq(attempt2), Some(attempt1)))) + assert(attempts.remove(attempt1.id) === (Seq(attempt2, attempt1), PublishAttempts(Seq(attempt3), None))) + } + } + } From 67e0d962f11ca024f2b1b84e9eecd7b74753b4b6 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 18 Jan 2022 18:19:48 +0100 Subject: [PATCH 22/23] Improve tests --- .../channel/publish/ReplaceableTxFunder.scala | 1 + .../publish/ReplaceableTxPublisher.scala | 1 + .../publish/ReplaceableTxPublisherSpec.scala | 29 ++++++++++--------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala index 338f333d6a..313c770fa4 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala @@ -306,6 +306,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, val nodeOperatorMessage = s"""Insufficient funds in bitcoin wallet to set feerate=$targetFeerate for ${cmd.desc}. |You should add more utxos to your bitcoin wallet to guarantee funds safety. + |Attempts will be made periodically to re-publish this transaction. |""".stripMargin context.system.eventStream ! EventStream.Publish(NotifyNodeOperator(NotificationsLogger.Warning, nodeOperatorMessage)) log.warn("cannot add inputs to {}: {}", cmd.desc, reason.getMessage) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index bd6238d423..61aad9d65a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -147,6 +147,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case WrappedFundingResult(result) => result match { case ReplaceableTxFunder.TransactionReady(tx) => + log.debug("publishing {} with confirmation target in {} blocks", cmd.desc, confirmBefore.toLong) val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), s"mempool-tx-monitor-${tx.signedTx.txid}") txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), tx.signedTx, cmd.input, cmd.desc, tx.fee) wait(tx) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala index df430fe963..a568efb7dd 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala @@ -142,14 +142,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // Execute our test. val publisher = system.spawn(ReplaceableTxPublisher(aliceNodeParams, walletClient, alice2blockchain.ref, TxPublishLogContext(testId, TestConstants.Bob.nodeParams.nodeId, None)), testId.toString) - try { - testFun(Fixture(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain, walletClient, walletRpcClient, publisher, probe)) - } finally { - publisher ! Stop - } + testFun(Fixture(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain, walletClient, walletRpcClient, publisher, probe)) } - def closeChannelWithoutHtlcs(f: Fixture, confirmCommitBefore: BlockHeight): (PublishFinalTx, PublishReplaceableTx) = { + def closeChannelWithoutHtlcs(f: Fixture, overrideCommitTarget: BlockHeight): (PublishFinalTx, PublishReplaceableTx) = { import f._ val commitTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.fullySignedLocalCommitTx(alice.underlyingActor.nodeParams.channelKeyManager) @@ -162,7 +158,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val publishAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(publishAnchor.txInfo.input.outPoint.txid === commitTx.tx.txid) assert(publishAnchor.txInfo.isInstanceOf[ClaimLocalAnchorOutputTx]) - val anchorTx = publishAnchor.txInfo.asInstanceOf[ClaimLocalAnchorOutputTx].copy(confirmBefore = confirmCommitBefore) + val anchorTx = publishAnchor.txInfo.asInstanceOf[ClaimLocalAnchorOutputTx].copy(confirmBefore = overrideCommitTarget) (publishCommitTx, publishAnchor.copy(txInfo = anchorTx)) } @@ -280,6 +276,13 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) assert(result.reason === WalletInputGone) + + // Since our wallet input is gone, we will retry and discover that a commit tx has been confirmed. + val publisher2 = createPublisher() + publisher2 ! Publish(probe.ref, anchorTx) + val result2 = probe.expectMsgType[TxRejected] + assert(result2.cmd === anchorTx) + assert(result2.reason === TxSkipped(retryNextBlock = false)) } } @@ -689,7 +692,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } } - def closeChannelWithHtlcs(f: Fixture, confirmHtlcBefore: BlockHeight): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { + def closeChannelWithHtlcs(f: Fixture, overrideHtlcTarget: BlockHeight): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { import f._ // Add htlcs in both directions and ensure that preimages are available. @@ -716,10 +719,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w alice2blockchain.expectMsgType[PublishFinalTx] // claim main output val htlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(htlcSuccess.txInfo.isInstanceOf[HtlcSuccessTx]) - val htlcSuccessTx = htlcSuccess.txInfo.asInstanceOf[HtlcSuccessTx].copy(confirmBefore = confirmHtlcBefore) + val htlcSuccessTx = htlcSuccess.txInfo.asInstanceOf[HtlcSuccessTx].copy(confirmBefore = overrideHtlcTarget) val htlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(htlcTimeout.txInfo.isInstanceOf[HtlcTimeoutTx]) - val htlcTimeoutTx = htlcTimeout.txInfo.asInstanceOf[HtlcTimeoutTx].copy(confirmBefore = confirmHtlcBefore) + val htlcTimeoutTx = htlcTimeout.txInfo.asInstanceOf[HtlcTimeoutTx].copy(confirmBefore = overrideHtlcTarget) alice2blockchain.expectMsgType[WatchTxConfirmed] // commit tx alice2blockchain.expectMsgType[WatchTxConfirmed] // claim main output @@ -1105,7 +1108,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } } - def remoteCloseChannelWithHtlcs(f: Fixture, confirmHtlcBefore: BlockHeight): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { + def remoteCloseChannelWithHtlcs(f: Fixture, overrideHtlcTarget: BlockHeight): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { import f._ // Add htlcs in both directions and ensure that preimages are available. @@ -1133,10 +1136,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w alice2blockchain.expectMsgType[PublishFinalTx] // claim main output val claimHtlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(claimHtlcTimeout.txInfo.isInstanceOf[ClaimHtlcTimeoutTx]) - val claimHtlcTimeoutTx = claimHtlcTimeout.txInfo.asInstanceOf[ClaimHtlcTimeoutTx].copy(confirmBefore = confirmHtlcBefore) + val claimHtlcTimeoutTx = claimHtlcTimeout.txInfo.asInstanceOf[ClaimHtlcTimeoutTx].copy(confirmBefore = overrideHtlcTarget) val claimHtlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(claimHtlcSuccess.txInfo.isInstanceOf[ClaimHtlcSuccessTx]) - val claimHtlcSuccessTx = claimHtlcSuccess.txInfo.asInstanceOf[ClaimHtlcSuccessTx].copy(confirmBefore = confirmHtlcBefore) + val claimHtlcSuccessTx = claimHtlcSuccess.txInfo.asInstanceOf[ClaimHtlcSuccessTx].copy(confirmBefore = overrideHtlcTarget) alice2blockchain.expectMsgType[WatchTxConfirmed] // commit tx alice2blockchain.expectMsgType[WatchTxConfirmed] // claim main output From 8b54837cfabbd2aeca29c9ac823689f06fa6730e Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 18 Jan 2022 18:32:40 +0100 Subject: [PATCH 23/23] fixup! Improve tests --- .../acinq/eclair/channel/publish/ReplaceableTxPublisher.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index 61aad9d65a..a2e7ff95a5 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -147,7 +147,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case WrappedFundingResult(result) => result match { case ReplaceableTxFunder.TransactionReady(tx) => - log.debug("publishing {} with confirmation target in {} blocks", cmd.desc, confirmBefore.toLong) + log.debug("publishing {} with confirmation target in {} blocks", cmd.desc, confirmBefore.toLong - nodeParams.currentBlockHeight) val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), s"mempool-tx-monitor-${tx.signedTx.txid}") txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), tx.signedTx, cmd.input, cmd.desc, tx.fee) wait(tx)