From 850f884f02da64e7c5b1e15393a61946946a9a24 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 4 Jul 2024 18:19:35 +0200 Subject: [PATCH] Add `funding_fee_credit` feature We add an optional feature that lets on-the-fly funding clients accept payments that are too small to pay the fees for an on-the-fly funding. When that happens, the payment amount is added as "fee credit" without performing an on-chain operation. Once enough fee credit has been obtained, we can initiate an on-chain operation to create a channel or a splice by paying part of the fees from our fee credit. This feature makes more efficient use of on-chain transactions by trusting that our peer will honor our fee credit in the future. The fee credit takes precedence over other ways of paying the fees (from our channel balance or future HTLCs), which guarantees that the fee credit eventually converges to 0. --- .../kotlin/fr/acinq/lightning/Features.kt | 13 +- .../acinq/lightning/channel/InteractiveTx.kt | 62 ++-- .../acinq/lightning/channel/states/Normal.kt | 26 +- .../channel/states/WaitForAcceptChannel.kt | 11 +- .../channel/states/WaitForFundingConfirmed.kt | 2 +- .../channel/states/WaitForOpenChannel.kt | 4 +- .../fr/acinq/lightning/db/PaymentsDb.kt | 9 + .../kotlin/fr/acinq/lightning/io/Peer.kt | 14 +- .../payment/IncomingPaymentHandler.kt | 105 +++++-- .../serialization/v4/Deserialization.kt | 22 +- .../serialization/v4/Serialization.kt | 41 ++- .../fr/acinq/lightning/wire/ChannelTlv.kt | 12 + .../acinq/lightning/wire/LightningMessages.kt | 48 +++ .../fr/acinq/lightning/wire/LiquidityAds.kt | 18 +- .../channel/InteractiveTxTestsCommon.kt | 59 +++- .../channel/states/SpliceTestsCommon.kt | 113 ++++--- .../IncomingPaymentHandlerTestsCommon.kt | 277 +++++++++++++----- .../OutgoingPaymentHandlerTestsCommon.kt | 4 +- .../wire/LightningCodecsTestsCommon.kt | 28 +- .../lightning/wire/LiquidityAdsTestsCommon.kt | 5 +- 20 files changed, 644 insertions(+), 229 deletions(-) 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 59c6946ff..89820822e 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt @@ -98,6 +98,20 @@ data class InteractiveTxParams( val fundingTxIndex = (sharedInput as? SharedFundingInput.Multisig2of2)?.let { it.fundingTxIndex + 1 } ?: 0 return Helpers.Funding.makeFundingPubKeyScript(channelKeys.fundingPubKey(fundingTxIndex), remoteFundingPubkey) } + + fun liquidityFees(purchase: LiquidityAds.Purchase?): MilliSatoshi = purchase?.let { l -> + val fees = when (l) { + is LiquidityAds.Purchase.Standard -> l.fees.total.toMilliSatoshi() + is LiquidityAds.Purchase.WithFeeCredit -> l.fees.total.toMilliSatoshi() - l.feeCreditUsed + } + when (l.paymentDetails) { + is LiquidityAds.PaymentDetails.FromChannelBalance -> if (isInitiator) fees else -fees + is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> if (isInitiator) fees else -fees + // Fees will be paid later, from relayed HTLCs. + is LiquidityAds.PaymentDetails.FromFutureHtlc -> 0.msat + is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> 0.msat + } + } ?: 0.msat } sealed class InteractiveTxInput { @@ -209,7 +223,12 @@ sealed class InteractiveTxOutput { */ data class Remote(override val serialId: Long, override val amount: Satoshi, override val pubkeyScript: ByteVector) : InteractiveTxOutput(), Incoming - /** The shared output can be added by us or by our peer, depending on who initiated the protocol. */ + /** + * The shared output can be added by us or by our peer, depending on who initiated the protocol. + * + * @param localAmount amount contributed by us, before applying push_amount and (optional) liquidity fees: this is different from the channel balance. + * @param remoteAmount amount contributed by our peer, before applying push_amount and (optional) liquidity fees: this is different from the channel balance. + */ data class Shared(override val serialId: Long, override val pubkeyScript: ByteVector, val localAmount: MilliSatoshi, val remoteAmount: MilliSatoshi, val htlcAmount: MilliSatoshi) : InteractiveTxOutput(), Incoming, Outgoing { // Note that the truncation is a no-op: the sum of balances in a channel must be a satoshi amount. override val amount: Satoshi = (localAmount + remoteAmount + htlcAmount).truncateToSatoshi() @@ -246,8 +265,17 @@ data class FundingContributions(val inputs: List, v /** * @param walletInputs 2-of-2 swap-in wallet inputs. */ - fun create(channelKeys: KeyManager.ChannelKeys, swapInKeys: KeyManager.SwapInOnChainKeys, params: InteractiveTxParams, walletInputs: List): Either = - create(channelKeys, swapInKeys, params, null, walletInputs, listOf()) + fun create( + channelKeys: KeyManager.ChannelKeys, + swapInKeys: KeyManager.SwapInOnChainKeys, + params: InteractiveTxParams, + walletInputs: List, + localPushAmount: MilliSatoshi, + remotePushAmount: MilliSatoshi, + liquidityPurchase: LiquidityAds.Purchase? + ): Either { + return create(channelKeys, swapInKeys, params, null, walletInputs, listOf(), localPushAmount, remotePushAmount, liquidityPurchase) + } /** * @param sharedUtxo previous input shared between the two participants (e.g. previous funding output when splicing) and our corresponding balance. @@ -262,6 +290,9 @@ data class FundingContributions(val inputs: List, v sharedUtxo: Pair?, walletInputs: List, localOutputs: List, + localPushAmount: MilliSatoshi, + remotePushAmount: MilliSatoshi, + liquidityPurchase: LiquidityAds.Purchase?, changePubKey: PublicKey? = null ): Either { walletInputs.forEach { utxo -> @@ -277,14 +308,18 @@ data class FundingContributions(val inputs: List, v return Either.Left(FundingContributionFailure.NotEnoughFunding(params.localContribution, localOutputs.map { it.amount }.sum(), totalAmountIn)) } - val nextLocalBalance = (sharedUtxo?.second?.toLocal ?: 0.msat) + params.localContribution.toMilliSatoshi() - val nextRemoteBalance = (sharedUtxo?.second?.toRemote ?: 0.msat) + params.remoteContribution.toMilliSatoshi() - if (nextLocalBalance < 0.msat || nextRemoteBalance < 0.msat) { - return Either.Left(FundingContributionFailure.InvalidFundingBalances(params.fundingAmount, nextLocalBalance, nextRemoteBalance)) + val liquidityFees = params.liquidityFees(liquidityPurchase) + val nextLocalBalanceBeforePush = (sharedUtxo?.second?.toLocal ?: 0.msat) + params.localContribution.toMilliSatoshi() + val nextLocalBalanceAfterPush = (sharedUtxo?.second?.toLocal ?: 0.msat) + params.localContribution.toMilliSatoshi() - localPushAmount + remotePushAmount - liquidityFees + val nextRemoteBalanceBeforePush = (sharedUtxo?.second?.toRemote ?: 0.msat) + params.remoteContribution.toMilliSatoshi() + val nextRemoteBalanceAfterPush = (sharedUtxo?.second?.toRemote ?: 0.msat) + params.remoteContribution.toMilliSatoshi() + localPushAmount - remotePushAmount + liquidityFees + if (nextLocalBalanceAfterPush < 0.msat || nextRemoteBalanceAfterPush < 0.msat) { + return Either.Left(FundingContributionFailure.InvalidFundingBalances(params.fundingAmount, nextLocalBalanceAfterPush, nextRemoteBalanceAfterPush)) } val fundingPubkeyScript = params.fundingPubkeyScript(channelKeys) - val sharedOutput = listOf(InteractiveTxOutput.Shared(0, fundingPubkeyScript, nextLocalBalance, nextRemoteBalance, sharedUtxo?.second?.toHtlcs ?: 0.msat)) + // We use local and remote balances before amounts are pushed to allow computing the local and remote mining fees. + val sharedOutput = listOf(InteractiveTxOutput.Shared(0, fundingPubkeyScript, nextLocalBalanceBeforePush, nextRemoteBalanceBeforePush, sharedUtxo?.second?.toHtlcs ?: 0.msat)) val nonChangeOutputs = localOutputs.map { o -> InteractiveTxOutput.Local.NonChange(0, o.amount, o.publicKeyScript) } val changeOutput = when (changePubKey) { null -> listOf() @@ -1068,16 +1103,7 @@ data class InteractiveTxSigningSession( val channelKeys = channelParams.localParams.channelKeys(keyManager) val unsignedTx = sharedTx.buildUnsignedTx() val sharedOutputIndex = unsignedTx.txOut.indexOfFirst { it.publicKeyScript == fundingParams.fundingPubkeyScript(channelKeys) } - val liquidityFees = liquidityPurchase?.let { l -> - val fees = l.fees.total.toMilliSatoshi() - when (l.paymentDetails) { - is LiquidityAds.PaymentDetails.FromChannelBalance -> if (fundingParams.isInitiator) fees else -fees - is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> if (fundingParams.isInitiator) fees else -fees - // Fees will be paid later, from relayed HTLCs. - is LiquidityAds.PaymentDetails.FromFutureHtlc -> 0.msat - is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> 0.msat - } - } ?: 0.msat + val liquidityFees = fundingParams.liquidityFees(liquidityPurchase) return Helpers.Funding.makeCommitTxs( channelKeys, channelParams.channelId, 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 2941147b5..7034497d9 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt @@ -405,15 +405,6 @@ data class Normal( add(ChannelAction.Disconnect) } Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) - } else if (!canAffordSpliceLiquidityFees(spliceStatus.command, parentCommitment)) { - val missing = spliceStatus.command.requestRemoteFunding?.let { r -> r.fees(spliceStatus.command.feerate).total - parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() } - logger.warning { "cannot do splice: balance is too low to pay for inbound liquidity (missing=$missing)" } - spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds) - val actions = buildList { - add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceRequest(channelId).message))) - add(ChannelAction.Disconnect) - } - Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) } else { val spliceInit = SpliceInit( channelId, @@ -521,6 +512,7 @@ data class Normal( Helpers.Funding.makeFundingPubKeyScript(spliceStatus.spliceInit.fundingPubkey, cmd.message.fundingPubkey), cmd.message.fundingContribution, spliceStatus.spliceInit.feerate, + cmd.message.feeCreditUsed, cmd.message.willFund, )) { is Either.Left -> { @@ -550,6 +542,9 @@ data class Normal( sharedUtxo = Pair(sharedInput, SharedFundingInputBalances(toLocal = parentCommitment.localCommit.spec.toLocal, toRemote = parentCommitment.localCommit.spec.toRemote, toHtlcs = parentCommitment.localCommit.spec.htlcs.map { it.add.amountMsat }.sum())), walletInputs = spliceStatus.command.spliceIn?.walletInputs ?: emptyList(), localOutputs = spliceStatus.command.spliceOutputs, + localPushAmount = spliceStatus.spliceInit.pushAmount, + remotePushAmount = cmd.message.pushAmount, + liquidityPurchase = liquidityPurchase.value, changePubKey = null // we don't want a change output: we're spending every funds available )) { is Either.Left -> { @@ -854,19 +849,6 @@ data class Normal( } } - private fun canAffordSpliceLiquidityFees(splice: ChannelCommand.Commitment.Splice.Request, parentCommitment: Commitment): Boolean { - return when (val request = splice.requestRemoteFunding) { - null -> true - else -> when (request.paymentDetails) { - is LiquidityAds.PaymentDetails.FromChannelBalance -> request.fees(splice.feerate).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() - is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> request.fees(splice.feerate).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() - // 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 - } - } - } - private fun ChannelContext.sendSpliceTxSigs( origins: List, action: InteractiveTxSigningSessionAction.SendTxSigs, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt index ba64d90e6..adfc1fac3 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt @@ -58,13 +58,22 @@ data class WaitForAcceptChannel( fundingParams.fundingPubkeyScript(channelKeys), accept.fundingAmount, lastSent.fundingFeerate, + accept.feeCreditUsed, accept.willFund )) { is Either.Left -> { logger.error { "rejecting liquidity proposal: ${liquidityPurchase.value.message}" } Pair(Aborted, listOf(ChannelAction.Message.Send(Error(cmd.message.temporaryChannelId, liquidityPurchase.value.message)))) } - is Either.Right -> when (val fundingContributions = FundingContributions.create(channelKeys, keyManager.swapInOnChainWallet, fundingParams, init.walletInputs)) { + is Either.Right -> when (val fundingContributions = FundingContributions.create( + channelKeys, + keyManager.swapInOnChainWallet, + fundingParams, + init.walletInputs, + lastSent.pushAmount, + accept.pushAmount, + liquidityPurchase.value + )) { is Either.Left -> { logger.error { "could not fund channel: ${fundingContributions.value}" } Pair(Aborted, listOf(ChannelAction.Message.Send(Error(channelId, ChannelFundingError(channelId).message)))) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt index a7d9f2ef3..2026b4b24 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt @@ -136,7 +136,7 @@ data class WaitForFundingConfirmed( latestFundingTx.fundingParams.dustLimit, rbfStatus.command.targetFeerate ) - when (val contributions = FundingContributions.create(channelKeys(), keyManager.swapInOnChainWallet, fundingParams, rbfStatus.command.walletInputs)) { + when (val contributions = FundingContributions.create(channelKeys(), keyManager.swapInOnChainWallet, fundingParams, rbfStatus.command.walletInputs, 0.msat, 0.msat, null)) { is Either.Left -> { logger.warning { "error creating funding contributions: ${contributions.value}" } Pair(this@WaitForFundingConfirmed.copy(rbfStatus = RbfStatus.RbfAborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, ChannelFundingError(channelId).message)))) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannel.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannel.kt index e103af1e7..f9eca893f 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannel.kt @@ -48,7 +48,7 @@ data class WaitForOpenChannel( fundingRates == null -> null requestFunding == null -> null requestFunding.requestedAmount > fundingAmount -> null - else -> fundingRates.validateRequest(staticParams.nodeParams.nodePrivateKey, fundingScript, open.fundingFeerate, requestFunding) + else -> fundingRates.validateRequest(staticParams.nodeParams.nodePrivateKey, fundingScript, open.fundingFeerate, requestFunding, 0.msat) } val accept = AcceptDualFundedChannel( temporaryChannelId = open.temporaryChannelId, @@ -91,7 +91,7 @@ data class WaitForOpenChannel( val remoteFundingPubkey = open.fundingPubkey val dustLimit = open.dustLimit.max(localParams.dustLimit) val fundingParams = InteractiveTxParams(channelId, false, fundingAmount, open.fundingAmount, remoteFundingPubkey, open.lockTime, dustLimit, open.fundingFeerate) - when (val fundingContributions = FundingContributions.create(channelKeys, keyManager.swapInOnChainWallet, fundingParams, walletInputs)) { + when (val fundingContributions = FundingContributions.create(channelKeys, keyManager.swapInOnChainWallet, fundingParams, walletInputs, accept.pushAmount, open.pushAmount, null)) { is Either.Left -> { logger.error { "could not fund channel: ${fundingContributions.value}" } Pair(Aborted, listOf(ChannelAction.Message.Send(Error(temporaryChannelId, ChannelFundingError(temporaryChannelId).message)))) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt index dc0fb48fd..f9d41306c 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 412d72571..950a28a74 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> { @@ -911,9 +912,10 @@ class Peer( private suspend fun processIncomingPayment(item: Either) { val currentBlockHeight = currentTipFlow.filterNotNull().first() val currentFeerate = peerFeeratesFlow.filterNotNull().first().fundingFeerate + val currentFeeCredit = feeCreditFlow.value 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, theirInit!!.features, currentBlockHeight, currentFeerate, currentFeeCredit) + is Either.Left -> incomingPaymentHandler.process(item.value, theirInit!!.features, currentBlockHeight, currentFeerate, currentFeeCredit) } when (result) { is IncomingPaymentHandler.ProcessAddResult.Accepted -> { @@ -1021,6 +1023,11 @@ class Peer( } } } + is CurrentFeeCredit -> { + if (nodeParams.features.hasFeature(Feature.FundingFeeCredit)) { + feeCreditFlow.value = msg.amount + } + } is Ping -> { val pong = Pong(ByteVector(ByteArray(msg.pongLength))) peerConnection?.send(pong) @@ -1339,6 +1346,7 @@ class Peer( } is AddLiquidityForIncomingPayment -> { val currentFeerates = peerFeeratesFlow.filterNotNull().first() + val currentFeeCredit = feeCreditFlow.value when (val available = selectChannelForSplicing()) { is SelectChannelResult.Available -> { // We don't contribute any input or output, but we must pay on-chain fees for the shared input and output. @@ -1347,7 +1355,7 @@ class Peer( val spliceWeight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = available.channel.commitments.active.first(), walletInputs = listOf(), localOutputs = listOf()) val (fundingFeerate, localMiningFee) = client.computeSpliceCpfpFeerate(available.channel.commitments, currentFeerates.fundingFeerate, spliceWeight, logger) val (targetFeerate, paymentDetails) = when { - localBalance >= localMiningFee + cmd.fees(fundingFeerate).total -> { + localBalance + currentFeeCredit >= localMiningFee + cmd.fees(fundingFeerate).total -> { // We have enough funds to pay the mining fee and the lease fees. // This the ideal scenario because the fees can be paid immediately with the splice transaction. Pair(fundingFeerate, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(listOf(cmd.paymentHash))) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index 07c95ac7c..cdb65cfc3 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, remoteFeatures: Features, currentBlockHeight: Int, currentFeerate: FeeratePerKw, currentFeeCredit: MilliSatoshi = 0.msat): ProcessAddResult { + return process(Either.Right(htlc), remoteFeatures, 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, remoteFeatures: Features, currentBlockHeight: Int, currentFeerate: FeeratePerKw, currentFeeCredit: MilliSatoshi = 0.msat): ProcessAddResult { + return process(Either.Left(htlc), remoteFeatures, currentBlockHeight, currentFeerate, currentFeeCredit) + } - private suspend fun process(htlc: Either, currentBlockHeight: Int, currentFeerate: FeeratePerKw): ProcessAddResult { + private suspend fun process(htlc: Either, remoteFeatures: Features, 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, remoteFeatures, 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, remoteFeatures: Features, 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}" } @@ -228,7 +232,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri nodeParams._nodeEvents.emit(LiquidityEvents.Rejected(payment.amountReceived, 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.TooManyParts(payment.parts.size))) rejectPayment(payment, incomingPayment, TemporaryNodeFailure) } - willAddHtlcParts.isNotEmpty() -> when (val result = validateOnTheFlyFundingRate(willAddHtlcParts.map { it.amount }.sum(), currentFeerate)) { + willAddHtlcParts.isNotEmpty() -> when (val result = validateOnTheFlyFundingRate(willAddHtlcParts.map { it.amount }.sum(), remoteFeatures, currentFeerate)) { is Either.Left -> { logger.warning { "rejecting on-the-fly funding: reason=${result.value.reason}" } nodeParams._nodeEvents.emit(result.value) @@ -236,19 +240,57 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri } is Either.Right -> { val (requestedAmount, fundingRate) = result.value - val actions = listOf(AddLiquidityForIncomingPayment(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) && remoteFeatures.hasFeature(Feature.FundingFeeCredit) + // 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 && 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. + val fees = fundingRate.fees(currentFeerate, requestedAmount, requestedAmount).total.toMilliSatoshi() + when (val rejected = nodeParams.liquidityPolicy.value.maybeReject(requestedAmount.toMilliSatoshi(), fees, LiquidityEvents.Source.OffChainPayment, logger)) { + is LiquidityEvents.Rejected -> { + nodeParams._nodeEvents.emit(rejected) + rejectPayment(payment, incomingPayment, TemporaryNodeFailure) + } + else -> { + val actions = listOf(AddLiquidityForIncomingPayment(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)) { @@ -258,21 +300,12 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri rejectPayment(payment, incomingPayment, failure) } 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) } } } @@ -283,6 +316,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 fun rejectPayment(payment: PendingPayment, incomingPayment: IncomingPayment, failure: FailureMessage): ProcessAddResult.Rejected { pending.remove(incomingPayment.paymentHash) val actions = payment.parts.map { part -> @@ -294,7 +340,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri return ProcessAddResult.Rejected(actions, incomingPayment) } - private fun validateOnTheFlyFundingRate(willAddHtlcAmount: MilliSatoshi, currentFeerate: FeeratePerKw): Either> { + private fun validateOnTheFlyFundingRate(willAddHtlcAmount: MilliSatoshi, remoteFeatures: Features, currentFeerate: FeeratePerKw): Either> { return when (val liquidityPolicy = nodeParams.liquidityPolicy.value) { is LiquidityPolicy.Disable -> Either.Left(LiquidityEvents.Rejected(willAddHtlcAmount, 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.PolicySetToDisabled)) is LiquidityPolicy.Auto -> { @@ -307,6 +353,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) && remoteFeatures.hasFeature(Feature.FundingFeeCredit) -> 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( 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 494314312..eafda5d8b 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt @@ -415,17 +415,25 @@ object Deserialization { 0x00 -> LiquidityAds.Purchase.Standard( amount = readNumber().sat, fees = readLiquidityFees(), - paymentDetails = when (val paymentDetailsDiscriminator = read()) { - 0x00 -> LiquidityAds.PaymentDetails.FromChannelBalance - 0x80 -> LiquidityAds.PaymentDetails.FromFutureHtlc(readCollection { readByteVector32() }.toList()) - 0x81 -> LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(readCollection { readByteVector32() }.toList()) - 0x82 -> LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(readCollection { readByteVector32() }.toList()) - else -> error("unknown discriminator $paymentDetailsDiscriminator for class ${LiquidityAds.PaymentDetails::class}") - } + paymentDetails = readLiquidityAdsPaymentDetails() + ) + 0x01 -> LiquidityAds.Purchase.WithFeeCredit( + amount = readNumber().sat, + fees = readLiquidityFees(), + feeCreditUsed = readNumber().msat, + paymentDetails = readLiquidityAdsPaymentDetails() ) else -> error("unknown discriminator $discriminator for class ${LiquidityAds.Purchase::class}") } + private fun Input.readLiquidityAdsPaymentDetails(): LiquidityAds.PaymentDetails = when (val discriminator = read()) { + 0x00 -> LiquidityAds.PaymentDetails.FromChannelBalance + 0x80 -> LiquidityAds.PaymentDetails.FromFutureHtlc(readCollection { readByteVector32() }.toList()) + 0x81 -> LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(readCollection { readByteVector32() }.toList()) + 0x82 -> LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(readCollection { readByteVector32() }.toList()) + else -> error("unknown discriminator $discriminator for class ${LiquidityAds.PaymentDetails::class}") + } + private fun Input.skipLegacyLiquidityLease() { readNumber() // amount readNumber() // mining fee 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 9dac84ef3..19c671b92 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt @@ -417,21 +417,32 @@ object Serialization { write(0x00) // discriminator writeNumber(purchase.amount.toLong()) writeLiquidityFees(purchase.fees) - when (val paymentDetails = purchase.paymentDetails) { - is LiquidityAds.PaymentDetails.FromChannelBalance -> write(0x00) - is LiquidityAds.PaymentDetails.FromFutureHtlc -> { - write(0x80) - writeCollection(paymentDetails.paymentHashes) { writeByteVector32(it) } - } - is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> { - write(0x81) - writeCollection(paymentDetails.preimages) { writeByteVector32(it) } - } - is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> { - write(0x82) - writeCollection(paymentDetails.paymentHashes) { writeByteVector32(it) } - } - } + writeLiquidityAdsPaymentDetails(purchase.paymentDetails) + } + is LiquidityAds.Purchase.WithFeeCredit -> { + write(0x01) // discriminator + writeNumber(purchase.amount.toLong()) + writeLiquidityFees(purchase.fees) + writeNumber(purchase.feeCreditUsed.toLong()) + writeLiquidityAdsPaymentDetails(purchase.paymentDetails) + } + } + } + + private fun Output.writeLiquidityAdsPaymentDetails(paymentDetails: LiquidityAds.PaymentDetails) { + when (paymentDetails) { + is LiquidityAds.PaymentDetails.FromChannelBalance -> write(0x00) + is LiquidityAds.PaymentDetails.FromFutureHtlc -> { + write(0x80) + writeCollection(paymentDetails.paymentHashes) { writeByteVector32(it) } + } + is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> { + write(0x81) + writeCollection(paymentDetails.preimages) { writeByteVector32(it) } + } + is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> { + write(0x82) + writeCollection(paymentDetails.paymentHashes) { writeByteVector32(it) } } } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt index 02da88516..75b2d7dfd 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt @@ -89,6 +89,18 @@ sealed class ChannelTlv : Tlv { } } + /** Fee credit that will be used for the given on-the-fly funding operation. */ + data class FeeCreditUsedTlv(val amount: MilliSatoshi) : ChannelTlv() { + override val tag: Long get() = FeeCreditUsedTlv.tag + + override fun write(out: Output) = LightningCodecs.writeTU64(amount.toLong(), out) + + companion object : TlvValueReader { + const val tag: Long = 41042 + override fun read(input: Input): FeeCreditUsedTlv = FeeCreditUsedTlv(LightningCodecs.tu64(input).msat) + } + } + /** Amount that will be offered by the initiator of a dual-funded channel to the non-initiator. */ data class PushAmountTlv(val amount: MilliSatoshi) : ChannelTlv() { override val tag: Long get() = PushAmountTlv.tag diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index 1bbfb2cb5..54a7ab2f7 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -83,6 +83,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 DNSAddressRequest.type -> DNSAddressRequest.read(stream) @@ -787,6 +789,7 @@ data class AcceptDualFundedChannel( ) : ChannelMessage, HasTemporaryChannelId { val channelType: ChannelType? get() = tlvStream.get()?.channelType val willFund: LiquidityAds.WillFund? get() = tlvStream.get()?.willFund + val feeCreditUsed: MilliSatoshi = tlvStream.get()?.amount ?: 0.msat val pushAmount: MilliSatoshi get() = tlvStream.get()?.amount ?: 0.msat override val type: Long get() = AcceptDualFundedChannel.type @@ -819,6 +822,7 @@ data class AcceptDualFundedChannel( ChannelTlv.ChannelTypeTlv.tag to ChannelTlv.ChannelTypeTlv.Companion as TlvValueReader, ChannelTlv.RequireConfirmedInputsTlv.tag to ChannelTlv.RequireConfirmedInputsTlv as TlvValueReader, ChannelTlv.ProvideFundingTlv.tag to ChannelTlv.ProvideFundingTlv as TlvValueReader, + ChannelTlv.FeeCreditUsedTlv.tag to ChannelTlv.FeeCreditUsedTlv as TlvValueReader, ChannelTlv.PushAmountTlv.tag to ChannelTlv.PushAmountTlv.Companion as TlvValueReader, ) @@ -1012,6 +1016,7 @@ data class SpliceAck( override val type: Long get() = SpliceAck.type val requireConfirmedInputs: Boolean = tlvStream.get()?.let { true } ?: false val willFund: LiquidityAds.WillFund? = tlvStream.get()?.willFund + val feeCreditUsed: MilliSatoshi = tlvStream.get()?.amount ?: 0.msat val pushAmount: MilliSatoshi = tlvStream.get()?.amount ?: 0.msat constructor(channelId: ByteVector32, fundingContribution: Satoshi, pushAmount: MilliSatoshi, fundingPubkey: PublicKey, willFund: LiquidityAds.WillFund?) : this( @@ -1039,6 +1044,7 @@ data class SpliceAck( private val readers = mapOf( ChannelTlv.RequireConfirmedInputsTlv.tag to ChannelTlv.RequireConfirmedInputsTlv as TlvValueReader, ChannelTlv.ProvideFundingTlv.tag to ChannelTlv.ProvideFundingTlv as TlvValueReader, + ChannelTlv.FeeCreditUsedTlv.tag to ChannelTlv.FeeCreditUsedTlv.Companion as TlvValueReader, ChannelTlv.PushAmountTlv.tag to ChannelTlv.PushAmountTlv.Companion as TlvValueReader, ) @@ -1777,6 +1783,48 @@ 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) : HasChainHash, OnTheFlyFundingMessage { + override val type: Long = CurrentFeeCredit.type + + override fun write(out: Output) { + LightningCodecs.writeBytes(chainHash.value, out) + LightningCodecs.writeU64(amount.toLong(), out) + } + + companion object : LightningMessageReader { + const val type: Long = 41046 + + override fun read(input: Input): CurrentFeeCredit = CurrentFeeCredit( + chainHash = BlockHash(LightningCodecs.bytes(input, 32)), + amount = LightningCodecs.u64(input).msat, + ) + } +} + 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 d80c6de6c..e99bf54fa 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt @@ -13,6 +13,7 @@ import fr.acinq.lightning.channel.InvalidLiquidityAdsSig import fr.acinq.lightning.channel.MissingLiquidityAds import fr.acinq.lightning.transactions.Transactions import fr.acinq.lightning.utils.BitField +import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat /** @@ -186,14 +187,17 @@ object LiquidityAds { /** Sellers offer various rates and payment options. */ data class WillFundRates(val fundingRates: List, val paymentTypes: Set) { - fun validateRequest(nodeKey: PrivateKey, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding): WillFundPurchase? { + fun validateRequest(nodeKey: PrivateKey, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding, feeCreditUsed: MilliSatoshi): WillFundPurchase? { val paymentTypeOk = paymentTypes.contains(request.paymentDetails.paymentType) val rateOk = fundingRates.contains(request.fundingRate) val amountOk = request.fundingRate.minAmount <= request.requestedAmount && request.requestedAmount <= request.fundingRate.maxAmount return when { paymentTypeOk && rateOk && amountOk -> { val sig = Crypto.sign(request.fundingRate.signedData(fundingScript), nodeKey) - val purchase = Purchase.Standard(request.requestedAmount, request.fees(fundingFeerate), request.paymentDetails) + val purchase = when (feeCreditUsed) { + 0.msat -> Purchase.Standard(request.requestedAmount, request.fees(fundingFeerate), request.paymentDetails) + else -> Purchase.WithFeeCredit(request.requestedAmount, request.fees(fundingFeerate), feeCreditUsed, request.paymentDetails) + } WillFundPurchase(WillFund(request.fundingRate, fundingScript, sig), purchase) } else -> null @@ -250,6 +254,7 @@ object LiquidityAds { fundingScript: ByteVector, remoteFundingAmount: Satoshi, fundingFeerate: FeeratePerKw, + feeCreditUsed: MilliSatoshi, willFund: WillFund? ): Either { return when (willFund) { @@ -262,7 +267,10 @@ object LiquidityAds { else -> { val purchasedAmount = requestedAmount.min(remoteFundingAmount) val fees = fundingRate.fees(fundingFeerate, requestedAmount, remoteFundingAmount) - Either.Right(Purchase.Standard(purchasedAmount, fees, paymentDetails)) + when (feeCreditUsed) { + 0.msat -> Either.Right(Purchase.Standard(purchasedAmount, fees, paymentDetails)) + else -> Either.Right(Purchase.WithFeeCredit(purchasedAmount, fees, feeCreditUsed, paymentDetails)) + } } } } @@ -295,11 +303,12 @@ object LiquidityAds { fundingScript: ByteVector, remoteFundingAmount: Satoshi, fundingFeerate: FeeratePerKw, + feeCreditUsed: MilliSatoshi, willFund: WillFund?, ): Either { return when (request) { null -> Either.Right(null) - else -> request.validateRemoteFunding(remoteNodeId, channelId, fundingScript, remoteFundingAmount, fundingFeerate, willFund) + else -> request.validateRemoteFunding(remoteNodeId, channelId, fundingScript, remoteFundingAmount, fundingFeerate, feeCreditUsed, willFund) } } @@ -310,6 +319,7 @@ object LiquidityAds { abstract val paymentDetails: PaymentDetails data class Standard(override val amount: Satoshi, override val fees: Fees, override val paymentDetails: PaymentDetails) : Purchase() + data class WithFeeCredit(override val amount: Satoshi, override val fees: Fees, val feeCreditUsed: MilliSatoshi, override val paymentDetails: PaymentDetails) : Purchase() } data class WillFundPurchase(val willFund: WillFund, val purchase: Purchase) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt index 0f8ae24d4..253494187 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt @@ -785,7 +785,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val fundingParams = InteractiveTxParams(randomBytes32(), true, 150_000.sat, 50_000.sat, pubKey, 0, 660.sat, FeeratePerKw(2500.sat)) run { val previousTx = Transaction(2, listOf(), listOf(TxOut(293.sat, Script.pay2wpkh(pubKey))), 0) - val result = FundingContributions.create(channelKeys, swapInKeys, fundingParams, listOf(WalletState.Utxo(previousTx.txid, 0, 0, previousTx, WalletState.AddressMeta.Single))).left + val result = FundingContributions.create(channelKeys, swapInKeys, fundingParams, listOf(WalletState.Utxo(previousTx.txid, 0, 0, previousTx, WalletState.AddressMeta.Single)), 0.msat, 0.msat, null).left assertNotNull(result) assertIs(result) } @@ -793,18 +793,61 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val txIn = (1..1000).map { TxIn(OutPoint(TxId(randomBytes32()), 3), ByteVector.empty, 0, Script.witnessPay2wpkh(pubKey, Transactions.PlaceHolderSig)) } val txOut = (1..1000).map { i -> TxOut(1000.sat * i, Script.pay2wpkh(pubKey)) } val previousTx = Transaction(2, txIn, txOut, 0) - val result = FundingContributions.create(channelKeys, swapInKeys, fundingParams, listOf(WalletState.Utxo(previousTx.txid, 53, 0, previousTx, WalletState.AddressMeta.Single))).left + val result = FundingContributions.create(channelKeys, swapInKeys, fundingParams, listOf(WalletState.Utxo(previousTx.txid, 53, 0, previousTx, WalletState.AddressMeta.Single)), 0.msat, 0.msat, null).left assertNotNull(result) assertIs(result) } run { val previousTx = Transaction(2, listOf(), listOf(TxOut(80_000.sat, Script.pay2wpkh(pubKey)), TxOut(60_000.sat, Script.pay2wpkh(pubKey))), 0) - val result = FundingContributions.create(channelKeys, swapInKeys, fundingParams, listOf(WalletState.Utxo(previousTx.txid, 0, 0, previousTx, WalletState.AddressMeta.Single), WalletState.Utxo(previousTx.txid, 1, 0, previousTx, WalletState.AddressMeta.Single))).left + val walletInputs = listOf( + WalletState.Utxo(previousTx.txid, 0, 0, previousTx, WalletState.AddressMeta.Single), + WalletState.Utxo(previousTx.txid, 1, 0, previousTx, WalletState.AddressMeta.Single), + ) + val result = FundingContributions.create(channelKeys, swapInKeys, fundingParams, walletInputs, 0.msat, 0.msat, null).left assertNotNull(result) assertIs(result) } } + @Test + fun `cannot pay liquidity ads fees`() { + val channelKeys = TestConstants.Alice.keyManager.run { channelKeys(newFundingKeyPath(isInitiator = true)) } + val swapInKeys = TestConstants.Alice.keyManager.swapInOnChainWallet + val walletKey = randomKey().publicKey() + val fundingParams = InteractiveTxParams(randomBytes32(), true, 0.sat, 250_000.sat, walletKey, 0, 660.sat, FeeratePerKw(2500.sat)) + val fees = LiquidityAds.Fees(3000.sat, 2000.sat) + val paymentDetails = LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(listOf(randomBytes32())) + run { + // If we don't contribute any funds, we cannot pay the liquidity lease. + val purchase = LiquidityAds.Purchase.Standard(100_000.sat, fees, paymentDetails) + val result = FundingContributions.create(channelKeys, swapInKeys, fundingParams, listOf(), 0.msat, 0.msat, purchase).left + assertNotNull(result) + assertIs(result) + } + run { + // If our peer pushes enough funds on our side to pay liquidity fees, we're fine. + val purchase = LiquidityAds.Purchase.Standard(100_000.sat, fees, paymentDetails) + val result = FundingContributions.create(channelKeys, swapInKeys, fundingParams, listOf(), 0.msat, 10_000_000.msat, purchase).right + assertNotNull(result) + assertTrue(result.inputs.isEmpty()) + assertEquals(1, result.outputs.size) + val sharedOutput = result.outputs.first() + assertIs(sharedOutput) + assertEquals(fundingParams.fundingAmount, sharedOutput.amount) + } + run { + // If we have enough fee credit to pay liquidity fees, we're fine. + val purchase = LiquidityAds.Purchase.WithFeeCredit(100_000.sat, fees, 5_000_000.msat, paymentDetails) + val result = FundingContributions.create(channelKeys, swapInKeys, fundingParams, listOf(), 0.msat, 0.msat, purchase).right + assertNotNull(result) + assertTrue(result.inputs.isEmpty()) + assertEquals(1, result.outputs.size) + val sharedOutput = result.outputs.first() + assertIs(sharedOutput) + assertEquals(fundingParams.fundingAmount, sharedOutput.amount) + } + } + @Test fun `invalid input`() { // Create a transaction with a mix of segwit and non-segwit inputs. @@ -1275,10 +1318,10 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val fundingParamsA = InteractiveTxParams(channelId, true, fundingAmountA, fundingAmountB, fundingPubkeyB, lockTime, dustLimit, targetFeerate) val fundingParamsB = InteractiveTxParams(channelId, false, fundingAmountB, fundingAmountA, fundingPubkeyA, lockTime, dustLimit, targetFeerate) val walletA = createWallet(swapInKeysA, utxosA, legacyUtxosA) - val contributionsA = FundingContributions.create(channelKeysA, swapInKeysA, fundingParamsA, null, walletA, listOf(), randomKey().publicKey()) + val contributionsA = FundingContributions.create(channelKeysA, swapInKeysA, fundingParamsA, null, walletA, listOf(), 0.msat, 0.msat, null, randomKey().publicKey()) assertNotNull(contributionsA.right) val walletB = createWallet(swapInKeysB, utxosB, legacyUtxosB) - val contributionsB = FundingContributions.create(channelKeysB, swapInKeysB, fundingParamsB, null, walletB, listOf(), randomKey().publicKey()) + val contributionsB = FundingContributions.create(channelKeysB, swapInKeysB, fundingParamsB, null, walletB, listOf(), 0.msat, 0.msat, null, randomKey().publicKey()) assertNotNull(contributionsB.right) return Fixture(channelId, TestConstants.Alice.keyManager, channelKeysA, localParamsA, fundingParamsA, contributionsA.right!!, TestConstants.Bob.keyManager, channelKeysB, localParamsB, fundingParamsB, contributionsB.right!!) } @@ -1311,7 +1354,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val sharedInputA = SharedFundingInput.Multisig2of2(inputInfo, fundingTxIndex, channelKeysB.fundingPubKey(fundingTxIndex)) val nextFundingPubkeyB = channelKeysB.fundingPubKey(fundingTxIndex + 1) val fundingParamsA = InteractiveTxParams(channelId, true, fundingContributionA, fundingContributionB, sharedInputA, nextFundingPubkeyB, outputsA, lockTime, dustLimit, targetFeerate) - return FundingContributions.create(channelKeysA, swapInKeysA, fundingParamsA, Pair(sharedInputA, SharedFundingInputBalances(balanceA, balanceB, 0.msat)), listOf(), outputsA, randomKey().publicKey()) + return FundingContributions.create(channelKeysA, swapInKeysA, fundingParamsA, Pair(sharedInputA, SharedFundingInputBalances(balanceA, balanceB, 0.msat)), listOf(), outputsA, 0.msat, 0.msat, null, randomKey().publicKey()) } private fun createSpliceFixture( @@ -1350,10 +1393,10 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val fundingParamsA = InteractiveTxParams(channelId, true, fundingContributionA, fundingContributionB, sharedInputA, nextFundingPubkeyB, outputsA, lockTime, dustLimit, targetFeerate) val fundingParamsB = InteractiveTxParams(channelId, false, fundingContributionB, fundingContributionA, sharedInputB, nextFundingPubkeyA, outputsB, lockTime, dustLimit, targetFeerate) val walletA = createWallet(swapInKeysA, utxosA) - val contributionsA = FundingContributions.create(channelKeysA, swapInKeysA, fundingParamsA, Pair(sharedInputA, SharedFundingInputBalances(balanceA, balanceB, 0.msat)), walletA, outputsA, randomKey().publicKey()) + val contributionsA = FundingContributions.create(channelKeysA, swapInKeysA, fundingParamsA, Pair(sharedInputA, SharedFundingInputBalances(balanceA, balanceB, 0.msat)), walletA, outputsA, 0.msat, 0.msat, null, randomKey().publicKey()) assertNotNull(contributionsA.right) val walletB = createWallet(swapInKeysB, utxosB) - val contributionsB = FundingContributions.create(channelKeysB, swapInKeysB, fundingParamsB, Pair(sharedInputB, SharedFundingInputBalances(balanceB, balanceA, 0.msat)), walletB, outputsB, randomKey().publicKey()) + val contributionsB = FundingContributions.create(channelKeysB, swapInKeysB, fundingParamsB, Pair(sharedInputB, SharedFundingInputBalances(balanceB, balanceA, 0.msat)), walletB, outputsB, 0.msat, 0.msat, null, randomKey().publicKey()) assertNotNull(contributionsB.right) return Fixture(channelId, TestConstants.Alice.keyManager, channelKeysA, localParamsA, fundingParamsA, contributionsA.right!!, TestConstants.Bob.keyManager, channelKeysB, localParamsB, fundingParamsB, contributionsB.right!!) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt index 9152669aa..0bf11b406 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt @@ -23,7 +23,6 @@ import fr.acinq.lightning.utils.sum import fr.acinq.lightning.utils.toMilliSatoshi import fr.acinq.lightning.wire.* import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlin.math.abs import kotlin.test.* @@ -205,7 +204,7 @@ class SpliceTestsCommon : LightningTestSuite() { assertNull(defaultSpliceAck.willFund) val fundingScript = Helpers.Funding.makeFundingPubKeyScript(spliceInit.fundingPubkey, defaultSpliceAck.fundingPubkey) run { - val willFund = fundingRates.validateRequest(bob.staticParams.nodeParams.nodePrivateKey, fundingScript, cmd.feerate, spliceInit.requestFunding!!)?.willFund + val willFund = fundingRates.validateRequest(bob.staticParams.nodeParams.nodePrivateKey, fundingScript, cmd.feerate, spliceInit.requestFunding!!, 0.msat)?.willFund assertNotNull(willFund) val spliceAck = SpliceAck(alice.channelId, liquidityRequest.requestedAmount, 0.msat, defaultSpliceAck.fundingPubkey, willFund) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceAck)) @@ -213,9 +212,18 @@ class SpliceTestsCommon : LightningTestSuite() { assertIs(alice2.state.spliceStatus) actionsAlice2.hasOutgoingMessage() } + run { + val willFund = fundingRates.validateRequest(bob.staticParams.nodeParams.nodePrivateKey, fundingScript, cmd.feerate, spliceInit.requestFunding!!, 5_000_000.msat)?.willFund + assertNotNull(willFund) + val spliceAck = SpliceAck(alice.channelId, liquidityRequest.requestedAmount, defaultSpliceAck.fundingPubkey, TlvStream(ChannelTlv.ProvideFundingTlv(willFund), ChannelTlv.FeeCreditUsedTlv(5_000_000.msat))) + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceAck)) + assertIs(alice2.state) + assertIs(alice2.state.spliceStatus) + actionsAlice2.hasOutgoingMessage() + } run { // Bob uses a different funding script than what Alice expects. - val willFund = fundingRates.validateRequest(bob.staticParams.nodeParams.nodePrivateKey, ByteVector("deadbeef"), cmd.feerate, spliceInit.requestFunding!!)?.willFund + val willFund = fundingRates.validateRequest(bob.staticParams.nodeParams.nodePrivateKey, ByteVector("deadbeef"), cmd.feerate, spliceInit.requestFunding!!, 0.msat)?.willFund assertNotNull(willFund) val spliceAck = SpliceAck(alice.channelId, liquidityRequest.requestedAmount, 0.msat, defaultSpliceAck.fundingPubkey, willFund) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceAck)) @@ -234,55 +242,78 @@ class SpliceTestsCommon : LightningTestSuite() { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun `splice to purchase inbound liquidity -- not enough funds`() { val (alice, bob) = reachNormal(aliceFundingAmount = 100_000.sat, bobFundingAmount = 10_000.sat, alicePushAmount = 0.msat, bobPushAmount = 0.msat) - val fundingRate = LiquidityAds.FundingRate(100_000.sat, 10_000_000.sat, 0, 100 /* 1% */, 1.sat) + val fundingRate = LiquidityAds.FundingRate(100_000.sat, 10_000_000.sat, 0, 100 /* 1% */, 0.sat) + val fundingRates = LiquidityAds.WillFundRates(listOf(fundingRate), setOf(LiquidityAds.PaymentType.FromChannelBalance, LiquidityAds.PaymentType.FromFutureHtlc)) run { val liquidityRequest = LiquidityAds.RequestFunding(1_000_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance) - assertEquals(10_001.sat, liquidityRequest.fees(FeeratePerKw(1000.sat)).total) + assertEquals(10_000.sat, liquidityRequest.fees(FeeratePerKw(1000.sat)).total) val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf()) val (bob1, actionsBob1) = bob.process(cmd) val bobStfu = actionsBob1.findOutgoingMessage() - val (_, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) val aliceStfu = actionsAlice1.findOutgoingMessage() - val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) - assertEquals(2, actionsBob2.size) - actionsBob2.hasOutgoingMessage() - actionsBob2.has() - assertTrue(cmd.replyTo.isCompleted) - assertEquals(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds, cmd.replyTo.getCompleted()) + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) + val spliceInit = actionsBob2.hasOutgoingMessage().also { assertEquals(liquidityRequest, it.requestFunding) } + val (_, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceInit)) + val spliceAck = actionsAlice2.hasOutgoingMessage() + // We don't implement the liquidity provider side, so we must fake it. + assertNull(spliceAck.willFund) + val fundingScript = Helpers.Funding.makeFundingPubKeyScript(spliceInit.fundingPubkey, spliceAck.fundingPubkey) + val willFund = fundingRates.validateRequest(alice.staticParams.nodeParams.nodePrivateKey, fundingScript, cmd.feerate, spliceInit.requestFunding!!, 0.msat)!!.willFund + val (_, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(spliceAck.copy(fundingContribution = liquidityRequest.requestedAmount, tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))))) + assertEquals(1, actionsBob3.size) + actionsBob3.hasOutgoingMessage() } run { - val liquidityRequest = LiquidityAds.RequestFunding(1_000_000.sat, fundingRate.copy(feeBase = 0.sat), LiquidityAds.PaymentDetails.FromChannelBalance) - assertEquals(10_000.sat, liquidityRequest.fees(FeeratePerKw(1000.sat)).total) + val liquidityRequest = LiquidityAds.RequestFunding(900_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance) + assertEquals(9_000.sat, liquidityRequest.fees(FeeratePerKw(1000.sat)).total) val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf()) val (bob1, actionsBob1) = bob.process(cmd) val bobStfu = actionsBob1.findOutgoingMessage() - val (_, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) val aliceStfu = actionsAlice1.findOutgoingMessage() - val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) - actionsBob2.hasOutgoingMessage().also { assertEquals(liquidityRequest, it.requestFunding) } + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) + val spliceInit = actionsBob2.hasOutgoingMessage().also { assertEquals(liquidityRequest, it.requestFunding) } + val (_, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceInit)) + val spliceAck = actionsAlice2.hasOutgoingMessage() + // We don't implement the liquidity provider side, so we must fake it. + assertNull(spliceAck.willFund) + val fundingScript = Helpers.Funding.makeFundingPubKeyScript(spliceInit.fundingPubkey, spliceAck.fundingPubkey) + val willFund = fundingRates.validateRequest(alice.staticParams.nodeParams.nodePrivateKey, fundingScript, cmd.feerate, spliceInit.requestFunding!!, 0.msat)!!.willFund + val (_, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(spliceAck.copy(fundingContribution = liquidityRequest.requestedAmount, tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))))) + assertEquals(1, actionsBob3.size) + actionsBob3.hasOutgoingMessage() } run { - // When we don't have enough funds in our channel balance, fees can be paid via future HTLCs. + // When we don't have enough funds in our channel balance, fees can be paid via future HTLCs. val liquidityRequest = LiquidityAds.RequestFunding(1_000_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(randomBytes32()))) - assertEquals(10_001.sat, liquidityRequest.fees(FeeratePerKw(1000.sat)).total) + assertEquals(10_000.sat, liquidityRequest.fees(FeeratePerKw(1000.sat)).total) val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf()) val (bob1, actionsBob1) = bob.process(cmd) val bobStfu = actionsBob1.findOutgoingMessage() - val (_, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) val aliceStfu = actionsAlice1.findOutgoingMessage() - val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) - actionsBob2.hasOutgoingMessage().also { assertEquals(liquidityRequest, it.requestFunding) } + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) + val spliceInit = actionsBob2.hasOutgoingMessage().also { assertEquals(liquidityRequest, it.requestFunding) } + val (_, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceInit)) + val spliceAck = actionsAlice2.hasOutgoingMessage() + // We don't implement the liquidity provider side, so we must fake it. + assertNull(spliceAck.willFund) + val fundingScript = Helpers.Funding.makeFundingPubKeyScript(spliceInit.fundingPubkey, spliceAck.fundingPubkey) + val willFund = fundingRates.validateRequest(alice.staticParams.nodeParams.nodePrivateKey, fundingScript, cmd.feerate, spliceInit.requestFunding!!, 0.msat)!!.willFund + val (_, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(spliceAck.copy(fundingContribution = liquidityRequest.requestedAmount, tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))))) + assertEquals(1, actionsBob3.size) + actionsBob3.hasOutgoingMessage() } } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun `splice to purchase inbound liquidity -- not enough funds but on-the-fly funding`() { val (alice, bob) = reachNormal(channelType = ChannelType.SupportedChannelType.AnchorOutputsZeroReserve, bobFundingAmount = 0.sat, alicePushAmount = 0.msat, bobPushAmount = 0.msat) val fundingRate = LiquidityAds.FundingRate(0.sat, 500_000.sat, 0, 50, 0.sat) + val fundingRates = LiquidityAds.WillFundRates(listOf(fundingRate), setOf(LiquidityAds.PaymentType.FromChannelBalanceForFutureHtlc, LiquidityAds.PaymentType.FromFutureHtlc)) val origin = Origin.OffChainPayment(randomBytes32(), 25_000_000.msat, ChannelManagementFees(0.sat, 500.sat)) run { // We don't have enough funds to pay fees from our channel balance. @@ -290,14 +321,19 @@ class SpliceTestsCommon : LightningTestSuite() { val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, fundingRequest, FeeratePerKw(1000.sat), listOf(origin)) val (bob1, actionsBob1) = bob.process(cmd) val bobStfu = actionsBob1.findOutgoingMessage() - val (_, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) val aliceStfu = actionsAlice1.findOutgoingMessage() - val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) - assertEquals(2, actionsBob2.size) - actionsBob2.hasOutgoingMessage() - actionsBob2.has() - assertTrue(cmd.replyTo.isCompleted) - assertEquals(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds, cmd.replyTo.getCompleted()) + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) + val spliceInit = actionsBob2.hasOutgoingMessage().also { assertEquals(fundingRequest, it.requestFunding) } + val (_, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceInit)) + val spliceAck = actionsAlice2.hasOutgoingMessage() + // We don't implement the liquidity provider side, so we must fake it. + assertNull(spliceAck.willFund) + val fundingScript = Helpers.Funding.makeFundingPubKeyScript(spliceInit.fundingPubkey, spliceAck.fundingPubkey) + val willFund = fundingRates.validateRequest(alice.staticParams.nodeParams.nodePrivateKey, fundingScript, cmd.feerate, spliceInit.requestFunding!!, 0.msat)!!.willFund + val (_, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(spliceAck.copy(fundingContribution = fundingRequest.requestedAmount, tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))))) + assertEquals(1, actionsBob3.size) + actionsBob3.hasOutgoingMessage() } run { // We can use future HTLCs to pay fees for the liquidity we're purchasing. @@ -305,14 +341,21 @@ class SpliceTestsCommon : LightningTestSuite() { val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, fundingRequest, FeeratePerKw(1000.sat), listOf(origin)) val (bob1, actionsBob1) = bob.process(cmd) val bobStfu = actionsBob1.findOutgoingMessage() - val (_, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) val aliceStfu = actionsAlice1.findOutgoingMessage() - val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) - assertEquals(actionsBob2.size, 1) - actionsBob2.findOutgoingMessage().also { + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) + val spliceInit = actionsBob2.findOutgoingMessage().also { assertEquals(0.sat, it.fundingContribution) assertEquals(fundingRequest, it.requestFunding) } + val (_, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceInit)) + val spliceAck = actionsAlice2.hasOutgoingMessage() + // We don't implement the liquidity provider side, so we must fake it. + assertNull(spliceAck.willFund) + val fundingScript = Helpers.Funding.makeFundingPubKeyScript(spliceInit.fundingPubkey, spliceAck.fundingPubkey) + val willFund = fundingRates.validateRequest(alice.staticParams.nodeParams.nodePrivateKey, fundingScript, cmd.feerate, spliceInit.requestFunding!!, 0.msat)!!.willFund + val (_, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(spliceAck.copy(fundingContribution = fundingRequest.requestedAmount, tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))))) + actionsBob3.hasOutgoingMessage() } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt index da6764b24..411c37dbc 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt @@ -140,7 +140,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { checkDbPayment(incomingPayment, paymentHandler.db) val channelId = randomBytes32() val add = makeUpdateAddHtlc(12, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true) @@ -164,7 +164,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob doesn't accept the MPP set yet run { val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -174,7 +174,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob now accepts the MPP set run { val add = makeUpdateAddHtlc(5, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off @@ -199,7 +199,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 1: Alice sends first multipart htlc to Bob. val add1 = run { val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) @@ -211,7 +211,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 3: on reconnection, the HTLC from step 1 is processed again. run { - val result = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add1, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) @@ -220,7 +220,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 4: Alice sends second multipart htlc to Bob. run { val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off @@ -240,7 +240,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) checkDbPayment(incomingPayment, paymentHandler.db) val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) - val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertEquals(1, result.actions.size) val addLiquidity = result.actions.first() @@ -264,7 +264,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob doesn't trigger the open/splice yet run { val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount, amount * 2, paymentSecret)) - val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -274,7 +274,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob trigger an open/splice run { val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount, amount * 2, paymentSecret)) - val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertEquals(1, result.actions.size) val addLiquidity = result.actions.first() as AddLiquidityForIncomingPayment @@ -297,7 +297,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob doesn't trigger the open/splice yet run { val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -307,7 +307,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob trigger an open/splice run { val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2 + 10_000_000.msat, totalAmount, paymentSecret)) - val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertEquals(1, result.actions.size) val addLiquidity = result.actions.first() as AddLiquidityForIncomingPayment @@ -332,7 +332,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { assertTrue(trampolineOnion.packet.payload.size() < 500) makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, PaymentOnion.FinalPayload.Standard.createTrampolinePayload(amount, amount, expiry, randomBytes32(), trampolineOnion.packet)) } - val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertEquals(1, result.actions.size) val addLiquidity = result.actions.first() @@ -348,7 +348,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { fun `receive will_add_htlc with an unknown payment hash`() = runSuspendTest { val (paymentHandler, _, paymentSecret) = createFixture(defaultAmount) val willAddHtlc = makeWillAddHtlc(paymentHandler, randomBytes32(), makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) - val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertNull(result.incomingPayment) val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(paymentHandler.nodeParams.nodePrivateKey, willAddHtlc, IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())) @@ -360,7 +360,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { fun `receive will_add_htlc with an incorrect payment secret`() = runSuspendTest { val (paymentHandler, incomingPayment, _) = createFixture(defaultAmount) val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, randomBytes32())) - val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertEquals(incomingPayment, result.incomingPayment) val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(paymentHandler.nodeParams.nodePrivateKey, willAddHtlc, IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())) @@ -383,7 +383,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { assertTrue(trampolineOnion.packet.payload.size() < 500) makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, PaymentOnion.FinalPayload.Standard.createTrampolinePayload(amount, amount, expiry, randomBytes32(), trampolineOnion.packet)) } - val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertEquals(incomingPayment, result.incomingPayment) val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(paymentHandler.nodeParams.nodePrivateKey, willAddHtlc, IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())) @@ -421,7 +421,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { paymentHandler.nodeParams.liquidityPolicy.emit(policy) paymentHandler.nodeParams._nodeEvents.resetReplayCache() val add = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(paymentAmount, paymentAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) when (failure) { null -> { assertIs(result) @@ -454,7 +454,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - 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) + val result = paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -464,7 +464,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob triggers an open/splice run { val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertEquals(1, result.actions.size) val addLiquidity = result.actions.first() as AddLiquidityForIncomingPayment @@ -479,7 +479,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob accepts the MPP set run { val htlc = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off @@ -507,7 +507,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - 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) + val result = paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -518,7 +518,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(null, 100.sat, 100, skipAbsoluteFeeCheck = false)) - val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertEquals(2, result.actions.size) val willFailHtlc = result.actions.filterIsInstance().firstOrNull()?.message @@ -533,7 +533,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob doesn't accept the MPP set yet run { val htlc = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -543,7 +543,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob accepts the MPP payment run { val htlc = makeUpdateAddHtlc(2, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off @@ -569,7 +569,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Alice sends a normal HTLC to Bob first. val htlc = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(partialAmount, totalAmount, paymentSecret)) - paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw).also { result -> + paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw).also { result -> assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -577,13 +577,13 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Alice then sends some partial will_add_htlc. val willAddHtlcs = (0 until 5).map { makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(partialAmount, totalAmount, paymentSecret)) } willAddHtlcs.take(4).forEach { - val result = paymentHandler.process(it, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(it, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } // Alice sends the last will_add_htlc: there are too many parts, so Bob rejects the payment. - val result = paymentHandler.process(willAddHtlcs.last(), TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(willAddHtlcs.last(), Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertEquals(6, result.actions.size) val willFailHtlcs = result.actions.filterIsInstance().map { it.message }.filterIsInstance() @@ -594,6 +594,116 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) } + @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, feeCreditFeatures, 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, feeCreditFeatures, 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, feeCreditFeatures, 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, feeCreditFeatures, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, currentFeeCredit) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() + assertIs(addLiquidity) + assertEquals(totalAmount, addLiquidity.paymentAmount) + assertEquals(100_000.sat, addLiquidity.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, feeCreditFeatures, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, currentFeeCredit = 100.msat) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() + assertIs(addLiquidity) + assertEquals(totalAmount, addLiquidity.paymentAmount) + assertEquals(120_000.sat, addLiquidity.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() @@ -607,7 +717,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - 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) + val result = paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -617,7 +727,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob triggers an open/splice val purchase = run { val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertEquals(1, result.actions.size) val splice = result.actions.first() as AddLiquidityForIncomingPayment @@ -638,7 +748,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val htlc = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret), fundingFee = purchase.fundingFee) assertTrue(htlc.amountMsat < amount2) - val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off @@ -661,7 +771,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val fundingFee = LiquidityAds.FundingFee(3_000_000.msat, TxId(randomBytes32())) val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret), fundingFee = fundingFee) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) @@ -686,7 +796,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // If the funding fee is higher than what was agreed upon, we reject the payment. val fundingFeeTooHigh = payment.fundingFee.copy(amount = payment.fundingFee.amount + 1.msat) val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret), fundingFee = fundingFeeTooHigh) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) @@ -694,7 +804,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { // If our peer retries with the right funding fee, we accept it. val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret), fundingFee = payment.fundingFee) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertEquals(listOf(WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, incomingPayment.preimage, commit = true))), result.actions) assertEquals(defaultAmount - payment.fundingFee.amount, result.received.amount) @@ -719,7 +829,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { paymentHandler.db.addOutgoingPayment(payment) val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret), fundingFee = payment.fundingFee) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) @@ -732,16 +842,17 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { checkDbPayment(incomingPayment, paymentHandler.db) // We have a matching transaction in our DB, but the fees must be paid with a different payment_hash. - val purchase = LiquidityAds.Purchase.Standard( + val purchase = LiquidityAds.Purchase.WithFeeCredit( defaultAmount.truncateToSatoshi() + LiquidityPolicy.minInboundLiquidityTarget, LiquidityAds.Fees(2000.sat, 3000.sat), + 250_000.msat, LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(randomBytes32())), ) val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null) paymentHandler.db.addOutgoingPayment(payment) val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret), fundingFee = payment.fundingFee) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) @@ -759,7 +870,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob doesn't accept the MPP set yet run { val add = makeUpdateAddHtlc(7, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -769,7 +880,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob now accepts the MPP set run { val add = makeUpdateAddHtlc(11, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = setOf( WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(7, incomingPayment.preimage, commit = true)), @@ -798,7 +909,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Alice sends first 2 multipart htlcs to Bob. // - Bob doesn't accept the MPP set yet listOf(add1, add2).forEach { add -> - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -807,7 +918,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Alice sends third multipart htlc to Bob // - Bob now accepts the MPP set run { - val result = paymentHandler.process(add3, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add3, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = setOf( WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(3, incomingPayment.preimage, commit = true)), @@ -823,7 +934,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) val addGreaterExpiry = add.copy(cltvExpiry = add.cltvExpiry + CltvExpiryDelta(6)) - val result = paymentHandler.process(addGreaterExpiry, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(addGreaterExpiry, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = WrappedChannelCommand(add.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true)) assertEquals(setOf(expected), result.actions.toSet()) @@ -835,18 +946,18 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // We receive a first multipart htlc. val add1 = makeUpdateAddHtlc(3, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount / 2, defaultAmount, paymentSecret)) - val result1 = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result1 = paymentHandler.process(add1, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) assertTrue(result1.actions.isEmpty()) // This htlc is reprocessed (e.g. because the wallet restarted). - val result1b = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result1b = paymentHandler.process(add1, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1b) assertTrue(result1b.actions.isEmpty()) // We receive the second multipart htlc. val add2 = makeUpdateAddHtlc(5, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount / 2, defaultAmount, paymentSecret)) - val result2 = paymentHandler.process(add2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result2 = paymentHandler.process(add2, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) assertEquals(defaultAmount, result2.received.amount) val expected = setOf( @@ -856,7 +967,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { assertEquals(expected, result2.actions.toSet()) // The second htlc is reprocessed (e.g. because our peer disconnected before we could send them the preimage). - val result2b = paymentHandler.process(add2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result2b = paymentHandler.process(add2, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2b) assertEquals(defaultAmount, result2b.received.amount) assertEquals(listOf(WrappedChannelCommand(add2.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add2.id, incomingPayment.preimage, commit = true))), result2b.actions) @@ -868,7 +979,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // We receive a first multipart htlc. val add = makeUpdateAddHtlc(1, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount / 2, defaultAmount, paymentSecret)) - val result1 = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result1 = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) assertTrue(result1.actions.isEmpty()) @@ -878,7 +989,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { assertEquals(listOf(WrappedChannelCommand(add.channelId, addTimeout)), actions1) // For some reason, the channel was offline, didn't process the failure and retransmits the htlc. - val result2 = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result2 = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) assertTrue(result2.actions.isEmpty()) @@ -888,7 +999,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // The channel was offline again, didn't process the failure and retransmits the htlc, but it is now close to its expiry. val currentBlockHeight = add.cltvExpiry.toLong().toInt() - 3 - val result3 = paymentHandler.process(add, currentBlockHeight, TestConstants.feeratePerKw) + val result3 = paymentHandler.process(add, Features.empty, currentBlockHeight, TestConstants.feeratePerKw) assertIs(result3) val addExpired = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, currentBlockHeight.toLong())), commit = true) assertEquals(listOf(WrappedChannelCommand(add.channelId, addExpired)), result3.actions) @@ -904,7 +1015,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { expirySeconds = 3600 // one hour expiration ) val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(10_000.msat, defaultAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) @@ -915,7 +1026,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { fun `invoice unknown`() = runSuspendTest { val (paymentHandler, _, _) = createFixture(defaultAmount) val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, randomBytes32(), makeMppPayload(defaultAmount, defaultAmount, randomBytes32())) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) @@ -928,7 +1039,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val cltvExpiry = CltvExpiryDelta(144).toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) val badOnion = OnionRoutingPacket(0, ByteVector("0x02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619"), randomBytes(OnionRoutingPacket.PaymentPacketLength).toByteVector(), randomBytes32()) val add = UpdateAddHtlc(randomBytes32(), 0, defaultAmount, incomingPayment.paymentHash, cltvExpiry, badOnion) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) // The current flow of error checking within the codebase would be: @@ -945,7 +1056,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) val lowExpiry = CltvExpiryDelta(2) val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount / 2, defaultAmount, paymentSecret, lowExpiry)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) @@ -963,7 +1074,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { ) payloads.forEach { payload -> val add = makeUpdateAddHtlc(3, randomBytes32(), paymentHandler, incomingPayment.paymentHash, payload) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(payload.totalAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) @@ -982,7 +1093,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob doesn't accept the MPP set yet run { val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -994,7 +1105,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val payload = makeMppPayload(amount2, totalAmount + MilliSatoshi(1), paymentSecret) val add = makeUpdateAddHtlc(2, channelId, paymentHandler, incomingPayment.paymentHash, payload) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val failure = IncorrectOrUnknownPaymentDetails(totalAmount, TestConstants.defaultBlockHeight.toLong()) val expected = setOf( @@ -1022,7 +1133,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob doesn't accept the MPP set yet run { val add = makeUpdateAddHtlc(1, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -1033,7 +1144,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val payload = makeMppPayload(amount2, totalAmount, randomBytes32()) // <--- invalid payment secret val add = makeUpdateAddHtlc(1, randomBytes32(), paymentHandler, incomingPayment.paymentHash, payload) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(totalAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) @@ -1051,7 +1162,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { listOf(1L, 2L).forEach { id -> val add = makeUpdateAddHtlc(id, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(10_000.msat, defaultAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -1090,7 +1201,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Alice sends single (unfinished) multipart htlc to Bob. run { val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -1108,7 +1219,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Alice tries again, and sends another single (unfinished) multipart htlc to Bob. run { val add = makeUpdateAddHtlc(3, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -1118,7 +1229,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob accepts htlc set run { val add = makeUpdateAddHtlc(4, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = setOf( WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(3, incomingPayment.preimage, commit = true)), @@ -1142,11 +1253,11 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 1 of 2: // - Alice receives complete mpp set run { - val result1 = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result1 = paymentHandler.process(htlc1, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) assertTrue(result1.actions.isEmpty()) - val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result2 = paymentHandler.process(htlc2, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) val expected = setOf( @@ -1159,7 +1270,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 2 of 2: // - Alice receives local replay of htlc1 for the invoice she already completed. Must be fulfilled. run { - val result = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(htlc1, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = WrappedChannelCommand(channelId1, ChannelCommand.Htlc.Settlement.Fulfill(htlc1.id, incomingPayment.preimage, commit = true)) assertEquals(setOf(expected), result.actions.toSet()) @@ -1180,11 +1291,11 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 1 of 2: // - Alice receives complete mpp set run { - val result1 = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result1 = paymentHandler.process(htlc1, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) assertTrue(result1.actions.isEmpty()) - val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result2 = paymentHandler.process(htlc2, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) val expected = setOf( @@ -1198,7 +1309,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Alice receives an additional htlc (with new id) on channel1 for the invoice she already completed. Must be rejected. run { val add = htlc1.copy(id = 3) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = WrappedChannelCommand( channelId1, ChannelCommand.Htlc.Settlement.Fail( @@ -1214,7 +1325,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val channelId3 = randomBytes32() val add = htlc2.copy(channelId = channelId3) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = WrappedChannelCommand( channelId3, ChannelCommand.Htlc.Settlement.Fail( @@ -1284,7 +1395,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val cltvExpiry = TestConstants.Bob.nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, defaultAmount, defaultAmount, cltvExpiry, preimage = preimage) val add = makeUpdateAddHtlc(8, randomBytes32(), paymentHandler, paymentHash, finalPayload, route.blindingKey) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fulfill(add.id, preimage, commit = true) @@ -1313,7 +1424,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, amount1, totalAmount, cltvExpiry, preimage = preimage) val add = makeUpdateAddHtlc(0, channelId, paymentHandler, paymentHash, finalPayload, route.blindingKey) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) @@ -1325,7 +1436,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, amount2, totalAmount, cltvExpiry, preimage = preimage) val add = makeUpdateAddHtlc(1, channelId, paymentHandler, paymentHash, finalPayload, route.blindingKey) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off @@ -1348,7 +1459,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val cltvExpiry = TestConstants.Bob.nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, defaultAmount, defaultAmount, cltvExpiry, preimage = preimage) val willAddHtlc = makeWillAddHtlc(paymentHandler, paymentHash, finalPayload, route.blindingKey) - val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertEquals(1, result.actions.size) val addLiquidity = result.actions.first() @@ -1367,9 +1478,10 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val paymentHash = Crypto.sha256(preimage).toByteVector32() // We have a matching transaction in our DB, but the fees must be paid with a different payment_hash. - val purchase = LiquidityAds.Purchase.Standard( + val purchase = LiquidityAds.Purchase.WithFeeCredit( defaultAmount.truncateToSatoshi() + LiquidityPolicy.minInboundLiquidityTarget, LiquidityAds.Fees(2000.sat, 3000.sat), + 500.msat, LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(listOf(preimage)), ) val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null) @@ -1378,7 +1490,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val cltvExpiry = TestConstants.Bob.nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, defaultAmount, defaultAmount, cltvExpiry, preimage = preimage) val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, paymentHash, finalPayload, route.blindingKey, payment.fundingFee) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val fulfill = ChannelCommand.Htlc.Settlement.Fulfill(add.id, preimage, commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, fulfill)), result.actions.toSet()) @@ -1395,7 +1507,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val cltvExpiry = TestConstants.Bob.nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) val (blindedPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, defaultAmount, defaultAmount, cltvExpiry, preimage = incomingPayment.preimage) val add = makeUpdateAddHtlc(8, randomBytes32(), paymentHandler, incomingPayment.paymentHash, blindedPayload, route.blindingKey) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket)) @@ -1419,7 +1531,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, amount1, totalAmount, cltvExpiry, preimage = preimage) val add = makeUpdateAddHtlc(0, channelId, paymentHandler, paymentHash, finalPayload, route.blindingKey) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) @@ -1430,7 +1542,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob rejects that htlc (the first htlc will be rejected after the MPP timeout) run { val add = makeUpdateAddHtlc(1, channelId, paymentHandler, paymentHash, makeMppPayload(amount2, totalAmount, randomBytes32())) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(totalAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) @@ -1446,7 +1558,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val amountTooLow = metadata.amount - 10_000_000.msat val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, amountTooLow, amountTooLow, cltvExpiry, pathId) val add = makeUpdateAddHtlc(8, randomBytes32(), paymentHandler, metadata.paymentHash, finalPayload, route.blindingKey) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket)) @@ -1462,7 +1574,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val pathId = metadata.toPathId(TestConstants.Bob.nodeParams.nodePrivateKey) val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, metadata.amount, metadata.amount, cltvExpiry, pathId) val add = makeUpdateAddHtlc(8, randomBytes32(), paymentHandler, metadata.paymentHash.reversed(), finalPayload, route.blindingKey) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket)) @@ -1474,6 +1586,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val defaultPreimage = randomBytes32() val defaultPaymentHash = Crypto.sha256(defaultPreimage).toByteVector32() val defaultAmount = 150_000_000.msat + val feeCreditFeatures = Features(Feature.ExperimentalSplice to FeatureSupport.Optional, Feature.OnTheFlyFunding to FeatureSupport.Optional, Feature.FundingFeeCredit to FeatureSupport.Optional) private fun channelHops(destination: PublicKey): List { val dummyKey = PrivateKey(ByteVector32("0101010101010101010101010101010101010101010101010101010101010101")).publicKey() @@ -1602,5 +1715,13 @@ 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 paymentHandler = IncomingPaymentHandler(nodeParams, InMemoryPaymentsDb(), TestConstants.fundingRates) + val (incomingPayment, paymentSecret) = makeIncomingPayment(paymentHandler, invoiceAmount) + return Triple(paymentHandler, incomingPayment, paymentSecret) + } } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt index 7ac41825e..8a47103d0 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt @@ -476,9 +476,9 @@ class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { } // Bob receives these 2 HTLCs. - val process1 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[0].first, adds[0].second, 3), TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val process1 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[0].first, adds[0].second, 3), Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertTrue(process1 is IncomingPaymentHandler.ProcessAddResult.Pending) - val process2 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[1].first, adds[1].second, 5), TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + val process2 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[1].first, adds[1].second, 5), Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertTrue(process2 is IncomingPaymentHandler.ProcessAddResult.Accepted) val fulfills = process2.actions.filterIsInstance().mapNotNull { it.channelCommand as? ChannelCommand.Htlc.Settlement.Fulfill } assertEquals(2, fulfills.size) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt index 07acb9573..6f58997a5 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -381,7 +381,7 @@ class LightningCodecsTestsCommon : LightningTestSuite() { val fundingLease = LiquidityAds.FundingRate(500_000.sat, 5_000_000.sat, 1100, 75, 0.sat) val requestFunds = LiquidityAds.RequestFunding(750_000.sat, fundingLease, LiquidityAds.PaymentDetails.FromChannelBalance) val fundingScript = Helpers.Funding.makeFundingPubKeyScript(publicKey(1), publicKey(1)) - val willFund = LiquidityAds.WillFundRates(listOf(fundingLease), setOf(LiquidityAds.PaymentType.FromChannelBalance)).validateRequest(nodeKey, fundingScript, FeeratePerKw(5000.sat), requestFunds)!!.willFund + val willFund = LiquidityAds.WillFundRates(listOf(fundingLease), setOf(LiquidityAds.PaymentType.FromChannelBalance)).validateRequest(nodeKey, fundingScript, FeeratePerKw(5000.sat), requestFunds, 0.msat)!!.willFund // @formatter:off val defaultAccept = AcceptDualFundedChannel(ByteVector32.One, 50_000.sat, 473.sat, 100_000_000, 1.msat, 6, CltvExpiryDelta(144), 50, publicKey(1), point(2), point(3), point(4), point(5), point(6), publicKey(7)) val defaultEncoded = ByteVector("0041 0100000000000000000000000000000000000000000000000000000000000000 000000000000c350 00000000000001d9 0000000005f5e100 0000000000000001 00000006 0090 0032 031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f 024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766 02531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337 03462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b 0362c0a046dacce86ddd0343c6d3c7c79c2208ba0d9c9cf24a6d046d21d21f90f7 03f006a18d5653c4edf5391ff23a61f03ff83d237e880ee61187fa9f379a028e0a 02989c0b76cb563971fdc9bef31ec06c3560f3249d6ee9e5d83c57625596e05f6f") @@ -390,6 +390,8 @@ class LightningCodecsTestsCommon : LightningTestSuite() { defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.UnsupportedChannelType(Features(Feature.StaticRemoteKey to FeatureSupport.Mandatory))))) to (defaultEncoded + ByteVector("01021000")), defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.UpfrontShutdownScriptTlv(ByteVector("01abcdef")), ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs))) to (defaultEncoded + ByteVector("000401abcdef 0103101000")), defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.ProvideFundingTlv(willFund))) to (defaultEncoded + ByteVector("0103101000 fd053b740007a120004c4b40044c004b00000000002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c35962783e077e3c5214ba829752be2a3994a7c5e0e9d735ef5a9dab3ce1d6dda6282c3252b20af52e58c33c0e164167fd59e19114a8a8f9eb76b33008205dcb6")), + defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.FeeCreditUsedTlv(0.msat))) to (defaultEncoded + ByteVector("0103101000 fda05200")), + defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.FeeCreditUsedTlv(1729.msat))) to (defaultEncoded + ByteVector("0103101000 fda0520206c1")), defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.PushAmountTlv(1729.msat))) to (defaultEncoded + ByteVector("0103101000 fe470000070206c1")), defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.RequireConfirmedInputsTlv)) to (defaultEncoded + ByteVector("0103101000 0200")), defaultAccept.copy(tlvStream = TlvStream(setOf(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs)), setOf(GenericTlv(113, ByteVector("deadbeef"))))) to (defaultEncoded + ByteVector("0103101000 7104deadbeef")), @@ -543,6 +545,8 @@ class LightningCodecsTestsCommon : LightningTestSuite() { SpliceAck(channelId, 0.sat, fundingPubkey) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), SpliceAck(channelId, (-25_000).sat, fundingPubkey) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ffffffffffff9e58 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), SpliceAck(channelId, 25_000.sat, 0.msat, fundingPubkey, LiquidityAds.WillFund(fundingRate, ByteVector("deadbeef"), ByteVector64.Zeroes)) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b56000186a0000186a001900096000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), + SpliceAck(channelId, 25_000.sat, fundingPubkey, TlvStream(ChannelTlv.FeeCreditUsedTlv(0.msat))) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fda05200"), + SpliceAck(channelId, 25_000.sat, fundingPubkey, TlvStream(ChannelTlv.FeeCreditUsedTlv(1729.msat))) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fda0520206c1"), SpliceLocked(channelId, fundingTxId) to ByteVector("908c aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 24e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566"), // @formatter:on ) @@ -849,6 +853,28 @@ 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"), + // @formatter:on + ) + testCases.forEach { + val decoded = LightningMessage.decode(it.second) + assertNotNull(decoded) + assertEquals(it.first, decoded) + val encoded = LightningMessage.encode(decoded) + assertContentEquals(it.second, encoded) + } + } + @Test fun `encode - decode phoenix-android-legacy-info messages`() { val testCases = listOf( diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LiquidityAdsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LiquidityAdsTestsCommon.kt index b94138c7b..b486a57bc 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LiquidityAdsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LiquidityAdsTestsCommon.kt @@ -10,6 +10,7 @@ import fr.acinq.lightning.channel.InvalidLiquidityAdsAmount import fr.acinq.lightning.channel.InvalidLiquidityAdsSig import fr.acinq.lightning.channel.MissingLiquidityAds import fr.acinq.lightning.tests.utils.LightningTestSuite +import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat import kotlin.test.Test import kotlin.test.assertEquals @@ -32,7 +33,7 @@ class LiquidityAdsTestsCommon : LightningTestSuite() { val request = LiquidityAds.RequestFunding.chooseRate(500_000.sat, LiquidityAds.PaymentDetails.FromChannelBalance, fundingRates) assertNotNull(request) val fundingScript = ByteVector.fromHex("00202395c9c52c02ca069f1d56a3c6124bf8b152a617328c76e6b31f83ace370c2ff") - val willFund = fundingRates.validateRequest(nodeKey, fundingScript, FeeratePerKw(1000.sat), request)?.willFund + val willFund = fundingRates.validateRequest(nodeKey, fundingScript, FeeratePerKw(1000.sat), request, 0.msat)?.willFund assertNotNull(willFund) assertEquals(fundingScript, willFund.fundingScript) assertEquals(fundingRate, willFund.fundingRate) @@ -48,7 +49,7 @@ class LiquidityAdsTestsCommon : LightningTestSuite() { TestCase(0.sat, willFund, failure = InvalidLiquidityAdsAmount(channelId, 0.sat, 500_000.sat)), ) testCases.forEach { - val result = request.validateRemoteFunding(nodeKey.publicKey(), channelId, fundingScript, it.remoteFundingAmount, FeeratePerKw(2500.sat), it.willFund) + val result = request.validateRemoteFunding(nodeKey.publicKey(), channelId, fundingScript, it.remoteFundingAmount, FeeratePerKw(2500.sat), 0.msat, it.willFund) assertEquals(it.failure, result.left) } }