Skip to content

Add HTLC support to BOLT 3 commitment transaction - #181

Open
NishantBansal2003 wants to merge 11 commits into
lnfuzz:masterfrom
NishantBansal2003:htlc-commitment
Open

NishantBansal2003 wants to merge 11 commits into
lnfuzz:masterfrom
NishantBansal2003:htlc-commitment

Conversation

@NishantBansal2003

@NishantBansal2003 NishantBansal2003 commented Jul 30, 2026 •

Copy link
Copy Markdown
Member

ref: #111
Depends-on: #275

Bolts ref:

Verification

Anchor test vectors (local signatures): https://github.com/ACINQ/eclair/blob/master/eclair-core/src/test/resources/bolt3-tx-test-vectors-anchor-outputs-zero-fee-htlc-tx-format.txt

Custom tests on top of commit ACINQ/eclair@26d035070 (after this commit, eclair removed support for non-anchor channels), with the diff below:

Eclair diff
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala
index 1931aa8b1..1623fb59e 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala
@@ -456,6 +456,199 @@ class StaticRemoteKeyTestVectorSpec extends TestVectorsSpec {
   override def filename: String = "/bolt3-tx-test-vectors-static-remotekey-format.txt"
   override def channelFeatures: Set[ChannelTypeFeature] = Set(Features.StaticRemoteKey)
   // @formatter:on
+
+  test("commitment_tx_with_htlc_amount_msat_not_multiple_of_1000_legacy") {
+    val name = "commitment_tx_with_htlc_amount_msat_not_multiple_of_1000_legacy"
+    val htlc = OutgoingHtlc(UpdateAddHtlc(ByteVector32.Zeroes, 0, 2000999 msat, ByteVector32.Zeroes, CltvExpiry(500), TestConstants.emptyOnionPacket, None, Reputation.maxEndorsement, None))
+    val spec = CommitmentSpec(Set(htlc), FeeratePerKw(647 sat), toLocal = (7000000000L - 2000999L) msat, toRemote = 3000000000L msat)
+    val dustLimit = 546.sat
+
+    println()
+    println(s"name: $name")
+
+    val outputs = Transactions.makeCommitTxOutputs(
+      Local.funding_pubkey,
+      Remote.funding_pubkey,
+      localCommitmentKeys.publicKeys,
+      payCommitTxFees = true,
+      dustLimit,
+      Local.toSelfDelay,
+      spec,
+      commitmentFormat
+    )
+    val unsignedCommitTx = Transactions.makeCommitTx(
+      commitTxInput = commitmentInput,
+      commitTxNumber = Local.commitTxNumber,
+      localPaymentBasePoint = Local.payment_basepoint,
+      remotePaymentBasePoint = Remote.payment_basepoint,
+      localIsChannelOpener = true,
+      outputs = outputs)
+    val local_sig = unsignedCommitTx.sign(Local.funding_privkey, Remote.funding_pubkey)
+    val remote_sig = unsignedCommitTx.sign(Remote.funding_privkey, Local.funding_pubkey)
+    println(s"local_signature: ${Scripts.der(local_sig.sig).dropRight(1).toHex}")
+    println(s"remote_signature: ${Scripts.der(remote_sig.sig).dropRight(1).toHex}")
+    val commitTx = unsignedCommitTx.aggregateSigs(Local.funding_pubkey, Remote.funding_pubkey, local_sig, remote_sig)
+    Transaction.correctlySpends(commitTx, Seq(fundingTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
+
+    val htlcTxs = Transactions.makeHtlcTxs(commitTx, outputs, commitmentFormat).sortBy(_.input.outPoint.index)
+    println(s"num_htlcs: ${htlcTxs.length}")
+    htlcTxs.foreach {
+      case tx: UnsignedHtlcTimeoutTx =>
+        val remoteHtlcSig = tx.localSig(remoteCommitmentKeys)
+        val withRemoteSig = tx.addRemoteSig(localCommitmentKeys, remoteHtlcSig)
+        val localHtlcSig = withRemoteSig.localSig(WalletInputs(Nil, None))
+        println(s"# signature for output #${tx.input.outPoint.index} (htlc-timeout for htlc #${tx.htlcId})")
+        println(s"local_htlc_signature: ${Scripts.der(localHtlcSig).dropRight(1).toHex}")
+        println(s"remote_htlc_signature: ${Scripts.der(remoteHtlcSig).dropRight(1).toHex}")
+      case tx: UnsignedHtlcSuccessTx =>
+        fail(s"unexpected htlc-success tx for htlc #${tx.htlcId}")
+    }
+  }
+
+  test("commitment_tx_with_htlc_value_equals_non_htlc_value_legacy") {
+    val name = "commitment_tx_with_htlc_value_equals_non_htlc_value_legacy"
+    val htlc = OutgoingHtlc(UpdateAddHtlc(ByteVector32.Zeroes, 0, 2000000000 msat, ByteVector32.Zeroes, CltvExpiry(500), TestConstants.emptyOnionPacket, None, Reputation.maxEndorsement, None))
+    val spec = CommitmentSpec(Set(htlc), FeeratePerKw(15_000 sat), toLocal = (8000000000L - 2000000000L) msat, toRemote = 2000000000L msat)
+    val dustLimit = 546.sat
+
+    println()
+    println(s"name: $name")
+
+    val outputs = Transactions.makeCommitTxOutputs(
+      Local.funding_pubkey,
+      Remote.funding_pubkey,
+      localCommitmentKeys.publicKeys,
+      payCommitTxFees = true,
+      dustLimit,
+      Local.toSelfDelay,
+      spec,
+      commitmentFormat
+    )
+    val unsignedCommitTx = Transactions.makeCommitTx(
+      commitTxInput = commitmentInput,
+      commitTxNumber = Local.commitTxNumber,
+      localPaymentBasePoint = Local.payment_basepoint,
+      remotePaymentBasePoint = Remote.payment_basepoint,
+      localIsChannelOpener = true,
+      outputs = outputs)
+    val local_sig = unsignedCommitTx.sign(Local.funding_privkey, Remote.funding_pubkey)
+    val remote_sig = unsignedCommitTx.sign(Remote.funding_privkey, Local.funding_pubkey)
+    println(s"local_signature: ${Scripts.der(local_sig.sig).dropRight(1).toHex}")
+    println(s"remote_signature: ${Scripts.der(remote_sig.sig).dropRight(1).toHex}")
+    val commitTx = unsignedCommitTx.aggregateSigs(Local.funding_pubkey, Remote.funding_pubkey, local_sig, remote_sig)
+    Transaction.correctlySpends(commitTx, Seq(fundingTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
+
+    val htlcTxs = Transactions.makeHtlcTxs(commitTx, outputs, commitmentFormat).sortBy(_.input.outPoint.index)
+    println(s"num_htlcs: ${htlcTxs.length}")
+    htlcTxs.foreach {
+      case tx: UnsignedHtlcTimeoutTx =>
+        val remoteHtlcSig = tx.localSig(remoteCommitmentKeys)
+        val withRemoteSig = tx.addRemoteSig(localCommitmentKeys, remoteHtlcSig)
+        val localHtlcSig = withRemoteSig.localSig(WalletInputs(Nil, None))
+        println(s"# signature for output #${tx.input.outPoint.index} (htlc-timeout for htlc #${tx.htlcId})")
+        println(s"local_htlc_signature: ${Scripts.der(localHtlcSig).dropRight(1).toHex}")
+        println(s"remote_htlc_signature: ${Scripts.der(remoteHtlcSig).dropRight(1).toHex}")
+      case tx: UnsignedHtlcSuccessTx =>
+        fail(s"unexpected htlc-success tx for htlc #${tx.htlcId}")
+    }
+  }
+
+  test("commitment_tx_with_non_htlc_outputs_between_htlc_outputs_legacy") {
+    val name = "commitment_tx_with_non_htlc_outputs_between_htlc_outputs_legacy"
+    val htlc1 = OutgoingHtlc(UpdateAddHtlc(ByteVector32.Zeroes, 0, 2000000000L msat, ByteVector32.Zeroes, CltvExpiry(500), TestConstants.emptyOnionPacket, None, Reputation.maxEndorsement, None))
+    val htlc2 = OutgoingHtlc(UpdateAddHtlc(ByteVector32.Zeroes, 1, 4000000000L msat, ByteVector32.Zeroes, CltvExpiry(501), TestConstants.emptyOnionPacket, None, Reputation.maxEndorsement, None))
+    val spec = CommitmentSpec(Set[DirectedHtlc](htlc1, htlc2), FeeratePerKw(15000 sat), toLocal = (7000000000L - 2000000000L - 4000000000L) msat, toRemote = 3000000000L msat)
+    val dustLimit = 546.sat
+
+    println()
+    println(s"name: $name")
+
+    val outputs = Transactions.makeCommitTxOutputs(
+      Local.funding_pubkey,
+      Remote.funding_pubkey,
+      localCommitmentKeys.publicKeys,
+      payCommitTxFees = true,
+      dustLimit,
+      Local.toSelfDelay,
+      spec,
+      commitmentFormat
+    )
+    val unsignedCommitTx = Transactions.makeCommitTx(
+      commitTxInput = commitmentInput,
+      commitTxNumber = Local.commitTxNumber,
+      localPaymentBasePoint = Local.payment_basepoint,
+      remotePaymentBasePoint = Remote.payment_basepoint,
+      localIsChannelOpener = true,
+      outputs = outputs)
+    val local_sig = unsignedCommitTx.sign(Local.funding_privkey, Remote.funding_pubkey)
+    val remote_sig = unsignedCommitTx.sign(Remote.funding_privkey, Local.funding_pubkey)
+    println(s"local_signature: ${Scripts.der(local_sig.sig).dropRight(1).toHex}")
+    println(s"remote_signature: ${Scripts.der(remote_sig.sig).dropRight(1).toHex}")
+    val commitTx = unsignedCommitTx.aggregateSigs(Local.funding_pubkey, Remote.funding_pubkey, local_sig, remote_sig)
+    Transaction.correctlySpends(commitTx, Seq(fundingTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
+
+    val htlcTxs = Transactions.makeHtlcTxs(commitTx, outputs, commitmentFormat).sortBy(_.input.outPoint.index)
+    println(s"num_htlcs: ${htlcTxs.length}")
+    htlcTxs.foreach {
+      case tx: UnsignedHtlcTimeoutTx =>
+        val remoteHtlcSig = tx.localSig(remoteCommitmentKeys)
+        val withRemoteSig = tx.addRemoteSig(localCommitmentKeys, remoteHtlcSig)
+        val localHtlcSig = withRemoteSig.localSig(WalletInputs(Nil, None))
+        println(s"# signature for output #${tx.input.outPoint.index} (htlc-timeout for htlc #${tx.htlcId})")
+        println(s"local_htlc_signature: ${Scripts.der(localHtlcSig).dropRight(1).toHex}")
+        println(s"remote_htlc_signature: ${Scripts.der(remoteHtlcSig).dropRight(1).toHex}")
+      case tx: UnsignedHtlcSuccessTx =>
+        fail(s"unexpected htlc-success tx for htlc #${tx.htlcId}")
+    }
+  }
+
+  test("commitment_tx_with_asymmetric_dust_limits_legacy") {
+    val name = "commitment_tx_with_asymmetric_dust_limits_legacy"
+    val htlc = IncomingHtlc(UpdateAddHtlc(ByteVector32.Zeroes, 0, 20000000 msat, ByteVector32.Zeroes, CltvExpiry(500), TestConstants.emptyOnionPacket, None, Reputation.maxEndorsement, None))
+    val spec = CommitmentSpec(Set[DirectedHtlc](htlc), FeeratePerKw(15000 sat), toLocal = 7000000000L msat, toRemote = (3000000000L - 20000000L) msat)
+    val dustLimit = 546.sat
+
+    println()
+    println(s"name: $name")
+
+    val outputs = Transactions.makeCommitTxOutputs(
+      Local.funding_pubkey,
+      Remote.funding_pubkey,
+      localCommitmentKeys.publicKeys,
+      payCommitTxFees = true,
+      dustLimit,
+      Local.toSelfDelay,
+      spec,
+      commitmentFormat
+    )
+    val unsignedCommitTx = Transactions.makeCommitTx(
+      commitTxInput = commitmentInput,
+      commitTxNumber = Local.commitTxNumber,
+      localPaymentBasePoint = Local.payment_basepoint,
+      remotePaymentBasePoint = Remote.payment_basepoint,
+      localIsChannelOpener = true,
+      outputs = outputs)
+    val local_sig = unsignedCommitTx.sign(Local.funding_privkey, Remote.funding_pubkey)
+    val remote_sig = unsignedCommitTx.sign(Remote.funding_privkey, Local.funding_pubkey)
+    println(s"local_signature: ${Scripts.der(local_sig.sig).dropRight(1).toHex}")
+    println(s"remote_signature: ${Scripts.der(remote_sig.sig).dropRight(1).toHex}")
+    val commitTx = unsignedCommitTx.aggregateSigs(Local.funding_pubkey, Remote.funding_pubkey, local_sig, remote_sig)
+    Transaction.correctlySpends(commitTx, Seq(fundingTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
+
+    val htlcTxs = Transactions.makeHtlcTxs(commitTx, outputs, commitmentFormat).sortBy(_.input.outPoint.index)
+    println(s"num_htlcs: ${htlcTxs.length}")
+    htlcTxs.foreach {
+      case tx: UnsignedHtlcSuccessTx =>
+        val remoteHtlcSig = tx.localSig(remoteCommitmentKeys)
+        val redeemScript = Scripts.htlcReceived(localCommitmentKeys.publicKeys, tx.paymentHash, tx.htlcExpiry, commitmentFormat)
+        val localHtlcSig = Transaction.signInput(tx.tx, 0, redeemScript, fr.acinq.bitcoin.SigHash.SIGHASH_ALL, tx.input.txOut.amount, fr.acinq.bitcoin.SigVersion.SIGVERSION_WITNESS_V0, Local.htlc_privkey)
+        println(s"# signature for output #${tx.input.outPoint.index} (htlc-success for htlc #${tx.htlcId})")
+        println(s"local_htlc_signature: ${localHtlcSig.dropRight(1).toHex}")
+        println(s"remote_htlc_signature: ${Scripts.der(remoteHtlcSig).dropRight(1).toHex}")
+      case tx: UnsignedHtlcTimeoutTx =>
+        fail(s"unexpected htlc-timeout tx for htlc #${tx.htlcId}")
+    }
+  }
 }
 
 class AnchorOutputsTestVectorSpec extends TestVectorsSpec {
@@ -470,4 +663,245 @@ class AnchorOutputsZeroFeeHtlcTxTestVectorSpec extends TestVectorsSpec {
   override def filename: String = "/bolt3-tx-test-vectors-anchor-outputs-zero-fee-htlc-tx-format.txt"
   override def channelFeatures: Set[ChannelTypeFeature] = Set(Features.StaticRemoteKey, Features.AnchorOutputsZeroFeeHtlcTx)
   // @formatter:on
+
+  test("commitment_tx_with_htlc_amount_msat_not_multiple_of_1000_anchor") {
+    val name = "commitment_tx_with_htlc_amount_msat_not_multiple_of_1000_anchor"
+    val htlc = OutgoingHtlc(UpdateAddHtlc(ByteVector32.Zeroes, 0, 2000999 msat, ByteVector32.Zeroes, CltvExpiry(500), TestConstants.emptyOnionPacket, None, Reputation.maxEndorsement, None))
+    val spec = CommitmentSpec(Set(htlc), FeeratePerKw(647 sat), toLocal = (7000000000L - 2000999L) msat, toRemote = 3000000000L msat)
+    val dustLimit = 546.sat
+
+    println()
+    println(s"name: $name")
+
+    val outputs = Transactions.makeCommitTxOutputs(
+      Local.funding_pubkey,
+      Remote.funding_pubkey,
+      localCommitmentKeys.publicKeys,
+      payCommitTxFees = true,
+      dustLimit,
+      Local.toSelfDelay,
+      spec,
+      commitmentFormat
+    )
+    val unsignedCommitTx = Transactions.makeCommitTx(
+      commitTxInput = commitmentInput,
+      commitTxNumber = Local.commitTxNumber,
+      localPaymentBasePoint = Local.payment_basepoint,
+      remotePaymentBasePoint = Remote.payment_basepoint,
+      localIsChannelOpener = true,
+      outputs = outputs)
+    val local_sig = unsignedCommitTx.sign(Local.funding_privkey, Remote.funding_pubkey)
+    val remote_sig = unsignedCommitTx.sign(Remote.funding_privkey, Local.funding_pubkey)
+    println(s"local_signature: ${Scripts.der(local_sig.sig).dropRight(1).toHex}")
+    println(s"remote_signature: ${Scripts.der(remote_sig.sig).dropRight(1).toHex}")
+    val commitTx = unsignedCommitTx.aggregateSigs(Local.funding_pubkey, Remote.funding_pubkey, local_sig, remote_sig)
+    Transaction.correctlySpends(commitTx, Seq(fundingTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
+
+    val htlcTxs = Transactions.makeHtlcTxs(commitTx, outputs, commitmentFormat).sortBy(_.input.outPoint.index)
+    println(s"num_htlcs: ${htlcTxs.length}")
+    htlcTxs.foreach {
+      case tx: UnsignedHtlcTimeoutTx =>
+        val remoteHtlcSig = tx.localSig(remoteCommitmentKeys)
+        val withRemoteSig = tx.addRemoteSig(localCommitmentKeys, remoteHtlcSig)
+        val localHtlcSig = withRemoteSig.localSig(WalletInputs(Nil, None))
+        println(s"# signature for output #${tx.input.outPoint.index} (htlc-timeout for htlc #${tx.htlcId})")
+        println(s"local_htlc_signature: ${Scripts.der(localHtlcSig).dropRight(1).toHex}")
+        println(s"remote_htlc_signature: ${Scripts.der(remoteHtlcSig).dropRight(1).toHex}")
+      case tx: UnsignedHtlcSuccessTx =>
+        fail(s"unexpected htlc-success tx for htlc #${tx.htlcId}")
+    }
+  }
+
+  test("commitment_tx_with_htlc_value_equals_non_htlc_value_anchor") {
+    val name = "commitment_tx_with_htlc_value_equals_non_htlc_value_anchor"
+    val htlc = OutgoingHtlc(UpdateAddHtlc(ByteVector32.Zeroes, 0, 2000000000L msat, ByteVector32.Zeroes, CltvExpiry(500), TestConstants.emptyOnionPacket, None, Reputation.maxEndorsement, None))
+    val spec = CommitmentSpec(Set(htlc), FeeratePerKw(15000 sat), toLocal = (8000000000L - 2000000000L) msat, toRemote = 2000000000L msat)
+    val dustLimit = 546.sat
+
+    println()
+    println(s"name: $name")
+
+    val outputs = Transactions.makeCommitTxOutputs(
+      Local.funding_pubkey,
+      Remote.funding_pubkey,
+      localCommitmentKeys.publicKeys,
+      payCommitTxFees = true,
+      dustLimit,
+      Local.toSelfDelay,
+      spec,
+      commitmentFormat
+    )
+    val unsignedCommitTx = Transactions.makeCommitTx(
+      commitTxInput = commitmentInput,
+      commitTxNumber = Local.commitTxNumber,
+      localPaymentBasePoint = Local.payment_basepoint,
+      remotePaymentBasePoint = Remote.payment_basepoint,
+      localIsChannelOpener = true,
+      outputs = outputs)
+    val local_sig = unsignedCommitTx.sign(Local.funding_privkey, Remote.funding_pubkey)
+    val remote_sig = unsignedCommitTx.sign(Remote.funding_privkey, Local.funding_pubkey)
+    println(s"local_signature: ${Scripts.der(local_sig.sig).dropRight(1).toHex}")
+    println(s"remote_signature: ${Scripts.der(remote_sig.sig).dropRight(1).toHex}")
+    val commitTx = unsignedCommitTx.aggregateSigs(Local.funding_pubkey, Remote.funding_pubkey, local_sig, remote_sig)
+    Transaction.correctlySpends(commitTx, Seq(fundingTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
+
+    val htlcTxs = Transactions.makeHtlcTxs(commitTx, outputs, commitmentFormat).sortBy(_.input.outPoint.index)
+    println(s"num_htlcs: ${htlcTxs.length}")
+    htlcTxs.foreach {
+      case tx: UnsignedHtlcTimeoutTx =>
+        val remoteHtlcSig = tx.localSig(remoteCommitmentKeys)
+        val withRemoteSig = tx.addRemoteSig(localCommitmentKeys, remoteHtlcSig)
+        val localHtlcSig = withRemoteSig.localSig(WalletInputs(Nil, None))
+        println(s"# signature for output #${tx.input.outPoint.index} (htlc-timeout for htlc #${tx.htlcId})")
+        println(s"local_htlc_signature: ${Scripts.der(localHtlcSig).dropRight(1).toHex}")
+        println(s"remote_htlc_signature: ${Scripts.der(remoteHtlcSig).dropRight(1).toHex}")
+      case tx: UnsignedHtlcSuccessTx =>
+        fail(s"unexpected htlc-success tx for htlc #${tx.htlcId}")
+    }
+  }
+
+  test("commitment_tx_with_non_htlc_outputs_between_htlc_outputs_anchor") {
+    val name = "commitment_tx_with_non_htlc_outputs_between_htlc_outputs_anchor"
+    val htlc1 = OutgoingHtlc(UpdateAddHtlc(ByteVector32.Zeroes, 0, 2000000000L msat, ByteVector32.Zeroes, CltvExpiry(500), TestConstants.emptyOnionPacket, None, Reputation.maxEndorsement, None))
+    val htlc2 = OutgoingHtlc(UpdateAddHtlc(ByteVector32.Zeroes, 1, 4000000000L msat, ByteVector32.Zeroes, CltvExpiry(501), TestConstants.emptyOnionPacket, None, Reputation.maxEndorsement, None))
+    val spec = CommitmentSpec(Set[DirectedHtlc](htlc1, htlc2), FeeratePerKw(15000 sat), toLocal = (7000000000L - 2000000000L - 4000000000L) msat, toRemote = 3000000000L msat)
+    val dustLimit = 546.sat
+
+    println()
+    println(s"name: $name")
+
+    val outputs = Transactions.makeCommitTxOutputs(
+      Local.funding_pubkey,
+      Remote.funding_pubkey,
+      localCommitmentKeys.publicKeys,
+      payCommitTxFees = true,
+      dustLimit,
+      Local.toSelfDelay,
+      spec,
+      commitmentFormat
+    )
+    val unsignedCommitTx = Transactions.makeCommitTx(
+      commitTxInput = commitmentInput,
+      commitTxNumber = Local.commitTxNumber,
+      localPaymentBasePoint = Local.payment_basepoint,
+      remotePaymentBasePoint = Remote.payment_basepoint,
+      localIsChannelOpener = true,
+      outputs = outputs)
+    val local_sig = unsignedCommitTx.sign(Local.funding_privkey, Remote.funding_pubkey)
+    val remote_sig = unsignedCommitTx.sign(Remote.funding_privkey, Local.funding_pubkey)
+    println(s"local_signature: ${Scripts.der(local_sig.sig).dropRight(1).toHex}")
+    println(s"remote_signature: ${Scripts.der(remote_sig.sig).dropRight(1).toHex}")
+    val commitTx = unsignedCommitTx.aggregateSigs(Local.funding_pubkey, Remote.funding_pubkey, local_sig, remote_sig)
+    Transaction.correctlySpends(commitTx, Seq(fundingTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
+
+    val htlcTxs = Transactions.makeHtlcTxs(commitTx, outputs, commitmentFormat).sortBy(_.input.outPoint.index)
+    println(s"num_htlcs: ${htlcTxs.length}")
+    htlcTxs.foreach {
+      case tx: UnsignedHtlcTimeoutTx =>
+        val remoteHtlcSig = tx.localSig(remoteCommitmentKeys)
+        val withRemoteSig = tx.addRemoteSig(localCommitmentKeys, remoteHtlcSig)
+        val localHtlcSig = withRemoteSig.localSig(WalletInputs(Nil, None))
+        println(s"# signature for output #${tx.input.outPoint.index} (htlc-timeout for htlc #${tx.htlcId})")
+        println(s"local_htlc_signature: ${Scripts.der(localHtlcSig).dropRight(1).toHex}")
+        println(s"remote_htlc_signature: ${Scripts.der(remoteHtlcSig).dropRight(1).toHex}")
+      case tx: UnsignedHtlcSuccessTx =>
+        fail(s"unexpected htlc-success tx for htlc #${tx.htlcId}")
+    }
+  }
+
+  test("commitment_tx_with_asymmetric_dust_limits_anchor") {
+    val name = "commitment_tx_with_asymmetric_dust_limits_anchor"
+    val htlc = IncomingHtlc(UpdateAddHtlc(ByteVector32.Zeroes, 0, 15000000 msat, ByteVector32.Zeroes, CltvExpiry(500), TestConstants.emptyOnionPacket, None, Reputation.maxEndorsement, None))
+    val spec = CommitmentSpec(Set[DirectedHtlc](htlc), FeeratePerKw(15000 sat), toLocal = 7000000000L msat, toRemote = (3000000000L - 15000000L) msat)
+    val dustLimit = 546.sat
+
+    println()
+    println(s"name: $name")
+
+    val outputs = Transactions.makeCommitTxOutputs(
+      Local.funding_pubkey,
+      Remote.funding_pubkey,
+      localCommitmentKeys.publicKeys,
+      payCommitTxFees = true,
+      dustLimit,
+      Local.toSelfDelay,
+      spec,
+      commitmentFormat
+    )
+    val unsignedCommitTx = Transactions.makeCommitTx(
+      commitTxInput = commitmentInput,
+      commitTxNumber = Local.commitTxNumber,
+      localPaymentBasePoint = Local.payment_basepoint,
+      remotePaymentBasePoint = Remote.payment_basepoint,
+      localIsChannelOpener = true,
+      outputs = outputs)
+    val local_sig = unsignedCommitTx.sign(Local.funding_privkey, Remote.funding_pubkey)
+    val remote_sig = unsignedCommitTx.sign(Remote.funding_privkey, Local.funding_pubkey)
+    println(s"local_signature: ${Scripts.der(local_sig.sig).dropRight(1).toHex}")
+    println(s"remote_signature: ${Scripts.der(remote_sig.sig).dropRight(1).toHex}")
+    val commitTx = unsignedCommitTx.aggregateSigs(Local.funding_pubkey, Remote.funding_pubkey, local_sig, remote_sig)
+    Transaction.correctlySpends(commitTx, Seq(fundingTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
+
+    val htlcTxs = Transactions.makeHtlcTxs(commitTx, outputs, commitmentFormat).sortBy(_.input.outPoint.index)
+    println(s"num_htlcs: ${htlcTxs.length}")
+    htlcTxs.foreach {
+      case tx: UnsignedHtlcSuccessTx =>
+        val remoteHtlcSig = tx.localSig(remoteCommitmentKeys)
+        val redeemScript = Scripts.htlcReceived(localCommitmentKeys.publicKeys, tx.paymentHash, tx.htlcExpiry, commitmentFormat)
+        val localHtlcSig = Transaction.signInput(tx.tx, 0, redeemScript, fr.acinq.bitcoin.SigHash.SIGHASH_ALL, tx.input.txOut.amount, fr.acinq.bitcoin.SigVersion.SIGVERSION_WITNESS_V0, Local.htlc_privkey)
+        println(s"# signature for output #${tx.input.outPoint.index} (htlc-success for htlc #${tx.htlcId})")
+        println(s"local_htlc_signature: ${localHtlcSig.dropRight(1).toHex}")
+        println(s"remote_htlc_signature: ${Scripts.der(remoteHtlcSig).dropRight(1).toHex}")
+      case tx: UnsignedHtlcTimeoutTx =>
+        fail(s"unexpected htlc-timeout tx for htlc #${tx.htlcId}")
+    }
+  }
+
+  test("commitment_tx_with_main_output_trimmed_but_htlcs_present_anchor") {
+    val name = "commitment_tx_with_main_output_trimmed_but_htlcs_present_anchor"
+    val htlc = OutgoingHtlc(UpdateAddHtlc(ByteVector32.Zeroes, 0, 5000000 msat, ByteVector32.Zeroes, CltvExpiry(500), TestConstants.emptyOnionPacket, None, Reputation.maxEndorsement, None))
+    val spec = CommitmentSpec(Set(htlc), FeeratePerKw(15000 sat), toLocal = (10000000000L - 5000000L) msat, toRemote = 0 msat)
+    val dustLimit = 546.sat
+
+    println()
+    println(s"name: $name")
+
+    val outputs = Transactions.makeCommitTxOutputs(
+      Local.funding_pubkey,
+      Remote.funding_pubkey,
+      localCommitmentKeys.publicKeys,
+      payCommitTxFees = true,
+      dustLimit,
+      Local.toSelfDelay,
+      spec,
+      commitmentFormat
+    )
+    val unsignedCommitTx = Transactions.makeCommitTx(
+      commitTxInput = commitmentInput,
+      commitTxNumber = Local.commitTxNumber,
+      localPaymentBasePoint = Local.payment_basepoint,
+      remotePaymentBasePoint = Remote.payment_basepoint,
+      localIsChannelOpener = true,
+      outputs = outputs)
+    val local_sig = unsignedCommitTx.sign(Local.funding_privkey, Remote.funding_pubkey)
+    val remote_sig = unsignedCommitTx.sign(Remote.funding_privkey, Local.funding_pubkey)
+    println(s"local_signature: ${Scripts.der(local_sig.sig).dropRight(1).toHex}")
+    println(s"remote_signature: ${Scripts.der(remote_sig.sig).dropRight(1).toHex}")
+    val commitTx = unsignedCommitTx.aggregateSigs(Local.funding_pubkey, Remote.funding_pubkey, local_sig, remote_sig)
+    Transaction.correctlySpends(commitTx, Seq(fundingTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
+
+    val htlcTxs = Transactions.makeHtlcTxs(commitTx, outputs, commitmentFormat).sortBy(_.input.outPoint.index)
+    println(s"num_htlcs: ${htlcTxs.length}")
+    htlcTxs.foreach {
+      case tx: UnsignedHtlcTimeoutTx =>
+        val remoteHtlcSig = tx.localSig(remoteCommitmentKeys)
+        val withRemoteSig = tx.addRemoteSig(localCommitmentKeys, remoteHtlcSig)
+        val localHtlcSig = withRemoteSig.localSig(WalletInputs(Nil, None))
+        println(s"# signature for output #${tx.input.outPoint.index} (htlc-timeout for htlc #${tx.htlcId})")
+        println(s"local_htlc_signature: ${Scripts.der(localHtlcSig).dropRight(1).toHex}")
+        println(s"remote_htlc_signature: ${Scripts.der(remoteHtlcSig).dropRight(1).toHex}")
+      case tx: UnsignedHtlcSuccessTx =>
+        fail(s"unexpected htlc-success tx for htlc #${tx.htlcId}")
+    }
+  }
 }

@erickcestari erickcestari left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice PR! I've added some notes:

Comment thread smite/src/channel_tx/commitment.rs Outdated
///
/// Returns [`CommitmentError::HtlcNotFound`] if no in-flight HTLC matches
/// `id` and `offerer`.
pub fn fulfill_htlc(&mut self, id: u64, offerer: Side) -> Result<(), CommitmentError> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fulfill_htlc, fail_htlc, update_fee, update_per_commitment_point and advance_commitment_number are missing unit tests.

Comment thread smite/src/channel_tx/commitment.rs Outdated
Comment thread smite/src/channel_tx/commitment.rs Outdated
Comment thread smite-scenarios/src/executor.rs Outdated
Comment thread smite/src/channel_tx/commitment.rs Outdated
///
/// Returns [`CommitmentError::HtlcExceedsBalance`] if the HTLC amount
/// would underflow the offerer's balance.
pub fn add_htlc(&mut self, htlc: Htlc) -> Result<(), CommitmentError> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could either return an error or assert when an attempt is made to add a duplicate Htlc.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, we are the initiator here (In the future, when the target might be the initiator, we should definitely sanitize the result in the oracle itself before coming here), If we prohibit the duplicate HTLC case, we won't be able to test that case with the target. So I think we should just add it (it's not an issue in the commitment tx anyway), and if the peer's response is not an error/warning, that should be handled in the oracle.

I was also thinking of removing the HtlcExceedsBalance case as well, similar to how we handled the commitment fee case in the commitment tx with saturating_sub (Will also add a comment discussing the edge cases that are not handled here and should be handled only by the oracle)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me!

Comment thread smite-scenarios/src/executor.rs Outdated
Comment thread smite/src/channel_tx/commitment.rs Outdated
Comment thread smite/src/channel_tx/commitment.rs Outdated
Comment on lines +903 to +907
let pos = self
.htlcs
.iter()
.position(|h| h.id == id && h.offerer == offerer)
.ok_or(CommitmentError::HtlcNotFound)?;

@NishantBansal2003 NishantBansal2003 Aug 22, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, based on the discussion in #181 (comment), there might be a comment suggesting that we should remove the HtlcNotFound error and make it a hard assert, since this will also be checked by the oracle before getting here. But I think we should keep this until we have those oracles

Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Add optional incoming_htlcs/outgoing_htlcs and the matching
local_htlc_signatures/remote_htlc_signatures fields to the
commitment test vectors, load them into the commitment state
and assert the second-stage HTLC signatures alongside the
commitment signatures.

Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants