diff --git a/src/commonMain/kotlin/fr/acinq/lightning/Features.kt b/src/commonMain/kotlin/fr/acinq/lightning/Features.kt index 90c98d16a..3f834bc8f 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/Features.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/Features.kt @@ -263,6 +263,13 @@ sealed class Feature { override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) } + @Serializable + object FundingFeeCredit : Feature() { + override val rfcName get() = "funding_fee_credit" + override val mandatory get() = 562 + override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) + } + } @Serializable @@ -345,7 +352,8 @@ data class Features(val activated: Map, val unknown: Se Feature.ChannelBackupClient, Feature.ChannelBackupProvider, Feature.ExperimentalSplice, - Feature.OnTheFlyFunding + Feature.OnTheFlyFunding, + Feature.FundingFeeCredit ) operator fun invoke(bytes: ByteVector): Features = invoke(bytes.toByteArray()) @@ -378,7 +386,8 @@ data class Features(val activated: Map, val unknown: Se Feature.AnchorOutputs to listOf(Feature.StaticRemoteKey), Feature.TrampolinePayment to listOf(Feature.PaymentSecret), Feature.ExperimentalTrampolinePayment to listOf(Feature.PaymentSecret), - Feature.OnTheFlyFunding to listOf(Feature.ExperimentalSplice) + Feature.OnTheFlyFunding to listOf(Feature.ExperimentalSplice), + Feature.FundingFeeCredit to listOf(Feature.OnTheFlyFunding) ) class FeatureException(message: String) : IllegalArgumentException(message) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt index 12c9c0401..9af4b4ac1 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt @@ -1075,6 +1075,8 @@ data class InteractiveTxSigningSession( // Fees will be paid later, from relayed HTLCs. is LiquidityAds.PaymentDetails.FromFutureHtlc -> 0.msat is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> 0.msat + // Fees are taken from the current fee credit. + is LiquidityAds.PaymentDetails.FromFeeCredit -> 0.msat } } ?: 0.msat return Helpers.Funding.makeCommitTxs( diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt index fb9e2ed9c..854f50638 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt @@ -862,6 +862,8 @@ data class Normal( // Fees don't need to be paid during the splice, they will be deducted from relayed HTLCs. is LiquidityAds.PaymentDetails.FromFutureHtlc -> true is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> true + // Fees don't need to be paid during the splice, they are taken from our fee credit. + is LiquidityAds.PaymentDetails.FromFeeCredit -> true } } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt index a2568592a..1ccc5bacd 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt @@ -175,6 +175,15 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r override val fees: MilliSatoshi = fundingFee?.amount ?: 0.msat } + /** + * Payment was added to our fee credit for future on-chain operations (see [Feature.FundingFeeCredit]). + * We didn't really receive this amount yet, but we trust our peer to use it for future on-chain operations. + */ + data class AddedToFeeCredit(override val amount: MilliSatoshi) : ReceivedWith() { + // Adding to the fee credit doesn't cost any fees. + override val fees: MilliSatoshi = 0.msat + } + 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 65ad576c9..6640e8b39 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -211,6 +211,7 @@ class Peer( val currentTipFlow = MutableStateFlow(null) val onChainFeeratesFlow = MutableStateFlow(null) val peerFeeratesFlow = MutableStateFlow(null) + val feeCreditFlow = MutableStateFlow(0.msat) private val _channelLogger = nodeParams.loggerFactory.newLogger(ChannelState::class) private suspend fun ChannelState.process(cmd: ChannelCommand): Pair> { @@ -720,9 +721,13 @@ class Peer( peerConnection?.send(message) } - /** Return true if we are currently funding a channel. */ + /** + * Return true if we are currently funding a channel. + * Note that we also return true if we haven't yet received the remote [TxSignatures] for the latest splice transaction. + * Since our peer sends [CurrentFeeCredit] before [TxSignatures], this ensures that we never over-estimate our fee credit when initiating a funding flow. + */ private fun channelFundingIsInProgress(): Boolean = when (val channel = _channels.values.firstOrNull { it is Normal }) { - is Normal -> channel.spliceStatus != SpliceStatus.None + is Normal -> channel.spliceStatus != SpliceStatus.None || channel.commitments.latest.localFundingStatus.signedTx == null else -> _channels.values.any { it is WaitForAcceptChannel || it is WaitForFundingCreated || it is WaitForFundingSigned || it is WaitForFundingConfirmed || it is WaitForChannelReady } } @@ -865,9 +870,10 @@ class Peer( private suspend fun processIncomingPayment(item: Either) { val currentBlockHeight = currentTipFlow.filterNotNull().first() val currentFeerate = peerFeeratesFlow.filterNotNull().first().fundingFeerate + val currentFeeCredit = feeCreditFlow.first() val result = when (item) { - is Either.Right -> incomingPaymentHandler.process(item.value, currentBlockHeight, currentFeerate) - is Either.Left -> incomingPaymentHandler.process(item.value, currentBlockHeight, currentFeerate) + is Either.Right -> incomingPaymentHandler.process(item.value, currentBlockHeight, currentFeerate, currentFeeCredit) + is Either.Left -> incomingPaymentHandler.process(item.value, currentBlockHeight, currentFeerate, currentFeeCredit) } when (result) { is IncomingPaymentHandler.ProcessAddResult.Accepted -> { @@ -973,6 +979,12 @@ class Peer( } } } + is CurrentFeeCredit -> { + when { + nodeParams.features.hasFeature(Feature.FundingFeeCredit) -> feeCreditFlow.value = msg.amount + else -> {} + } + } is Ping -> { val pong = Pong(ByteVector(ByteArray(msg.pongLength))) peerConnection?.send(pong) @@ -1276,6 +1288,7 @@ class Peer( is OpenOrSplicePayment -> { val channel = channels.values.firstOrNull { it is Normal } val currentFeerates = peerFeeratesFlow.filterNotNull().first() + val currentFeeCredit = feeCreditFlow.first().truncateToSatoshi() when { channelFundingIsInProgress() -> { // Once the channel funding is complete, we may have enough inbound liquidity to receive the payment without an on-chain operation @@ -1303,8 +1316,9 @@ class Peer( // We must cover the shared input and the shared output, which is a lot of weight, so we add 50%. else -> fundingFeerate * 1.5 } - // We cannot pay the liquidity fees from our channel balance, so we fall back to future HTLCs. + // We cannot pay the liquidity fees from our channel balance, so we fall back to future HTLCs or fee credit. val paymentDetails = when { + remoteFundingRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFeeCredit) && cmd.leaseFees(targetFeerate).total <= currentFeeCredit -> LiquidityAds.PaymentDetails.FromFeeCredit remoteFundingRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlc) -> LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(cmd.paymentHash)) remoteFundingRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlcWithPreimage) -> LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(listOf(cmd.preimage)) else -> null @@ -1347,8 +1361,9 @@ class Peer( // We don't pay any local on-chain fees, our fee is only for the liquidity lease. val leaseFees = cmd.leaseFees(fundingFeerate) val totalFees = TransactionFees(miningFee = leaseFees.miningFee, serviceFee = leaseFees.serviceFee) - // We cannot pay the liquidity fees from our channel balance, so we fall back to future HTLCs. + // We cannot pay the liquidity fees from our channel balance, so we fall back to future HTLCs or fee credit. val paymentDetails = when { + remoteFundingRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFeeCredit) && leaseFees.total <= currentFeeCredit -> LiquidityAds.PaymentDetails.FromFeeCredit remoteFundingRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlc) -> LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(cmd.paymentHash)) remoteFundingRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlcWithPreimage) -> LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(listOf(cmd.preimage)) else -> null diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index 8adfd8172..09fac0ba4 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -150,22 +150,26 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri } /** Process an incoming htlc. Before calling this, the htlc must be committed and ack-ed by both peers. */ - suspend fun process(htlc: UpdateAddHtlc, currentBlockHeight: Int, currentFeerate: FeeratePerKw): ProcessAddResult = process(Either.Right(htlc), currentBlockHeight, currentFeerate) + suspend fun process(htlc: UpdateAddHtlc, currentBlockHeight: Int, currentFeerate: FeeratePerKw, currentFeeCredit: MilliSatoshi = 0.msat): ProcessAddResult { + return process(Either.Right(htlc), currentBlockHeight, currentFeerate, currentFeeCredit) + } /** Process an incoming on-the-fly funding request. */ - suspend fun process(htlc: WillAddHtlc, currentBlockHeight: Int, currentFeerate: FeeratePerKw): ProcessAddResult = process(Either.Left(htlc), currentBlockHeight, currentFeerate) + suspend fun process(htlc: WillAddHtlc, currentBlockHeight: Int, currentFeerate: FeeratePerKw, currentFeeCredit: MilliSatoshi = 0.msat): ProcessAddResult { + return process(Either.Left(htlc), currentBlockHeight, currentFeerate, currentFeeCredit) + } - private suspend fun process(htlc: Either, currentBlockHeight: Int, currentFeerate: FeeratePerKw): ProcessAddResult { + private suspend fun process(htlc: Either, currentBlockHeight: Int, currentFeerate: FeeratePerKw, currentFeeCredit: MilliSatoshi): ProcessAddResult { // There are several checks we could perform *before* decrypting the onion. // But we need to carefully handle which error message is returned to prevent information leakage, so we always peel the onion first. return when (val res = toPaymentPart(privateKey, htlc)) { is Either.Left -> res.value - is Either.Right -> processPaymentPart(res.value, currentBlockHeight, currentFeerate) + is Either.Right -> processPaymentPart(res.value, currentBlockHeight, currentFeerate, currentFeeCredit) } } /** Main payment processing, that handles payment parts. */ - private suspend fun processPaymentPart(paymentPart: PaymentPart, currentBlockHeight: Int, currentFeerate: FeeratePerKw): ProcessAddResult { + private suspend fun processPaymentPart(paymentPart: PaymentPart, currentBlockHeight: Int, currentFeerate: FeeratePerKw, currentFeeCredit: MilliSatoshi): ProcessAddResult { val logger = MDCLogger(logger.logger, staticMdc = paymentPart.mdc()) when (paymentPart) { is HtlcPart -> logger.info { "processing htlc part expiry=${paymentPart.htlc.cltvExpiry}" } @@ -231,33 +235,62 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri val willAddHtlcParts = payment.parts.filterIsInstance() when { willAddHtlcParts.isNotEmpty() -> when (val result = validateOnTheFlyFundingRate(willAddHtlcParts.map { it.amount }.sum(), currentFeerate)) { - is Either.Left -> { - logger.warning { "rejecting on-the-fly funding: reason=${result.value.reason}" } - nodeParams._nodeEvents.emit(result.value) - pending.remove(paymentPart.paymentHash) - val actions = payment.parts.map { part -> - when (part) { - is HtlcPart -> actionForFailureMessage(TemporaryNodeFailure, part.htlc) - is WillAddHtlcPart -> actionForWillAddHtlcFailure(nodeParams.nodePrivateKey, TemporaryNodeFailure, part.htlc) - } - } - ProcessAddResult.Rejected(actions, incomingPayment) - } + is Either.Left -> rejectOnTheFlyFunding(payment, incomingPayment, result.value) is Either.Right -> { val (requestedAmount, fundingRate) = result.value - val actions = listOf(OpenOrSplicePayment(payment.amountReceived, requestedAmount, fundingRate, incomingPayment.preimage)) - val paymentOnlyHtlcs = payment.copy( - // We need to splice before receiving the remaining HTLC parts. - // We extend the duration of the MPP timeout to give more time for funding to complete. - startedAtSeconds = payment.startedAtSeconds + 30, - // We keep the currently added HTLCs, and should receive the remaining HTLCs after the open/splice. - parts = htlcParts.toSet() - ) + val addToFeeCredit = run { + val featureOk = nodeParams.features.hasFeature(Feature.FundingFeeCredit) + val remoteNodeOk = remoteFundingRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFeeCredit) + // We use an arbitrary threshold that is higher than just the current liquidity fees. + // This reduces the frequency of on-chain operations for payments that are about the size of the fees. + // It also ensures that if we end up splicing with a higher feerate because we have unconfirmed parent + // transactions, we will have enough fee credit to cover this higher feerate. + val feeCreditThreshold = fundingRate.fees(currentFeerate, requestedAmount, requestedAmount).total * 5 + val amountBelowThreshold = (payment.amountReceived + currentFeeCredit).truncateToSatoshi() < feeCreditThreshold + featureOk && remoteNodeOk && amountBelowThreshold + } when { - paymentOnlyHtlcs.parts.isNotEmpty() -> pending[paymentPart.paymentHash] = paymentOnlyHtlcs - else -> pending.remove(paymentPart.paymentHash) + addToFeeCredit -> { + logger.info { "adding on-the-fly funding to fee credit (amount=${willAddHtlcParts.map { it.amount }.sum()})" } + val receivedWith = buildList { + htlcParts.forEach { add(IncomingPayment.ReceivedWith.LightningPayment(it.amount, it.htlc.channelId, it.htlc.id, it.htlc.fundingFee)) } + willAddHtlcParts.forEach { add(IncomingPayment.ReceivedWith.AddedToFeeCredit(it.amount)) } + } + val actions = buildList { + // We send a single add_fee_credit for the will_add_htlc set. + add(SendOnTheFlyFundingMessage(AddFeeCredit(nodeParams.chainHash, incomingPayment.preimage))) + htlcParts.forEach { add(WrappedChannelCommand(it.htlc.channelId, ChannelCommand.Htlc.Settlement.Fulfill(it.htlc.id, incomingPayment.preimage, true))) } + } + acceptPayment(incomingPayment, receivedWith, actions) + } + 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. + when (val rejected = nodeParams.liquidityPolicy.value.maybeReject( + requestedAmount.toMilliSatoshi(), + fundingRate.fees(currentFeerate, requestedAmount, requestedAmount).total.toMilliSatoshi(), + LiquidityEvents.Source.OffChainPayment, + logger + )) { + is LiquidityEvents.Rejected -> rejectOnTheFlyFunding(payment, incomingPayment, rejected) + else -> { + val actions = listOf(OpenOrSplicePayment(payment.amountReceived, requestedAmount, fundingRate, incomingPayment.preimage)) + val paymentOnlyHtlcs = payment.copy( + // We need to splice before receiving the remaining HTLC parts. + // We extend the duration of the MPP timeout to give more time for funding to complete. + startedAtSeconds = payment.startedAtSeconds + 30, + // We keep the currently added HTLCs, and should receive the remaining HTLCs after the open/splice. + parts = htlcParts.toSet() + ) + when { + paymentOnlyHtlcs.parts.isNotEmpty() -> pending[paymentPart.paymentHash] = paymentOnlyHtlcs + else -> pending.remove(paymentPart.paymentHash) + } + ProcessAddResult.Pending(incomingPayment, paymentOnlyHtlcs, actions) + } + } + } } - ProcessAddResult.Pending(incomingPayment, paymentOnlyHtlcs, actions) } } else -> when (val fundingFee = validateFundingFee(htlcParts)) { @@ -269,21 +302,12 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri ProcessAddResult.Rejected(actions, incomingPayment) } is Either.Right -> { - pending.remove(paymentPart.paymentHash) val receivedWith = htlcParts.map { part -> IncomingPayment.ReceivedWith.LightningPayment(part.amount, part.htlc.channelId, part.htlc.id, part.htlc.fundingFee) } - val received = IncomingPayment.Received(receivedWith = receivedWith) val actions = htlcParts.map { part -> val cmd = ChannelCommand.Htlc.Settlement.Fulfill(part.htlc.id, incomingPayment.preimage, true) WrappedChannelCommand(part.htlc.channelId, cmd) } - if (incomingPayment.origin is IncomingPayment.Origin.Offer) { - // We didn't store the Bolt 12 invoice in our DB when receiving the invoice_request (to protect against DoS). - // We need to create the DB entry now otherwise the payment won't be recorded. - db.addIncomingPayment(incomingPayment.preimage, incomingPayment.origin) - } - db.receivePayment(paymentPart.paymentHash, received.receivedWith) - nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(paymentPart.paymentHash, received.receivedWith)) - ProcessAddResult.Accepted(actions, incomingPayment.copy(received = received), received) + acceptPayment(incomingPayment, receivedWith, actions) } } } @@ -307,6 +331,9 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri else -> { val fees = fundingRate.fees(currentFeerate, requestedAmount, requestedAmount).total val rejected = when { + // We never reject if we can use the fee credit feature. + // We instead add payments to our fee credit until making an on-chain operation becomes acceptable. + nodeParams.features.hasFeature(Feature.FundingFeeCredit) && remoteFundingRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFeeCredit) -> 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 < fees * 2 -> LiquidityEvents.Rejected( @@ -327,6 +354,19 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri } } + private suspend fun rejectOnTheFlyFunding(payment: PendingPayment, incomingPayment: IncomingPayment, rejected: LiquidityEvents.Rejected): ProcessAddResult.Rejected { + logger.warning { "rejecting on-the-fly funding: reason=${rejected.reason}" } + pending.remove(incomingPayment.paymentHash) + nodeParams._nodeEvents.emit(rejected) + val actions = payment.parts.map { part -> + when (part) { + is HtlcPart -> actionForFailureMessage(TemporaryNodeFailure, part.htlc) + is WillAddHtlcPart -> actionForWillAddHtlcFailure(nodeParams.nodePrivateKey, TemporaryNodeFailure, part.htlc) + } + } + return ProcessAddResult.Rejected(actions, incomingPayment) + } + private suspend fun validateFundingFee(parts: List): Either { return when (val fundingTxId = parts.map { it.htlc.fundingFee?.fundingTxId }.firstOrNull()) { is TxId -> { @@ -340,6 +380,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri is LiquidityAds.PaymentDetails.FromFutureHtlc -> details.paymentHashes.contains(paymentHash) is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> details.preimages.any { Crypto.sha256(it).byteVector32() == paymentHash } is LiquidityAds.PaymentDetails.FromChannelBalance -> false + is LiquidityAds.PaymentDetails.FromFeeCredit -> false } val feeAmountOk = fundingFee <= lease.fees.total.toMilliSatoshi() when { @@ -353,6 +394,19 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri } } + private suspend fun acceptPayment(incomingPayment: IncomingPayment, receivedWith: List, actions: List): ProcessAddResult.Accepted { + pending.remove(incomingPayment.paymentHash) + if (incomingPayment.origin is IncomingPayment.Origin.Offer) { + // We didn't store the Bolt 12 invoice in our DB when receiving the invoice_request (to protect against DoS). + // We need to create the DB entry now otherwise the payment won't be recorded. + db.addIncomingPayment(incomingPayment.preimage, incomingPayment.origin) + } + db.receivePayment(incomingPayment.paymentHash, receivedWith) + nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(incomingPayment.paymentHash, receivedWith)) + val received = IncomingPayment.Received(receivedWith) + return ProcessAddResult.Accepted(actions, incomingPayment.copy(received = received), received) + } + private suspend fun validatePaymentPart(paymentPart: PaymentPart, currentBlockHeight: Int): Either { val logger = MDCLogger(logger.logger, staticMdc = paymentPart.mdc()) when (val finalPayload = paymentPart.finalPayload) { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt index f99b40820..739ecb41e 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt @@ -419,6 +419,7 @@ object Deserialization { 0x00 -> LiquidityAds.PaymentDetails.FromChannelBalance 0x80 -> LiquidityAds.PaymentDetails.FromFutureHtlc(readCollection { readByteVector32() }.toList()) 0x81 -> LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(readCollection { readByteVector32() }.toList()) + 0x82 -> LiquidityAds.PaymentDetails.FromFeeCredit else -> error("unknown discriminator $paymentDetailsDiscriminator for class ${LiquidityAds.PaymentDetails::class}") }, sellerSig = readByteVector64(), diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt index 9938ed4ff..c38fff461 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt @@ -425,6 +425,7 @@ object Serialization { write(0x81) writeCollection(lease.paymentDetails.preimages) { writeByteVector32(it) } } + is LiquidityAds.PaymentDetails.FromFeeCredit -> write(0x82) } writeByteVector64(lease.sellerSig) when (lease.witness) { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt index acae643ba..30dd3d434 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt @@ -3,6 +3,7 @@ package fr.acinq.lightning.wire import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.ByteVector64 import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.byteVector32 import fr.acinq.bitcoin.io.Input import fr.acinq.bitcoin.io.Output @@ -62,3 +63,24 @@ sealed class InitTlv : Tlv { } } } + +sealed class CurrentFeeCreditTlv : Tlv { + /** Latest payments that were added to fee credit. */ + data class LatestPayments(val preimages: List) : CurrentFeeCreditTlv() { + override val tag: Long get() = LatestPayments.tag + + override fun write(out: Output) { + preimages.forEach { LightningCodecs.writeBytes(it, out) } + } + + companion object : TlvValueReader { + const val tag: Long = 1 + + override fun read(input: Input): LatestPayments { + val count = input.availableBytes / 32 + val preimages = (0 until count).map { LightningCodecs.bytes(input, 32).byteVector32() } + return LatestPayments(preimages) + } + } + } +} \ 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 e212e7780..a992e0da8 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -81,6 +81,8 @@ interface LightningMessage { WillFailHtlc.type -> WillFailHtlc.read(stream) WillFailMalformedHtlc.type -> WillFailMalformedHtlc.read(stream) CancelOnTheFlyFunding.type -> CancelOnTheFlyFunding.read(stream) + AddFeeCredit.type -> AddFeeCredit.read(stream) + CurrentFeeCredit.type -> CurrentFeeCredit.read(stream) FCMToken.type -> FCMToken.read(stream) UnsetFCMToken.type -> UnsetFCMToken PhoenixAndroidLegacyInfo.type -> PhoenixAndroidLegacyInfo.read(stream) @@ -1770,6 +1772,60 @@ data class CancelOnTheFlyFunding(override val channelId: ByteVector32, val payme } } +/** + * This message is used to reveal the preimage of a small payment for which it isn't economical to perform an on-chain + * transaction. The amount of the payment will be added to our fee credit, which can be used when a future on-chain + * transaction is needed. This message requires the [Feature.FundingFeeCredit] feature. + */ +data class AddFeeCredit(override val chainHash: BlockHash, val preimage: ByteVector32) : HasChainHash, OnTheFlyFundingMessage { + override val type: Long = AddFeeCredit.type + + override fun write(out: Output) { + LightningCodecs.writeBytes(chainHash.value, out) + LightningCodecs.writeBytes(preimage, out) + } + + companion object : LightningMessageReader { + const val type: Long = 41045 + + override fun read(input: Input): AddFeeCredit = AddFeeCredit( + chainHash = BlockHash(LightningCodecs.bytes(input, 32)), + preimage = LightningCodecs.bytes(input, 32).byteVector32() + ) + } +} + +/** This message contains our current fee credit: our peer is the source of truth for that value. */ +data class CurrentFeeCredit(override val chainHash: BlockHash, val amount: MilliSatoshi, val tlvStream: TlvStream = TlvStream.empty()) : HasChainHash, OnTheFlyFundingMessage { + constructor(chainHash: BlockHash, amount: MilliSatoshi, preimages: List) : this(chainHash, amount, if (preimages.isNotEmpty()) TlvStream(CurrentFeeCreditTlv.LatestPayments(preimages)) else TlvStream.empty()) + + override val type: Long = CurrentFeeCredit.type + + val latestPaymentPreimages: List = tlvStream.get()?.preimages ?: listOf() + val latestPaymentHashes: List = latestPaymentPreimages.map { Crypto.sha256(it).byteVector32() } + + override fun write(out: Output) { + LightningCodecs.writeBytes(chainHash.value, out) + LightningCodecs.writeU64(amount.toLong(), out) + TlvStreamSerializer(false, readers).write(tlvStream, out) + } + + companion object : LightningMessageReader { + const val type: Long = 41046 + + @Suppress("UNCHECKED_CAST") + val readers = mapOf( + CurrentFeeCreditTlv.LatestPayments.tag to CurrentFeeCreditTlv.LatestPayments.Companion as TlvValueReader, + ) + + override fun read(input: Input): CurrentFeeCredit = CurrentFeeCredit( + chainHash = BlockHash(LightningCodecs.bytes(input, 32)), + amount = LightningCodecs.u64(input).msat, + tlvStream = TlvStreamSerializer(false, readers).read(input) + ) + } +} + data class FCMToken(val token: ByteVector) : LightningMessage { constructor(token: String) : this(ByteVector(token.encodeToByteArray())) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt index c82e81756..c835fa4b7 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt @@ -150,6 +150,8 @@ object LiquidityAds { data object FromFutureHtlc : PaymentType() /** Fees will be deducted from future HTLCs that will be relayed to the buyer, but the preimage is revealed immediately. */ data object FromFutureHtlcWithPreimage : PaymentType() + /** Fees will be taken from the buyer's current fee credit. */ + data object FromFeeCredit : PaymentType() /** Sellers may support unknown payment types, which we must ignore. */ data class Unknown(val bitIndex: Int) : PaymentType() @@ -160,6 +162,7 @@ object LiquidityAds { is FromChannelBalance -> 0 is FromFutureHtlc -> 128 is FromFutureHtlcWithPreimage -> 129 + is FromFeeCredit -> 130 is Unknown -> it.bitIndex } } @@ -174,6 +177,7 @@ object LiquidityAds { it.value && it.index == 0 -> FromChannelBalance it.value && it.index == 128 -> FromFutureHtlc it.value && it.index == 129 -> FromFutureHtlcWithPreimage + it.value && it.index == 130 -> FromFeeCredit it.value -> Unknown(it.index) else -> null } @@ -190,6 +194,7 @@ object LiquidityAds { data object FromChannelBalance : PaymentDetails() { override val paymentType: PaymentType = PaymentType.FromChannelBalance } data class FromFutureHtlc(val paymentHashes: List) : PaymentDetails() { override val paymentType: PaymentType = PaymentType.FromFutureHtlc } data class FromFutureHtlcWithPreimage(val preimages: List) : PaymentDetails() { override val paymentType: PaymentType = PaymentType.FromFutureHtlcWithPreimage } + data object FromFeeCredit : PaymentDetails() { override val paymentType: PaymentType = PaymentType.FromFeeCredit } // @formatter:on fun write(out: Output) = when (this) { @@ -207,6 +212,10 @@ object LiquidityAds { LightningCodecs.writeBigSize(32 * preimages.size.toLong(), out) // length preimages.forEach { LightningCodecs.writeBytes(it, out) } } + is FromFeeCredit -> { + LightningCodecs.writeBigSize(130, out) // tag + LightningCodecs.writeBigSize(0, out) // length + } } companion object { @@ -225,6 +234,10 @@ object LiquidityAds { val preimages = (0 until count).map { LightningCodecs.bytes(input, 32).byteVector32() } FromFutureHtlcWithPreimage(preimages) } + 130L -> { + require(LightningCodecs.bigSize(input) == 0L) { "invalid length for from_fee_credit payment details" } + FromFeeCredit + } else -> throw IllegalArgumentException("unknown payment details (tag=$tag)") } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt index 0da360909..56be48d88 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt @@ -559,6 +559,116 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } } + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `receive will_add_htlc added to fee credit`() = runSuspendTest { + val policy = LiquidityPolicy.Auto(inboundLiquidityTarget = 100_000.sat, maxAbsoluteFee = 500.sat, maxRelativeFeeBasisPoints = 1000, skipAbsoluteFeeCheck = false) + val totalAmount = 2500.msat + val testCases = listOf( + // We don't have any fee credit: we add the payment to our credit regardless of liquidity fees. + 0.msat to null, + // We have enough fee credit for an on-chain operation, but the fees are too high for our policy. + 20_000_000.msat to LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(500.sat) + ) + testCases.forEach { (currentFeeCredit, failure) -> + val (paymentHandler, incomingPayment, paymentSecret) = createFeeCreditFixture(totalAmount, policy) + paymentHandler.nodeParams._nodeEvents.resetReplayCache() + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(totalAmount, totalAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, currentFeeCredit) + when (failure) { + null -> { + assertIs(result) + assertEquals(listOf(SendOnTheFlyFundingMessage(AddFeeCredit(paymentHandler.nodeParams.chainHash, incomingPayment.preimage))), result.actions) + assertEquals(totalAmount, result.received.amount) + assertEquals(listOf(IncomingPayment.ReceivedWith.AddedToFeeCredit(totalAmount)), result.received.receivedWith) + checkDbPayment(result.incomingPayment, paymentHandler.db) + } + else -> { + assertIs(result) + assertEquals(1, result.actions.size) + val willFailHtlc = result.actions.filterIsInstance().firstOrNull()?.message + assertIs(willFailHtlc) + assertEquals(willAddHtlc.id, willFailHtlc.id) + val event = paymentHandler.nodeParams.nodeEvents.first() + assertIs(event) + assertEquals(event.reason, failure) + } + } + } + } + + @Test + fun `receive multipart payment with a mix of HTLC and will_add_htlc added to fee credit`() = runSuspendTest { + val channelId = randomBytes32() + val (amount1, amount2) = listOf(10_000.msat, 5_000.msat) + val totalAmount = amount1 + amount2 + val (paymentHandler, incomingPayment, paymentSecret) = createFeeCreditFixture(totalAmount, LiquidityPolicy.Auto(null, 50.sat, 100, skipAbsoluteFeeCheck = false)) + + // Step 1 of 2: + // - Alice sends a normal HTLC to Bob first + // - Bob doesn't accept the MPP set yet + run { + val htlc = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) + val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, currentFeeCredit = 0.msat) + assertIs(result) + assertTrue(result.actions.isEmpty()) + } + + // Step 2 of 2: + // - Alice sends will_add_htlc to Bob + // - Bob adds it to its fee credit and fulfills the HTLC + run { + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, currentFeeCredit = 0.msat) + assertIs(result) + val (expectedActions, expectedReceivedWith) = setOf( + // @formatter:off + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, incomingPayment.preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0, fundingFee = null), + SendOnTheFlyFundingMessage(AddFeeCredit(paymentHandler.nodeParams.chainHash, incomingPayment.preimage)) to IncomingPayment.ReceivedWith.AddedToFeeCredit(amount2), + // @formatter:on + ).unzip() + assertEquals(expectedActions.toSet(), result.actions.toSet()) + assertEquals(totalAmount, result.received.amount) + assertEquals(expectedReceivedWith, result.received.receivedWith) + checkDbPayment(result.incomingPayment, paymentHandler.db) + } + } + + @Test + fun `receive will_add_htlc with enough fee credit`() = runSuspendTest { + // This tiny HTLC wouldn't be accepted if we didn't have enough fee credit. + val totalAmount = 500.msat + val currentFeeCredit = 20_000_000.msat + val (paymentHandler, incomingPayment, paymentSecret) = createFeeCreditFixture(totalAmount, LiquidityPolicy.Auto(100_000.sat, 5000.sat, 1000, skipAbsoluteFeeCheck = false)) + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(totalAmount, totalAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, currentFeeCredit) + assertIs(result) + assertEquals(1, result.actions.size) + val openOrSplice = result.actions.first() + assertIs(openOrSplice) + assertEquals(totalAmount, openOrSplice.paymentAmount) + assertEquals(100_000.sat, openOrSplice.requestedAmount) + // We don't update the payments DB: we're waiting to receive HTLCs after the open/splice. + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } + + @Test + fun `receive will_add_htlc larger than fee credit threshold`() = runSuspendTest { + // Large payments shouldn't be added to fee credit. + val totalAmount = 20_000_000.msat + val (paymentHandler, incomingPayment, paymentSecret) = createFeeCreditFixture(totalAmount, LiquidityPolicy.Auto(100_000.sat, 5000.sat, 1000, skipAbsoluteFeeCheck = false)) + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(totalAmount, totalAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, currentFeeCredit = 100.msat) + assertIs(result) + assertEquals(1, result.actions.size) + val openOrSplice = result.actions.first() + assertIs(openOrSplice) + assertEquals(totalAmount, openOrSplice.paymentAmount) + assertEquals(120_000.sat, openOrSplice.requestedAmount) + // We don't update the payments DB: we're waiting to receive HTLCs after the open/splice. + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } + @Test fun `receive multipart payment with funding fee`() = runSuspendTest { val channelId = randomBytes32() @@ -1576,5 +1686,14 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val (incomingPayment, paymentSecret) = makeIncomingPayment(paymentHandler, invoiceAmount) return Triple(paymentHandler, incomingPayment, paymentSecret) } + + private suspend fun createFeeCreditFixture(invoiceAmount: MilliSatoshi, policy: LiquidityPolicy): Triple { + val nodeParams = TestConstants.Bob.nodeParams.copy(features = TestConstants.Bob.nodeParams.features.add(Feature.FundingFeeCredit to FeatureSupport.Optional)) + nodeParams.liquidityPolicy.emit(policy) + val fundingRates = TestConstants.fundingRates.copy(paymentTypes = TestConstants.fundingRates.paymentTypes + LiquidityAds.PaymentType.FromFeeCredit) + val paymentHandler = IncomingPaymentHandler(nodeParams, InMemoryPaymentsDb(), fundingRates) + val (incomingPayment, paymentSecret) = makeIncomingPayment(paymentHandler, invoiceAmount) + return Triple(paymentHandler, incomingPayment, paymentSecret) + } } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt index 0ee1d2a48..6d3355a7d 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -839,6 +839,29 @@ class LightningCodecsTestsCommon : LightningTestSuite() { } } + @Test + fun `encode - decode fee credit messages`() { + val preimages = listOf( + ByteVector32("6962570ba49642729d77020821f55a492f5df092f3777e75f9740e5b6efec08f"), + ByteVector32("4ad834d418faf74ebf7c8a026f2767a41c3a0995c334d7d3dab47737794b0c16") + ) + val testCases = listOf( + // @formatter:off + AddFeeCredit(Block.RegtestGenesisBlock.hash, preimages.first()) to Hex.decode("a055 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 6962570ba49642729d77020821f55a492f5df092f3777e75f9740e5b6efec08f"), + CurrentFeeCredit(Block.RegtestGenesisBlock.hash, 0.msat) to Hex.decode("a056 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 0000000000000000"), + CurrentFeeCredit(Block.RegtestGenesisBlock.hash, 20_000_000.msat) to Hex.decode("a056 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 0000000001312d00"), + CurrentFeeCredit(Block.RegtestGenesisBlock.hash, 25_000_000.msat, preimages) to Hex.decode("a056 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 00000000017d7840 01406962570ba49642729d77020821f55a492f5df092f3777e75f9740e5b6efec08f4ad834d418faf74ebf7c8a026f2767a41c3a0995c334d7d3dab47737794b0c16"), + // @formatter:on + ) + testCases.forEach { + val decoded = LightningMessage.decode(it.second) + assertNotNull(decoded) + assertEquals(it.first, decoded) + val encoded = LightningMessage.encode(decoded) + assertArrayEquals(it.second, encoded) + } + } + @Test fun `encode - decode phoenix-android-legacy-info messages`() { val testCases = listOf(