Skip to content

Commit 217e659

Browse files
committed
Allow underpaying feerate when using future HTLCs
When an interactive-tx session is created for a liquidity purchase that uses future HTLCs to pay fees, the initiator may not have enough funds to honor the target feerate. We allow the transaction anyway, because we want to get paid for the liquidity we're providing. If the feerate is too low and the transaction doesn't confirm, we can double-spend it if we need that liquidity elsewhere.
1 parent 4ff8b3d commit 217e659

2 files changed

Lines changed: 77 additions & 6 deletions

File tree

eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -741,7 +741,17 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
741741
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
742742
}
743743
case None =>
744-
val minimumFee = Transactions.weight2fee(fundingParams.targetFeerate, tx.weight())
744+
val feeWithoutWitness = Transactions.weight2fee(fundingParams.targetFeerate, tx.weight())
745+
val minimumFee = liquidityPurchase_opt.map(_.paymentDetails) match {
746+
case Some(paymentDetails) => paymentDetails match {
747+
case LiquidityAds.PaymentDetails.FromChannelBalance | _: LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc => feeWithoutWitness
748+
// We allow the feerate to be lower than requested when using on-the-fly funding, because our peer may not
749+
// be able to contribute as much as expected to the funding transaction itself since they don't have funds.
750+
// It's acceptable because they will be paying liquidity fees from future HTLCs.
751+
case _: LiquidityAds.PaymentDetails.FromFutureHtlc | _: LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage => feeWithoutWitness * 0.5
752+
}
753+
case None => feeWithoutWitness
754+
}
745755
if (sharedTx.fees < minimumFee) {
746756
log.warn("invalid interactive tx: below the target feerate (target={}, actual={})", fundingParams.targetFeerate, Transactions.fee2rate(sharedTx.fees, tx.weight()))
747757
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
@@ -951,7 +961,11 @@ object InteractiveTxSigningSession {
951961
return Left(InvalidFundingSignature(fundingParams.channelId, Some(partiallySignedTx.txId)))
952962
}
953963
// We allow a 5% error margin since witness size prediction could be inaccurate.
954-
if (fundingParams.localContribution != 0.sat && txWithSigs.feerate < fundingParams.targetFeerate * 0.95) {
964+
// If they didn't contribute to the transaction, they're not responsible, so we don't check the feerate.
965+
// If we didn't contribute to the transaction, we don't care if they use a lower feerate than expected.
966+
val localContributed = txWithSigs.tx.localInputs.nonEmpty || txWithSigs.tx.localOutputs.nonEmpty
967+
val remoteContributed = txWithSigs.tx.remoteInputs.nonEmpty || txWithSigs.tx.remoteOutputs.nonEmpty
968+
if (localContributed && remoteContributed && txWithSigs.feerate < fundingParams.targetFeerate * 0.95) {
955969
return Left(InvalidFundingFeerate(fundingParams.channelId, fundingParams.targetFeerate, txWithSigs.feerate))
956970
}
957971
val previousOutputs = {

eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,11 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
211211
}
212212
}
213213

214-
private def createFixtureParams(fundingAmountA: Satoshi, fundingAmountB: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long, requireConfirmedInputs: RequireConfirmedInputs = RequireConfirmedInputs(forLocal = false, forRemote = false)): FixtureParams = {
214+
private def createFixtureParams(fundingAmountA: Satoshi, fundingAmountB: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long, requireConfirmedInputs: RequireConfirmedInputs = RequireConfirmedInputs(forLocal = false, forRemote = false), nonInitiatorPaysCommitTxFees: Boolean = false): FixtureParams = {
215215
val channelFeatures = ChannelFeatures(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), Features[InitFeature](Features.DualFunding -> FeatureSupport.Optional), Features[InitFeature](Features.DualFunding -> FeatureSupport.Optional), announceChannel = true)
216216
val Seq(nodeParamsA, nodeParamsB) = Seq(TestConstants.Alice.nodeParams, TestConstants.Bob.nodeParams).map(_.copy(features = Features(channelFeatures.features.map(f => f -> FeatureSupport.Optional).toMap[Feature, FeatureSupport])))
217-
val localParamsA = makeChannelParams(nodeParamsA, nodeParamsA.features.initFeatures(), None, None, isChannelOpener = true, dualFunded = true, fundingAmountA, unlimitedMaxHtlcValueInFlight = false)
218-
val localParamsB = makeChannelParams(nodeParamsB, nodeParamsB.features.initFeatures(), None, None, isChannelOpener = false, dualFunded = true, fundingAmountB, unlimitedMaxHtlcValueInFlight = false)
217+
val localParamsA = makeChannelParams(nodeParamsA, nodeParamsA.features.initFeatures(), None, None, isChannelOpener = true, dualFunded = true, fundingAmountA, unlimitedMaxHtlcValueInFlight = false).copy(paysCommitTxFees = !nonInitiatorPaysCommitTxFees)
218+
val localParamsB = makeChannelParams(nodeParamsB, nodeParamsB.features.initFeatures(), None, None, isChannelOpener = false, dualFunded = true, fundingAmountB, unlimitedMaxHtlcValueInFlight = false).copy(paysCommitTxFees = nonInitiatorPaysCommitTxFees)
219219

220220
val Seq(remoteParamsA, remoteParamsB) = Seq((nodeParamsA, localParamsA), (nodeParamsB, localParamsB)).map {
221221
case (nodeParams, localParams) =>
@@ -287,7 +287,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
287287
utxosB.foreach(amount => addUtxo(walletB, amount, probe))
288288
generateBlocks(1)
289289

290-
val fixtureParams = createFixtureParams(fundingAmountA, fundingAmountB, targetFeerate, dustLimit, lockTime, requireConfirmedInputs)
290+
val fixtureParams = createFixtureParams(fundingAmountA, fundingAmountB, targetFeerate, dustLimit, lockTime, requireConfirmedInputs, nonInitiatorPaysCommitTxFees = liquidityPurchase_opt.nonEmpty)
291291
val alice = fixtureParams.spawnTxBuilderAlice(walletA, liquidityPurchase_opt = liquidityPurchase_opt)
292292
val bob = fixtureParams.spawnTxBuilderBob(walletB, liquidityPurchase_opt = liquidityPurchase_opt)
293293
testFun(Fixture(alice, bob, fixtureParams, walletA, rpcClientA, walletB, rpcClientB, TestProbe(), TestProbe()))
@@ -564,6 +564,59 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
564564
}
565565
}
566566

567+
test("initiator does not contribute -- on-the-fly funding") {
568+
val targetFeerate = FeeratePerKw(5000 sat)
569+
val fundingB = 150_000.sat
570+
val utxosB = Seq(200_000 sat)
571+
// When on-the-fly funding is used, the initiator may not contribute to the funding transaction.
572+
// It will receive HTLCs later that use the purchased inbound liquidity, and liquidity fees will be deduced from those HTLCs.
573+
val purchase = LiquidityAds.Purchase.Standard(fundingB, LiquidityAds.Fees(2500 sat, 7500 sat), LiquidityAds.PaymentDetails.FromFutureHtlc(Nil))
574+
withFixture(0 sat, Nil, fundingB, utxosB, targetFeerate, 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false), Some(purchase)) { f =>
575+
import f._
576+
577+
alice ! Start(alice2bob.ref)
578+
bob ! Start(bob2alice.ref)
579+
580+
// Alice --- tx_add_output --> Bob
581+
fwd.forwardAlice2Bob[TxAddOutput]
582+
// Alice <-- tx_add_input --- Bob
583+
fwd.forwardBob2Alice[TxAddInput]
584+
// Alice --- tx_complete --> Bob
585+
fwd.forwardAlice2Bob[TxComplete]
586+
// Alice <-- tx_add_output --- Bob
587+
fwd.forwardBob2Alice[TxAddOutput]
588+
// Alice --- tx_complete --> Bob
589+
fwd.forwardAlice2Bob[TxComplete]
590+
// Alice <-- tx_complete --- Bob
591+
fwd.forwardBob2Alice[TxComplete]
592+
593+
// Alice is responsible for adding the shared output, but Bob is paying for everything.
594+
assert(aliceParams.fundingAmount == fundingB)
595+
596+
// Alice sends signatures first as she did not contribute at all.
597+
val successA = alice2bob.expectMsgType[Succeeded]
598+
val successB = bob2alice.expectMsgType[Succeeded]
599+
val (txA, _, txB, commitmentB) = fixtureParams.exchangeSigsAliceFirst(aliceParams, successA, successB)
600+
// Alice doesn't pay any fees to Bob during the interactive-tx, fees will be paid from future HTLCs.
601+
assert(commitmentB.localCommit.spec.toLocal == fundingB.toMilliSatoshi)
602+
603+
// The resulting transaction is valid but has a lower feerate than expected.
604+
assert(txA.txId == txB.txId)
605+
assert(txA.tx.localAmountIn == 0.msat)
606+
assert(txA.tx.localFees == 0.msat)
607+
assert(txB.tx.remoteAmountIn == 0.msat)
608+
assert(txB.tx.remoteFees == 0.msat)
609+
assert(txB.tx.localFees > 0.msat)
610+
val probe = TestProbe()
611+
walletA.publishTransaction(txA.signedTx).pipeTo(probe.ref)
612+
probe.expectMsg(txA.txId)
613+
walletA.getMempoolTx(txA.txId).pipeTo(probe.ref)
614+
val mempoolTx = probe.expectMsgType[MempoolTx]
615+
assert(mempoolTx.fees == txA.tx.fees)
616+
assert(targetFeerate * 0.5 <= txA.feerate && txA.feerate < targetFeerate, s"unexpected feerate (target=$targetFeerate actual=${txA.feerate})")
617+
}
618+
}
619+
567620
test("initiator and non-initiator splice-in") {
568621
val targetFeerate = FeeratePerKw(1000 sat)
569622
// We chose those amounts to ensure that Bob always signs first:
@@ -2193,6 +2246,10 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
21932246
val bobSplice = params.spawnTxBuilderSpliceBob(spliceParams, previousCommitment, wallet, Some(purchase))
21942247
bobSplice ! Start(probe.ref)
21952248
assert(probe.expectMsgType[LocalFailure].cause == InvalidFundingBalances(params.channelId, 620_000 sat, 625_000_000 msat, -5_000_000 msat))
2249+
// If we use a payment type where fees are paid outside of the interactive-tx session, the funding attempt is valid.
2250+
val bobFutureHtlc = params.spawnTxBuilderBob(wallet, params.fundingParamsB, Some(purchase.copy(paymentDetails = LiquidityAds.PaymentDetails.FromFutureHtlc(Nil))))
2251+
bobFutureHtlc ! Start(probe.ref)
2252+
probe.expectNoMessage(100 millis)
21962253
}
21972254

21982255
test("invalid input") {

0 commit comments

Comments
 (0)