diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index c1ab71dea..2778d28f5 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -239,6 +239,7 @@ data class NodeParams( maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false, + considerOnlyMiningFeeForAbsoluteFeeCheck = false, maxAllowedFeeCredit = 0.msat ) ), diff --git a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index 9f1f6d904..a1afd9c6d 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -1289,7 +1289,7 @@ class Peer( val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = available.channel.commitments.active.first(), walletInputs = cmd.walletInputs, localOutputs = emptyList()) val (feerate, fee) = client.computeSpliceCpfpFeerate(available.channel.commitments, targetFeerate, spliceWeight = weight, logger) logger.info { "requesting splice-in using balance=${cmd.walletInputs.balance} feerate=$feerate fee=$fee" } - when (val rejected = nodeParams.liquidityPolicy.value.maybeReject(cmd.walletInputs.balance.toMilliSatoshi(), fee.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger)) { + when (val rejected = nodeParams.liquidityPolicy.value.maybeReject(cmd.walletInputs.balance.toMilliSatoshi(), ChannelManagementFees(miningFee = fee, serviceFee = 0.sat), LiquidityEvents.Source.OnChainWallet, logger)) { is LiquidityEvents.Rejected -> { logger.info { "rejecting splice: reason=${rejected.reason}" } nodeParams._nodeEvents.emit(rejected) @@ -1356,7 +1356,7 @@ class Peer( swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) } else { val totalAmount = cmd.walletInputs.balance.toMilliSatoshi() + requestRemoteFunding.requestedAmount.toMilliSatoshi() - when (val rejected = nodeParams.liquidityPolicy.first().maybeReject(totalAmount, fees.total.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger)) { + when (val rejected = nodeParams.liquidityPolicy.first().maybeReject(totalAmount, fees, LiquidityEvents.Source.OnChainWallet, logger)) { is LiquidityEvents.Rejected -> { logger.info { "rejecting channel open: reason=${rejected.reason}" } nodeParams._nodeEvents.emit(rejected) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index 955a6934e..ccc4f050f 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -275,7 +275,8 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) { else -> { // We're not adding to our fee credit, so we need to check our liquidity policy. // Even if we have enough fee credit to pay the fees, we may want to wait for a lower feerate. - val fees = fundingRate.fees(currentFeerate, requestedAmount, requestedAmount, isChannelCreation = true).total.toMilliSatoshi() + val fees = fundingRate.fees(currentFeerate, requestedAmount, requestedAmount, isChannelCreation = true) + .let { ChannelManagementFees(miningFee = it.miningFee, serviceFee = it.serviceFee) } when (val rejected = nodeParams.liquidityPolicy.value.maybeReject(requestedAmount.toMilliSatoshi(), fees, LiquidityEvents.Source.OffChainPayment, logger)) { is LiquidityEvents.Rejected -> { nodeParams._nodeEvents.emit(rejected) @@ -368,7 +369,8 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) { else -> { // We don't know at that point if we'll need a channel or if we already have one. // We must use the worst case fees that applies to channel creation. - val fees = fundingRate.fees(currentFeerate, requestedAmount, requestedAmount, isChannelCreation = true).total + val fees = fundingRate.fees(currentFeerate, requestedAmount, requestedAmount, isChannelCreation = true) + .let { ChannelManagementFees(miningFee = it.miningFee, serviceFee = it.serviceFee) } val canAddToFeeCredit = Features.canUseFeature(nodeParams.features, remoteFeatures, Feature.FundingFeeCredit) && (willAddHtlcAmount + currentFeeCredit) <= liquidityPolicy.maxAllowedFeeCredit logger.info { "on-the-fly assessment: amount=$requestedAmount feerate=$currentFeerate fees=$fees" } val rejected = when { @@ -376,13 +378,13 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) { canAddToFeeCredit -> null // We only initiate on-the-fly funding if the missing amount is greater than the fees paid. // Otherwise our peer may not be able to claim the funding fees from the relayed HTLCs. - (willAddHtlcAmount + currentFeeCredit) < fees * 2 -> LiquidityEvents.Rejected( + (willAddHtlcAmount + currentFeeCredit) < fees.total * 2 -> LiquidityEvents.Rejected( requestedAmount.toMilliSatoshi(), - fees.toMilliSatoshi(), + fees.total.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow(willAddHtlcAmount, currentFeeCredit) ) - else -> liquidityPolicy.maybeReject(requestedAmount.toMilliSatoshi(), fees.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, logger) + else -> liquidityPolicy.maybeReject(requestedAmount.toMilliSatoshi(), fees, LiquidityEvents.Source.OffChainPayment, logger) } when (rejected) { null -> Either.Right(Pair(requestedAmount, fundingRate)) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt index 359c72b2e..e375d0dae 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt @@ -3,6 +3,7 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.LiquidityEvents import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.channel.ChannelManagementFees import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat @@ -19,12 +20,13 @@ sealed class LiquidityPolicy { * @param maxAbsoluteFee max absolute fee * @param maxRelativeFeeBasisPoints max relative fee (all included: service fee and mining fee) (1_000 bips = 10 %) * @param skipAbsoluteFeeCheck only applies for off-chain payments, being more lax may make sense when the sender doesn't retry payments + * @param considerOnlyMiningFeeForAbsoluteFeeCheck only consider the mining fee for the absolute fee check. This makes sense in `inboundLiquidityTarget` is used, and the funding rate predictable * @param maxAllowedFeeCredit maximum amount that can be added to fee credit (see [fr.acinq.lightning.Feature.FundingFeeCredit]) */ - data class Auto(val inboundLiquidityTarget: Satoshi?, val maxAbsoluteFee: Satoshi, val maxRelativeFeeBasisPoints: Int, val skipAbsoluteFeeCheck: Boolean, val maxAllowedFeeCredit: MilliSatoshi) : LiquidityPolicy() + data class Auto(val inboundLiquidityTarget: Satoshi?, val maxAbsoluteFee: Satoshi, val maxRelativeFeeBasisPoints: Int, val skipAbsoluteFeeCheck: Boolean, val maxAllowedFeeCredit: MilliSatoshi, val considerOnlyMiningFeeForAbsoluteFeeCheck: Boolean = false) : LiquidityPolicy() /** Make a decision for a particular liquidity event. */ - fun maybeReject(amount: MilliSatoshi, fee: MilliSatoshi, source: LiquidityEvents.Source, logger: MDCLogger): LiquidityEvents.Rejected? { + fun maybeReject(amount: MilliSatoshi, fee: ChannelManagementFees, source: LiquidityEvents.Source, logger: MDCLogger): LiquidityEvents.Rejected? { return when (this) { is Disable -> LiquidityEvents.Rejected.Reason.PolicySetToDisabled is Auto -> { @@ -32,11 +34,12 @@ sealed class LiquidityPolicy { val maxRelativeFee = amount * maxRelativeFeeBasisPoints / 10_000 logger.info { "liquidity policy check: amount=$amount liquidityTarget=${inboundLiquidityTarget ?: 0.sat} fee=$fee maxAbsoluteFee=$maxAbsoluteFee maxRelativeFee=$maxRelativeFee policy=$this" } when { - fee > maxRelativeFee -> LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(maxRelativeFeeBasisPoints) - fee > maxAbsoluteFee -> LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(this.maxAbsoluteFee) + fee.total.toMilliSatoshi() > maxRelativeFee -> LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(maxRelativeFeeBasisPoints) + considerOnlyMiningFeeForAbsoluteFeeCheck && fee.miningFee.toMilliSatoshi() > maxAbsoluteFee -> LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(this.maxAbsoluteFee) + !considerOnlyMiningFeeForAbsoluteFeeCheck && fee.total.toMilliSatoshi() > maxAbsoluteFee -> LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(this.maxAbsoluteFee) else -> null // accept } } - }?.let { reason -> LiquidityEvents.Rejected(amount, fee, source, reason) } + }?.let { reason -> LiquidityEvents.Rejected(amount, fee.total.toMilliSatoshi(), source, reason) } } } \ No newline at end of file diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt index 0a4d99b5b..146a78078 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt @@ -1,6 +1,7 @@ package fr.acinq.lightning.payment import fr.acinq.lightning.LiquidityEvents +import fr.acinq.lightning.channel.ChannelManagementFees import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.utils.msat @@ -19,30 +20,42 @@ class LiquidityPolicyTestsCommon : LightningTestSuite() { // fee over both absolute and relative assertEquals( expected = LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(policy.maxRelativeFeeBasisPoints), - actual = policy.maybeReject(amount = 4_000_000.msat, fee = 3_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)?.reason + actual = policy.maybeReject(amount = 4_000_000.msat, fee = ChannelManagementFees(miningFee = 3_000.sat, serviceFee = 0.sat), source = LiquidityEvents.Source.OffChainPayment, logger)?.reason ) // fee over absolute assertEquals( expected = LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(policy.maxAbsoluteFee), - actual = policy.maybeReject(amount = 15_000_000.msat, fee = 3_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)?.reason + actual = policy.maybeReject(amount = 15_000_000.msat, fee = ChannelManagementFees(miningFee = 3_000.sat, serviceFee = 0.sat), source = LiquidityEvents.Source.OffChainPayment, logger)?.reason ) // fee over relative assertEquals( expected = LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(policy.maxRelativeFeeBasisPoints), - actual = policy.maybeReject(amount = 4_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)?.reason + actual = policy.maybeReject(amount = 4_000_000.msat, fee = ChannelManagementFees(miningFee = 2_000.sat, serviceFee = 0.sat), source = LiquidityEvents.Source.OffChainPayment, logger)?.reason ) - assertNull(policy.maybeReject(amount = 10_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)) + assertNull(policy.maybeReject(amount = 10_000_000.msat, fee = ChannelManagementFees(miningFee = 2_000.sat, serviceFee = 0.sat), source = LiquidityEvents.Source.OffChainPayment, logger)) } @Test fun `policy rejection skip absolute check`() { val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 1_000.sat, maxRelativeFeeBasisPoints = 5_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = true, inboundLiquidityTarget = null, maxAllowedFeeCredit = 0.msat) // fee is over absolute, and it's an offchain payment so the check passes - assertNull(policy.maybeReject(amount = 4_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)) + assertNull(policy.maybeReject(amount = 4_000_000.msat, fee = ChannelManagementFees(miningFee = 2_000.sat, serviceFee = 0.sat), source = LiquidityEvents.Source.OffChainPayment, logger)) // fee is over absolute, but it's an on-chain payment so the check fails assertEquals( expected = LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(policy.maxAbsoluteFee), - actual = policy.maybeReject(amount = 4_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OnChainWallet, logger)?.reason + actual = policy.maybeReject(amount = 4_000_000.msat, fee = ChannelManagementFees(miningFee = 2_000.sat, serviceFee = 0.sat), source = LiquidityEvents.Source.OnChainWallet, logger)?.reason + ) + } + + @Test + fun `policy rejection mining fee check`() { + val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 1_000.sat, maxRelativeFeeBasisPoints = 5_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false, inboundLiquidityTarget = null, maxAllowedFeeCredit = 0.msat, considerOnlyMiningFeeForAbsoluteFeeCheck = true) + // total fee is over absolute, but mining fee is below and we only consider the mining fee + assertNull(policy.maybeReject(amount = 10_000_000.msat, fee = ChannelManagementFees(miningFee = 900.sat, serviceFee = 2_000.sat), source = LiquidityEvents.Source.OnChainWallet, logger)) + // the mining fee is over absolute + assertEquals( + expected = LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(policy.maxAbsoluteFee), + actual = policy.maybeReject(amount = 10_000_000.msat, fee = ChannelManagementFees(miningFee = 2_000.sat, serviceFee = 0.sat), source = LiquidityEvents.Source.OnChainWallet, logger)?.reason ) } } \ No newline at end of file