From 83350196b6b35fb3f361be39e8461214738e11bb Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Tue, 30 Jan 2024 14:28:15 +0100 Subject: [PATCH] Add method to calculate input for a trampoline fee This method returns the amount that needs to be provided to the trampoline payment handler in order to entirely consume a given amount, without leaving any dust. This allows the node to send all their balance over LN. --- .../kotlin/fr/acinq/lightning/NodeParams.kt | 30 ++++++++++ .../lightning/TrampolineFeesTestsCommon.kt | 58 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 src/commonTest/kotlin/fr/acinq/lightning/TrampolineFeesTestsCommon.kt diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index eb6e7889f..f1dbabe0d 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -7,6 +7,7 @@ import fr.acinq.lightning.Lightning.nodeFee import fr.acinq.lightning.blockchain.fee.FeerateTolerance import fr.acinq.lightning.blockchain.fee.OnChainFeeConf import fr.acinq.lightning.crypto.KeyManager +import fr.acinq.lightning.io.SendPayment import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat @@ -25,7 +26,36 @@ data class NodeUri(val id: PublicKey, val host: String, val port: Int) * This class encapsulates the fees and expiry to use at a particular attempt. */ data class TrampolineFees(val feeBase: Satoshi, val feeProportional: Long, val cltvExpiryDelta: CltvExpiryDelta) { + /** + * Calculates the trampoline fee for [recipientAmount]. + * + * @param recipientAmount the amount that will be received by the recipient. + */ fun calculateFees(recipientAmount: MilliSatoshi): MilliSatoshi = nodeFee(feeBase.toMilliSatoshi(), feeProportional, recipientAmount) + + /** + * Calculates the amount that must be provided to the trampoline payload to pay the trampoline + * fee and consume all the [finalAmount], leaving no dust. + * + * E.g.: balance is 1_000 sat, trampoline fees is 4 sat + 0.4%. To use up all balance, [SendPayment] + * should use an amount of 992_032 msat (fee=7_968 msat, around 4 sat + 0.4%). + * + * @param finalAmount what leaves the node, including fees. + * @return the amount to provide to the trampoline handler, null if there is no valid amount. + */ + fun calculateReverseAmount(finalAmount: MilliSatoshi): MilliSatoshi? { + val amountBeforeBaseFee = finalAmount - this.feeBase.toMilliSatoshi() + val proportionalFee = this.feeProportional.toDouble() / 1_000_000 + + // first, we approximate the amount that must be used in the trampoline payload + val amountBeforeFee = amountBeforeBaseFee.msat / (1 + proportionalFee) + + // we cannot return the amount above directly: `OutgoingPaymentHandler` uses `calculateFees` to + // create the trampoline payload, which involves rounding, so the amount leaving the wallet may + // be off by 1 msat. So we reverse what the handler does to find the exact amount. + val fee = calculateFees(amountBeforeFee.toLong().msat) + return (finalAmount - fee).takeUnless { it < 0.msat } + } } /** diff --git a/src/commonTest/kotlin/fr/acinq/lightning/TrampolineFeesTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/TrampolineFeesTestsCommon.kt new file mode 100644 index 000000000..e13be609a --- /dev/null +++ b/src/commonTest/kotlin/fr/acinq/lightning/TrampolineFeesTestsCommon.kt @@ -0,0 +1,58 @@ +package fr.acinq.lightning + +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat +import kotlin.test.Test +import kotlin.test.assertEquals + +class TrampolineFeesTestsCommon { + + @Test + fun `calculate fees`() { + val trampolineFees = TrampolineFees( + feeBase = 4.sat, + feeProportional = 4_000, + cltvExpiryDelta = CltvExpiryDelta(576) + ) + + assertEquals(4_000.msat, trampolineFees.calculateFees(0.msat)) + assertEquals(4_000.msat, trampolineFees.calculateFees(1.msat)) + assertEquals(4_004.msat, trampolineFees.calculateFees(1_001.msat)) + assertEquals(8_000.msat, trampolineFees.calculateFees(1_000_000.msat)) + assertEquals(8_003.msat, trampolineFees.calculateFees(1_000_789.msat)) + assertEquals(497_827.msat, trampolineFees.calculateFees(123_456_789.msat)) + } + + @Test + fun `calculate reverse amount`() { + val trampolineFees = TrampolineFees( + feeBase = 4.sat, + feeProportional = 4_000, + cltvExpiryDelta = CltvExpiryDelta(576) + ) + + // amount available in the wallet -> amount that should be provided in the trampoline payload + val testCases = listOf( + 0.msat to null, + 1.msat to null, + 1_000.msat to null, + 4_000.msat to 0.msat, + 4_001.msat to 1.msat, + 4_004.msat to 4.msat, + 8_000.msat to 3_985.msat, + 8_016.msat to 4_000.msat, + 1_000_000.msat to 992_032.msat, + 100_000_000.msat to 99_597_610.msat, + 123_456_789.msat to 122_960_946.msat + ) + + testCases.forEach { (availableAmount, expectedTrampolineAmount) -> + val trampolineAmount = trampolineFees.calculateReverseAmount(availableAmount) + println("$availableAmount: expected=$expectedTrampolineAmount actual=$trampolineAmount") + assertEquals(expectedTrampolineAmount, trampolineAmount) + if (trampolineAmount != null) { + assertEquals(availableAmount, trampolineFees.calculateFees(trampolineAmount) + trampolineAmount) + } + } + } +} \ No newline at end of file