From b4e79977d59519e4d84dbdde88f495209cc4a1ff Mon Sep 17 00:00:00 2001 From: pm47 Date: Wed, 31 Jan 2024 18:00:54 +0100 Subject: [PATCH] fee credit + auto liquidity basic prototype This is a basic prototype for: - fee-credit: instead of rejecting a pay-to-open request that is too expensive (in absolute or relative terms), add the option to put the amount aside to pay for future mining/service fees. - auto-liquidity: inform the peer that we would like additional liquidity during the next splice operation. We also take the opportunity to do some clean-up in the `PayToOpenRequest` class by ignoring unused fields. --- .../kotlin/fr/acinq/lightning/NodeEvents.kt | 21 ++++--- .../kotlin/fr/acinq/lightning/NodeParams.kt | 17 ++++-- .../fr/acinq/lightning/db/PaymentsDb.kt | 4 ++ .../kotlin/fr/acinq/lightning/io/Peer.kt | 41 +++++++++---- .../payment/IncomingPaymentHandler.kt | 59 +++++++++++++------ .../lightning/payment/LiquidityPolicy.kt | 39 ++++++++---- .../acinq/lightning/wire/LightningMessages.kt | 48 ++++++++++++++- .../payment/LiquidityPolicyTestsCommon.kt | 30 ++++++---- .../wire/LightningCodecsTestsCommon.kt | 2 +- 9 files changed, 189 insertions(+), 72 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt index 225bc9c91..5187afaf0 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt @@ -35,15 +35,22 @@ sealed interface LiquidityEvents : NodeEvents { val source: Source enum class Source { OnChainWallet, OffChainPayment } - data class Rejected(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source, val reason: Reason) : LiquidityEvents { - sealed class Reason { - data object PolicySetToDisabled : Reason() - sealed class TooExpensive : Reason() { - data class OverAbsoluteFee(val maxAbsoluteFee: Satoshi) : TooExpensive() - data class OverRelativeFee(val maxRelativeFeeBasisPoints: Int) : TooExpensive() + + sealed interface Decision : LiquidityEvents { + data class Rejected(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source, val reason: Reason) : Decision { + sealed class Reason { + data object PolicySetToDisabled : Reason() + sealed class TooExpensive : Reason() { + data class OverAbsoluteFee(val maxAbsoluteFee: Satoshi) : TooExpensive() + data class OverRelativeFee(val maxRelativeFeeBasisPoints: Int) : TooExpensive() + } + data class OverMaxCredit(val maxAllowedCredit: Satoshi) : TooExpensive() + + data object ChannelInitializing : Reason() } - data object ChannelInitializing : Reason() } + data class AddedToFeeCredit(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source) : Decision + data class Accepted(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source) : Decision } data class ApprovalRequested(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source, val replyTo: CompletableDeferred) : LiquidityEvents diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index 6abd258e2..6879d591f 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -15,10 +15,7 @@ import fr.acinq.lightning.utils.toMilliSatoshi import fr.acinq.lightning.wire.OfferTypes import io.ktor.utils.io.charsets.* import io.ktor.utils.io.core.* -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.* import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -163,6 +160,9 @@ data class NodeParams( internal val _nodeEvents = MutableSharedFlow(replay = 10) val nodeEvents: SharedFlow get() = _nodeEvents.asSharedFlow() + internal val _feeCredit = MutableStateFlow(0.sat) + val feeCredit: StateFlow get() = _feeCredit.asStateFlow() + init { require(features.hasFeature(Feature.VariableLengthOnion, FeatureSupport.Mandatory)) { "${Feature.VariableLengthOnion.rfcName} should be mandatory" } require(features.hasFeature(Feature.PaymentSecret, FeatureSupport.Mandatory)) { "${Feature.PaymentSecret.rfcName} should be mandatory" } @@ -229,7 +229,14 @@ data class NodeParams( maxPaymentAttempts = 5, zeroConfPeers = emptySet(), paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(75), CltvExpiryDelta(200)), - liquidityPolicy = MutableStateFlow(LiquidityPolicy.Auto(maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false)), + liquidityPolicy = MutableStateFlow( + LiquidityPolicy.Auto( + maxAbsoluteFee = 2_000.sat, + maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, + skipAbsoluteFeeCheck = false, + maxAllowedCredit = 0.sat + ) + ), minFinalCltvExpiryDelta = Bolt11Invoice.DEFAULT_MIN_FINAL_EXPIRY_DELTA, maxFinalCltvExpiryDelta = CltvExpiryDelta(360), bolt12invoiceExpiry = 60.seconds, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt index ba6661fab..550697b56 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt @@ -175,6 +175,10 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r override val fees: MilliSatoshi = 0.msat // with Lightning, the fee is paid by the sender } + data class FeeCreditPayment(override val amount: MilliSatoshi) : ReceivedWith() { + override val fees: MilliSatoshi get() = 0.msat // there are no fees when payment is added to the fee credit + } + sealed class OnChainIncomingPayment : ReceivedWith() { abstract val serviceFee: MilliSatoshi abstract val miningFee: Satoshi diff --git a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index ce09b5001..76d2073c2 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -734,6 +734,11 @@ class Peer( return replyTo.await() } + fun setAutoLiquidityParams(amount: Satoshi) { + logger.info { "setting auto-liquidity=$amount" } + peerConnection?.send(AutoLiquidityParams(amount)) + } + sealed class SelectChannelResult { /** We have a channel that is available for payments and splicing. */ data class Available(val channel: Normal) : SelectChannelResult() @@ -1032,12 +1037,16 @@ class Peer( is Origin.PleaseOpenChannelOrigin -> when (val request = channelRequests[origin.requestId]) { is RequestChannelOpen -> { val totalFee = origin.serviceFee + origin.miningFee.toMilliSatoshi() - msg.pushAmount - nodeParams.liquidityPolicy.value.maybeReject(request.walletInputs.balance.toMilliSatoshi(), totalFee, LiquidityEvents.Source.OnChainWallet, logger)?.let { rejected -> - logger.info { "rejecting open_channel2: reason=${rejected.reason}" } - nodeParams._nodeEvents.emit(rejected) - swapInCommands.send(SwapInCommand.UnlockWalletInputs(request.walletInputs.map { it.outPoint }.toSet())) - peerConnection?.send(Error(msg.temporaryChannelId, "cancelling open due to local liquidity policy")) - return + val decision = nodeParams.liquidityPolicy.value.maybeReject(request.walletInputs.balance.toMilliSatoshi(), totalFee, LiquidityEvents.Source.OnChainWallet, logger, nodeParams.feeCredit.value) + when (decision) { + is LiquidityEvents.Decision.Rejected -> { + logger.info { "rejecting open_channel2: reason=${decision.reason}" } + nodeParams._nodeEvents.emit(decision) + swapInCommands.send(SwapInCommand.UnlockWalletInputs(request.walletInputs.map { it.outPoint }.toSet())) + peerConnection?.send(Error(msg.temporaryChannelId, "cancelling open due to local liquidity policy")) + return + } + else -> {} } val fundingFee = Transactions.weight2fee(msg.fundingFeerate, FundingContributions.weight(request.walletInputs)) // We have to pay the fees for our inputs, so we deduce them from our funding amount. @@ -1174,7 +1183,7 @@ class Peer( // the payment in db when we will process the corresponding splice and see the pay-to-open origin. This // can take a long time depending on the confirmation speed. It is better and simpler to reject the incoming // payment rather that having the user wonder where their money went. - val rejected = LiquidityEvents.Rejected(msg.amountMsat, msg.payToOpenFeeSatoshis.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.ChannelInitializing) + val rejected = LiquidityEvents.Decision.Rejected(msg.amountMsat, msg.payToOpenFeeSatoshis.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Decision.Rejected.Reason.ChannelInitializing) logger.info { "rejecting pay-to-open: reason=${rejected.reason}" } nodeParams._nodeEvents.emit(rejected) val action = IncomingPaymentHandler.actionForPayToOpenFailure(nodeParams.nodePrivateKey, TemporaryNodeFailure, msg) @@ -1214,6 +1223,10 @@ class Peer( logger.info { "bip353 dns address assigned: ${msg.address}" } _eventsFlow.emit(AddressAssigned(msg.address)) } + is CurrentFeeCredit -> { + logger.info { "current fee credit: ${msg.amount}" } + nodeParams._feeCredit.emit(msg.amount) + } } } is WatchReceived -> { @@ -1236,11 +1249,15 @@ class Peer( 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" } - nodeParams.liquidityPolicy.value.maybeReject(cmd.walletInputs.balance.toMilliSatoshi(), fee.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger)?.let { rejected -> - logger.info { "rejecting splice: reason=${rejected.reason}" } - nodeParams._nodeEvents.emit(rejected) - swapInCommands.send(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) - return + val decision = nodeParams.liquidityPolicy.value.maybeReject(cmd.walletInputs.balance.toMilliSatoshi(), fee.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger, nodeParams.feeCredit.value) + nodeParams._nodeEvents.emit(decision) + when (decision) { + is LiquidityEvents.Decision.Rejected -> { + logger.info { "rejecting splice: reason=${decision.reason}" } + swapInCommands.send(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) + return + } + else -> {} } val spliceCommand = ChannelCommand.Commitment.Splice.Request( diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index fd814cabc..22a31cf0e 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -255,23 +255,35 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment return ProcessAddResult.Pending(incomingPayment, payment) } else -> { - if (payment.parts.filterIsInstance().isNotEmpty()) { + val liquidityDecision = if (payment.parts.filterIsInstance().isNotEmpty()) { // We consider the total amount received (not only the pay-to-open parts) to evaluate whether or not to accept the payment val payToOpenFee = payment.parts.filterIsInstance().map { it.payToOpenRequest.payToOpenFeeSatoshis }.sum() - nodeParams.liquidityPolicy.value.maybeReject(payment.amountReceived, payToOpenFee.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, logger)?.let { rejected -> - logger.info { "rejecting pay-to-open: reason=${rejected.reason}" } - nodeParams._nodeEvents.emit(rejected) - val actions = payment.parts.map { part -> - val failureMsg = TemporaryNodeFailure - when (part) { - is HtlcPart -> actionForFailureMessage(failureMsg, part.htlc) - is PayToOpenPart -> actionForPayToOpenFailure(privateKey, failureMsg, part.payToOpenRequest) // NB: this will fail all parts, we could only return one + val decision = nodeParams.liquidityPolicy.value.maybeReject(payment.amountReceived, payToOpenFee.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, logger, nodeParams.feeCredit.value) + logger.info { "pay-to-open decision: $decision" } + nodeParams._nodeEvents.emit(decision) + when (decision) { + is LiquidityEvents.Decision.Rejected -> { + logger.info { "rejecting pay-to-open: reason=${decision.reason}" } + val actions = payment.parts.map { part -> + val failureMsg = TemporaryNodeFailure + when (part) { + is HtlcPart -> actionForFailureMessage(failureMsg, part.htlc) + is PayToOpenPart -> actionForPayToOpenFailure(privateKey, failureMsg, part.payToOpenRequest) // NB: this will fail all parts, we could only return one + } } + pending.remove(paymentPart.paymentHash) + return ProcessAddResult.Rejected(actions, incomingPayment) + } + is LiquidityEvents.Decision.AddedToFeeCredit -> { + logger.info { "added pay-to-open to fee credit" } + decision + } + is LiquidityEvents.Decision.Accepted -> { + logger.info { "accepted pay-to-open" } + decision } - pending.remove(paymentPart.paymentHash) - return ProcessAddResult.Rejected(actions, incomingPayment) } - } + } else null when (val finalPayload = paymentPart.finalPayload) { is PaymentOnion.FinalPayload.Standard -> when (finalPayload.paymentMetadata) { @@ -285,12 +297,21 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment // We only fill the DB with htlc parts, because we cannot be sure yet that our peer will honor the pay-to-open part(s). // When the payment contains pay-to-open parts, it will be considered received, but the sum of all parts will be smaller // than the expected amount. The pay-to-open part(s) will be added once we received the corresponding new channel or a splice-in. - val receivedWith = htlcParts.map { part -> - IncomingPayment.ReceivedWith.LightningPayment( - amount = part.amount, - htlcId = part.htlc.id, - channelId = part.htlc.channelId - ) + val receivedWith = buildList { + addAll(htlcParts.map { part -> + IncomingPayment.ReceivedWith.LightningPayment( + amount = part.amount, + htlcId = part.htlc.id, + channelId = part.htlc.channelId + ) + }) + if (liquidityDecision is LiquidityEvents.Decision.AddedToFeeCredit) { + addAll(payToOpenParts.map { part -> + IncomingPayment.ReceivedWith.FeeCreditPayment( + amount = part.amount + ) + }) + } } val actions = buildList { htlcParts.forEach { part -> @@ -299,7 +320,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment } // We avoid sending duplicate pay-to-open responses, since the preimage is the same for every part. if (payToOpenParts.isNotEmpty()) { - val response = PayToOpenResponse(nodeParams.chainHash, incomingPayment.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage)) + val response = PayToOpenResponse(nodeParams.chainHash, incomingPayment.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage, addToFeeCredit = liquidityDecision is LiquidityEvents.Decision.AddedToFeeCredit)) add(PayToOpenResponseCommand(response)) } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt index 101dc7bb7..b044c6085 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt @@ -3,8 +3,9 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.LiquidityEvents import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.toMilliSatoshi @@ -17,24 +18,38 @@ 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 maxAllowedCredit if other checks fail, accept the payment and add the corresponding amount to fee credit up to this max value (only applies to offline payments, 0 sat to disable) */ - data class Auto(val maxAbsoluteFee: Satoshi, val maxRelativeFeeBasisPoints: Int, val skipAbsoluteFeeCheck: Boolean) : LiquidityPolicy() + data class Auto(val maxAbsoluteFee: Satoshi, val maxRelativeFeeBasisPoints: Int, val skipAbsoluteFeeCheck: Boolean, val maxAllowedCredit: Satoshi) : LiquidityPolicy() /** Make decision for a particular liquidity event */ - fun maybeReject(amount: MilliSatoshi, fee: MilliSatoshi, source: LiquidityEvents.Source, logger: MDCLogger): LiquidityEvents.Rejected? { + fun maybeReject(amount: MilliSatoshi, fee: MilliSatoshi, source: LiquidityEvents.Source, logger: MDCLogger, currentFeeCredit: Satoshi): LiquidityEvents.Decision { return when (this) { - is Disable -> LiquidityEvents.Rejected.Reason.PolicySetToDisabled + is Disable -> LiquidityEvents.Decision.Rejected(amount, fee, source, LiquidityEvents.Decision.Rejected.Reason.PolicySetToDisabled) is Auto -> { val maxAbsoluteFee = if (skipAbsoluteFeeCheck && source == LiquidityEvents.Source.OffChainPayment) Long.MAX_VALUE.msat else this.maxAbsoluteFee.toMilliSatoshi() - val maxRelativeFee = amount * maxRelativeFeeBasisPoints / 10_000 - logger.info { "liquidity policy check: fee=$fee maxAbsoluteFee=$maxAbsoluteFee maxRelativeFee=$maxRelativeFee policy=$this" } - if (fee > maxRelativeFee) { - LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(maxRelativeFeeBasisPoints) - } else if (fee > maxAbsoluteFee) { - LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(this.maxAbsoluteFee) - } else null + if (maxAllowedCredit == 0.sat || source == LiquidityEvents.Source.OnChainWallet) { + val maxRelativeFee = amount * maxRelativeFeeBasisPoints / 10_000 + logger.info { "auto liquidity policy check: amount=$amount fee=$fee maxAbsoluteFee=$maxAbsoluteFee maxRelativeFee=$maxRelativeFee policy=$this" } + if (fee > maxRelativeFee) { + LiquidityEvents.Decision.Rejected(amount, fee, source, LiquidityEvents.Decision.Rejected.Reason.TooExpensive.OverRelativeFee(maxRelativeFeeBasisPoints)) + } else if (fee > maxAbsoluteFee) { + LiquidityEvents.Decision.Rejected(amount, fee, source, LiquidityEvents.Decision.Rejected.Reason.TooExpensive.OverAbsoluteFee(this.maxAbsoluteFee)) + } else LiquidityEvents.Decision.Accepted(amount, fee, source) + } else { + logger.info { "fee-credit liquidity policy check: amount=$amount fee=$fee maxAbsoluteFee=$maxAbsoluteFee currentFeeCredit=$currentFeeCredit maxAllowedCredit=$maxAllowedCredit policy=$this" } + // NB: we do check the max absolute fee, but will never raise an explicit error for it, because the payment will either be added to fee credit or rejected due to exceeding the + // max allowed credit + if (fee <= maxAbsoluteFee && fee < (amount + currentFeeCredit.toMilliSatoshi())) { + LiquidityEvents.Decision.Accepted(amount, fee, source) + } else if ((amount + currentFeeCredit.toMilliSatoshi()) > maxAllowedCredit.toMilliSatoshi()) { + LiquidityEvents.Decision.Rejected(amount, fee, source, LiquidityEvents.Decision.Rejected.Reason.OverMaxCredit(maxAllowedCredit)) + } else { + LiquidityEvents.Decision.AddedToFeeCredit(amount, fee, source) + } + } } - }?.let { reason -> LiquidityEvents.Rejected(amount, fee, source, reason) } + } } } \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index 48db001c1..dd3b98138 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -87,6 +87,8 @@ interface LightningMessage { DNSAddressResponse.type -> DNSAddressResponse.read(stream) PhoenixAndroidLegacyInfo.type -> PhoenixAndroidLegacyInfo.read(stream) PleaseOpenChannel.type -> PleaseOpenChannel.read(stream) + CurrentFeeCredit.type -> CurrentFeeCredit.read(stream) + AutoLiquidityParams.type -> AutoLiquidityParams.read(stream) Stfu.type -> Stfu.read(stream) SpliceInit.type -> SpliceInit.read(stream) SpliceAck.type -> SpliceAck.read(stream) @@ -1671,7 +1673,7 @@ data class PayToOpenResponse(override val chainHash: BlockHash, val paymentHash: sealed class Result { // @formatter:off - data class Success(val paymentPreimage: ByteVector32) : Result() + data class Success(val paymentPreimage: ByteVector32, val addToFeeCredit: Boolean = false) : Result() /** reason is an onion-encrypted failure message, like those in UpdateFailHtlc */ data class Failure(val reason: ByteVector?) : Result() // @formatter:on @@ -1681,7 +1683,10 @@ data class PayToOpenResponse(override val chainHash: BlockHash, val paymentHash: LightningCodecs.writeBytes(chainHash.value, out) LightningCodecs.writeBytes(paymentHash, out) when (result) { - is Result.Success -> LightningCodecs.writeBytes(result.paymentPreimage, out) + is Result.Success -> { + LightningCodecs.writeBytes(result.paymentPreimage, out) + LightningCodecs.writeByte(if (result.addToFeeCredit) 0xff else 0, out) + } is Result.Failure -> { LightningCodecs.writeBytes(ByteVector32.Zeroes, out) // this is for backward compatibility result.reason?.let { @@ -1704,7 +1709,10 @@ data class PayToOpenResponse(override val chainHash: BlockHash, val paymentHash: PayToOpenResponse(chainHash, paymentHash, Result.Failure(failure)) } - else -> PayToOpenResponse(chainHash, paymentHash, Result.Success(preimage)) + else -> { + val addToFeeCredit = LightningCodecs.byte(input) != 0 + PayToOpenResponse(chainHash, paymentHash, Result.Success(preimage, addToFeeCredit)) + } } } } @@ -1853,6 +1861,40 @@ data class PleaseOpenChannel( } } +data class CurrentFeeCredit(val amount: Satoshi) : LightningMessage { + + override val type: Long get() = FCMToken.type + + override fun write(out: Output) { + LightningCodecs.writeU64(amount.toLong(), out) + } + + companion object : LightningMessageReader { + const val type: Long = 36003 + + override fun read(input: Input): CurrentFeeCredit { + return CurrentFeeCredit(LightningCodecs.u64(input).sat) + } + } +} + +data class AutoLiquidityParams(val amount: Satoshi) : LightningMessage { + + override val type: Long get() = AutoLiquidityParams.type + + override fun write(out: Output) { + LightningCodecs.writeU64(amount.toLong(), out) + } + + companion object : LightningMessageReader { + const val type: Long = 36005 + + override fun read(input: Input): AutoLiquidityParams { + return AutoLiquidityParams(LightningCodecs.u64(input).sat) + } + } +} + data class UnknownMessage(override val type: Long) : LightningMessage { override fun write(out: Output) = TODO("Serialization of unknown messages is not implemented") } \ 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 718d09770..c6361c3f3 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt @@ -1,7 +1,7 @@ package fr.acinq.lightning.payment import fr.acinq.lightning.LiquidityEvents -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat @@ -16,42 +16,46 @@ class LiquidityPolicyTestsCommon : LightningTestSuite() { @Test fun `policy rejection`() { - val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false) + val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false, maxAllowedCredit = 0.sat) // 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 + expected = LiquidityEvents.Decision.Rejected.Reason.TooExpensive.OverRelativeFee(policy.maxRelativeFeeBasisPoints), + actual = (policy.maybeReject(amount = 4_000_000.msat, fee = 3_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger, currentFeeCredit = 0.sat) as? LiquidityEvents.Decision.Rejected)?.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 + expected = LiquidityEvents.Decision.Rejected.Reason.TooExpensive.OverAbsoluteFee(policy.maxAbsoluteFee), + actual = (policy.maybeReject(amount = 15_000_000.msat, fee = 3_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger, currentFeeCredit = 0.sat) as? LiquidityEvents.Decision.Rejected)?.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 + expected = LiquidityEvents.Decision.Rejected.Reason.TooExpensive.OverRelativeFee(policy.maxRelativeFeeBasisPoints), + actual = (policy.maybeReject(amount = 4_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger, currentFeeCredit = 0.sat) as? LiquidityEvents.Decision.Rejected)?.reason ) - assertNull(policy.maybeReject(amount = 10_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)) + assertEquals( + expected = LiquidityEvents.Decision.Accepted(amount = 10_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment), + actual = policy.maybeReject(amount = 10_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger, currentFeeCredit = 0.sat)) } @Test fun `policy rejection skip absolute check`() { - val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 1_000.sat, maxRelativeFeeBasisPoints = 5_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = true) + val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 1_000.sat, maxRelativeFeeBasisPoints = 5_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = true, maxAllowedCredit = 0.sat) // 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)) + assertEquals( + expected = LiquidityEvents.Decision.Accepted(amount = 4_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment), + actual = policy.maybeReject(amount = 4_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger, currentFeeCredit = 0.sat)) // 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 + expected = LiquidityEvents.Decision.Rejected.Reason.TooExpensive.OverAbsoluteFee(policy.maxAbsoluteFee), + actual = (policy.maybeReject(amount = 4_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OnChainWallet, logger, currentFeeCredit = 0.sat) as? LiquidityEvents.Decision.Rejected)?.reason ) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt index 1c9706096..ed2112f1b 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -760,7 +760,7 @@ class LightningCodecsTestsCommon : LightningTestSuite() { // @formatter:off PayToOpenRequest(Block.LivenetGenesisBlock.hash, 5_000.msat, 10.sat, preimage.sha256(), 100, onionPacket, 1_000_000.sat) to Hex.decode("88cd 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 0000000000000000 0000000000001388 0000000000000000 000000000000000a e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 00000064 0005 000209be9bd1016d73fc1f611c6f8fdccd99ffb0885594e96156a268ee9afd35559c0102030405e0a0d5be2ca6faafa03880258e4af33a0d15aa950ab738c88566a471bf3bb14f 00000000000f4240"), PayToOpenRequest(Block.LivenetGenesisBlock.hash, 5_000.msat, 10.sat, preimage.sha256(), 100, onionPacket, 0.sat, TlvStream(PayToOpenRequestTlv.Blinding(blinding))) to Hex.decode("88cd 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 0000000000000000 0000000000001388 0000000000000000 000000000000000a e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 00000064 0005 000209be9bd1016d73fc1f611c6f8fdccd99ffb0885594e96156a268ee9afd35559c0102030405e0a0d5be2ca6faafa03880258e4af33a0d15aa950ab738c88566a471bf3bb14f 0000000000000000 0021033da8b63fd839472b49935127072039e65d8f99d4603f14d79cdf74b59f895721"), - PayToOpenResponse(Block.LivenetGenesisBlock.hash, preimage.sha256(), PayToOpenResponse.Result.Success(preimage)) to Hex.decode("88bb 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 339770785632e71fe1f4b48b8b90d14af94a2a3a2c70af66f2156ed8a150f795"), + PayToOpenResponse(Block.LivenetGenesisBlock.hash, preimage.sha256(), PayToOpenResponse.Result.Success(preimage)) to Hex.decode("88bb 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 339770785632e71fe1f4b48b8b90d14af94a2a3a2c70af66f2156ed8a150f795 00"), PayToOpenResponse(Block.LivenetGenesisBlock.hash, preimage.sha256(), PayToOpenResponse.Result.Failure(null)) to Hex.decode("88bb 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 0000000000000000000000000000000000000000000000000000000000000000"), PayToOpenResponse(Block.LivenetGenesisBlock.hash, preimage.sha256(), PayToOpenResponse.Result.Failure(ByteVector("deadbeef"))) to Hex.decode("88bb 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 0000000000000000000000000000000000000000000000000000000000000000 0004deadbeef"), // @formatter:on