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/NodeEvents.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt index 098b225ed..f72ab80c8 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt @@ -50,7 +50,7 @@ sealed interface LiquidityEvents : NodeEvents { } data object ChannelFundingInProgress : Reason() data object NoMatchingFundingRate : Reason() - data class MissingOffChainAmountTooLow(val missingOffChainAmount: MilliSatoshi) : Reason() + data class MissingOffChainAmountTooLow(val missingOffChainAmount: MilliSatoshi, val currentFeeCredit: MilliSatoshi) : Reason() data class TooManyParts(val parts: Int) : Reason() } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index 9c158f1b2..aaa75d4da 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -232,7 +232,15 @@ data class NodeParams( maxPaymentAttempts = 5, zeroConfPeers = emptySet(), paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(75), CltvExpiryDelta(200)), - liquidityPolicy = MutableStateFlow(LiquidityPolicy.Auto(inboundLiquidityTarget = null, maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false)), + liquidityPolicy = MutableStateFlow( + LiquidityPolicy.Auto( + inboundLiquidityTarget = null, + maxAbsoluteFee = 2_000.sat, + maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, + skipAbsoluteFeeCheck = false, + maxAllowedFeeCredit = 0.msat + ) + ), minFinalCltvExpiryDelta = Bolt11Invoice.DEFAULT_MIN_FINAL_EXPIRY_DELTA, maxFinalCltvExpiryDelta = CltvExpiryDelta(360), bolt12invoiceExpiry = 60.seconds, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt index 25e2a90e3..a4086d9b4 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt @@ -87,7 +87,15 @@ sealed class ChannelCommand { data class UpdateFee(val feerate: FeeratePerKw, val commit: Boolean = false) : Commitment(), ForbiddenDuringSplice, ForbiddenDuringQuiescence data object CheckHtlcTimeout : Commitment() sealed class Splice : Commitment() { - data class Request(val replyTo: CompletableDeferred, val spliceIn: SpliceIn?, val spliceOut: SpliceOut?, val requestRemoteFunding: LiquidityAds.RequestFunding?, val feerate: FeeratePerKw, val origins: List) : Splice() { + data class Request( + val replyTo: CompletableDeferred, + val spliceIn: SpliceIn?, + val spliceOut: SpliceOut?, + val requestRemoteFunding: LiquidityAds.RequestFunding?, + val currentFeeCredit: MilliSatoshi, + val feerate: FeeratePerKw, + val origins: List + ) : Splice() { val pushAmount: MilliSatoshi = spliceIn?.pushAmount ?: 0.msat val spliceOutputs: List = spliceOut?.let { listOf(TxOut(it.amount, it.scriptPubKey)) } ?: emptyList() @@ -110,7 +118,7 @@ sealed class ChannelCommand { ) : Response() sealed class Failure : Response() { - data object InsufficientFunds : Failure() + data class InsufficientFunds(val balanceAfterFees: MilliSatoshi, val liquidityFees: MilliSatoshi, val currentFeeCredit: MilliSatoshi) : Failure() data object InvalidSpliceOutPubKeyScript : Failure() data object SpliceAlreadyInProgress : Failure() data object ConcurrentRemoteSplice : Failure() diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt index 4ff07bdfd..83a426763 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 f2c98a21a..99f010425 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt @@ -389,31 +389,29 @@ data class Normal( paysCommitTxFees -> Transactions.commitTxFee(commitments.params.remoteParams.dustLimit, parentCommitment.remoteCommit.spec) else -> 0.sat } - if (parentCommitment.localCommit.spec.toLocal + fundingContribution.toMilliSatoshi() < parentCommitment.localChannelReserve(commitments.params).max(commitTxFees)) { - logger.warning { "cannot do splice: insufficient funds" } - 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) + val liquidityFees = when (val requestRemoteFunding = spliceStatus.command.requestRemoteFunding) { + null -> 0.msat + else -> when (requestRemoteFunding.paymentDetails.paymentType) { + LiquidityAds.PaymentType.FromChannelBalance -> requestRemoteFunding.fees(spliceStatus.command.feerate, isChannelCreation = false).total.toMilliSatoshi() + LiquidityAds.PaymentType.FromChannelBalanceForFutureHtlc -> requestRemoteFunding.fees(spliceStatus.command.feerate, isChannelCreation = false).total.toMilliSatoshi() + // Liquidity fees will be deducted from future HTLCs instead of being paid immediately. + LiquidityAds.PaymentType.FromFutureHtlc -> 0.msat + LiquidityAds.PaymentType.FromFutureHtlcWithPreimage -> 0.msat + is LiquidityAds.PaymentType.Unknown -> 0.msat } - Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) + } + val liquidityFeesOwed = (liquidityFees - spliceStatus.command.currentFeeCredit).max(0.msat) + val balanceAfterFees = parentCommitment.localCommit.spec.toLocal + fundingContribution.toMilliSatoshi() - liquidityFeesOwed + if (balanceAfterFees < parentCommitment.localChannelReserve(commitments.params).max(commitTxFees)) { + logger.warning { "cannot do splice: insufficient funds (balanceAfterFees=$balanceAfterFees, liquidityFees=$liquidityFees, feeCredit=${spliceStatus.command.currentFeeCredit})" } + spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds(balanceAfterFees, liquidityFees, spliceStatus.command.currentFeeCredit)) + val action = listOf(ChannelAction.Message.Send(TxAbort(channelId, InvalidSpliceRequest(channelId).message))) + Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), action) } else if (spliceStatus.command.spliceOut?.scriptPubKey?.let { Helpers.Closing.isValidFinalScriptPubkey(it, allowAnySegwit = true) } == false) { logger.warning { "cannot do splice: invalid splice-out script" } spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InvalidSpliceOutPubKeyScript) - val actions = buildList { - add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceRequest(channelId).message))) - 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, isChannelCreation = false).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) + val action = listOf(ChannelAction.Message.Send(TxAbort(channelId, InvalidSpliceRequest(channelId).message))) + Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), action) } else { val spliceInit = SpliceInit( channelId, @@ -522,6 +520,7 @@ data class Normal( cmd.message.fundingContribution, spliceStatus.spliceInit.feerate, isChannelCreation = false, + cmd.message.feeCreditUsed, cmd.message.willFund, )) { is Either.Left -> { @@ -551,6 +550,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 -> { @@ -855,19 +857,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, isChannelCreation = false).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() - is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> request.fees(splice.feerate, isChannelCreation = false).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 baced6109..0148a6011 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt @@ -59,13 +59,22 @@ data class WaitForAcceptChannel( accept.fundingAmount, lastSent.fundingFeerate, isChannelCreation = true, + 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 ed30eabbb..205acfb63 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, isChannelCreation = true) + else -> fundingRates.validateRequest(staticParams.nodeParams.nodePrivateKey, fundingScript, open.fundingFeerate, requestFunding, isChannelCreation = true, 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 411dc372e..3698dfe48 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 amountReceived: 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 46e6dd5d5..0144026ca 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -66,6 +66,34 @@ data class AddLiquidityForIncomingPayment(val paymentAmount: MilliSatoshi, val r val paymentHash: ByteVector32 = Crypto.sha256(preimage.toByteArray()).byteVector32() fun fees(fundingFeerate: FeeratePerKw, isChannelCreation: Boolean): LiquidityAds.Fees = fundingRate.fees(fundingFeerate, requestedAmount, requestedAmount, isChannelCreation) + + companion object { + /** + * When we open a new channel without contributing any input, we won't be able to pay on-chain fees for our + * weight of the funding transaction. If we don't do anything, the resulting transaction will thus have a + * lower feerate than requested and may not confirm. + * + * To avoid that, we ask our peer to target a higher feerate than the one we actually want. They will pay + * more mining fees to satisfy that feerate, while we won't pay any mining fees. We should be paying for the + * shared output, which doesn't add too much weight, so we add 25%. This is hacky but should result in an + * effective feerate that is somewhat close to the initial feerate we wanted. Note that we will pay liquidity + * fees based on this inflated feerate, which will refund our peer for this hack. + */ + const val ChannelOpenFeerateRatio = 1.25 + + /** + * When our balance is low, we won't be able to pay the mining fees for our weight of the splice transaction. + * If we don't do anything, the resulting transaction will thus have a lower feerate than requested and may + * not confirm. + * + * To avoid that, we ask our peer to target a higher feerate than the one we actually want. They will pay more + * mining fees to satisfy that feerate, while we'll pay whatever we can from our current balance. We should be + * paying for the shared input and shared output, which is a lot of weight, so we add 50%. This is hacky but + * should result in an effective feerate that is somewhat close to the initial feerate we wanted. Note that we + * will pay liquidity fees based on the inflated feerate, which will refund our peer for this hack. + */ + const val SpliceWithNoBalanceFeerateRatio = 1.5 + } } data class PeerConnection(val id: Long, val output: Channel, val logger: MDCLogger) { @@ -212,6 +240,7 @@ class Peer( val onChainFeeratesFlow = MutableStateFlow(null) val peerFeeratesFlow = MutableStateFlow(null) val remoteFundingRates = MutableStateFlow(null) + val feeCreditFlow = MutableStateFlow(0.msat) private val _channelLogger = nodeParams.loggerFactory.newLogger(ChannelState::class) private suspend fun ChannelState.process(cmd: ChannelCommand): Pair> { @@ -614,6 +643,7 @@ class Peer( spliceIn = null, spliceOut = ChannelCommand.Commitment.Splice.Request.SpliceOut(amount, scriptPubKey), requestRemoteFunding = null, + currentFeeCredit = feeCreditFlow.value, feerate = feerate, origins = listOf(), ) @@ -633,6 +663,7 @@ class Peer( spliceIn = null, spliceOut = null, requestRemoteFunding = null, + currentFeeCredit = feeCreditFlow.value, feerate = feerate, origins = listOf(), ) @@ -651,6 +682,7 @@ class Peer( spliceIn = null, spliceOut = null, requestRemoteFunding = LiquidityAds.RequestFunding(amount, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance), + currentFeeCredit = feeCreditFlow.value, feerate = feerate, origins = listOf(), ) @@ -905,9 +937,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, remoteFundingRates.value) - is Either.Left -> incomingPaymentHandler.process(item.value, currentBlockHeight, currentFeerate, remoteFundingRates.value) + is Either.Right -> incomingPaymentHandler.process(item.value, theirInit!!.features, currentBlockHeight, currentFeerate, remoteFundingRates.value, currentFeeCredit) + is Either.Left -> incomingPaymentHandler.process(item.value, theirInit!!.features, currentBlockHeight, currentFeerate, remoteFundingRates.value, currentFeeCredit) } when (result) { is IncomingPaymentHandler.ProcessAddResult.Accepted -> { @@ -1019,6 +1052,11 @@ class Peer( is RecommendedFeerates -> { peerFeeratesFlow.value = msg } + is CurrentFeeCredit -> { + if (nodeParams.features.hasFeature(Feature.FundingFeeCredit)) { + feeCreditFlow.value = msg.amount + } + } is Ping -> { val pong = Pong(ByteVector(ByteArray(msg.pongLength))) peerConnection?.send(pong) @@ -1250,6 +1288,7 @@ class Peer( spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(cmd.walletInputs), spliceOut = null, requestRemoteFunding = null, + currentFeeCredit = feeCreditFlow.value, feerate = feerate, origins = listOf(Origin.OnChainWallet(cmd.walletInputs.map { it.outPoint }.toSet(), cmd.totalAmount.toMilliSatoshi(), ChannelManagementFees(fee, 0.sat))) ) @@ -1345,6 +1384,7 @@ class Peer( is AddLiquidityForIncomingPayment -> { val currentFeerates = peerFeeratesFlow.filterNotNull().first() val paymentTypes = remoteFundingRates.value?.paymentTypes ?: setOf() + 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. @@ -1353,7 +1393,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, isChannelCreation = false).total -> { + localBalance + currentFeeCredit >= localMiningFee + cmd.fees(fundingFeerate, isChannelCreation = false).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))) @@ -1362,13 +1402,8 @@ class Peer( val targetFeerate = when { localBalance >= localMiningFee * 0.75 -> fundingFeerate // Our current balance is too low to pay the mining fees for our weight of the splice transaction. - // If we don't do anything, the resulting transaction will thus have a lower feerate than requested and may not confirm. - // To avoid that, we ask our peer to target a higher feerate than the one we actually want. - // They will pay more mining fees to satisfy that feerate, while we'll pay whatever we can from our current balance. - // We should be paying for the shared input and shared output, which is a lot of weight, so we add 50%. - // This is hacky but should result in an effective feerate that is somewhat close to the initial feerate we wanted. - // Note that we will pay liquidity fees based on the target feerate, which will refund our peer for this hack. - else -> fundingFeerate * 1.5 + // We target a higher feerate so that the effective feerate isn't too low compared to our target. + else -> fundingFeerate * AddLiquidityForIncomingPayment.SpliceWithNoBalanceFeerateRatio } // We cannot pay the liquidity fees from our channel balance, so we fall back to future HTLCs. val paymentDetails = when { @@ -1394,6 +1429,7 @@ class Peer( spliceIn = null, spliceOut = null, requestRemoteFunding = LiquidityAds.RequestFunding(cmd.requestedAmount, cmd.fundingRate, paymentDetails), + currentFeeCredit = currentFeeCredit, feerate = targetFeerate, origins = listOf(Origin.OffChainPayment(cmd.preimage, cmd.paymentAmount, totalFees)) ) @@ -1409,8 +1445,7 @@ class Peer( val channelFlags = ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = true) // Since we don't have inputs to contribute, we're unable to pay on-chain fees for the shared output. // We target a higher feerate so that the effective feerate isn't too low compared to our target. - // We only need to cover the shared output, which doesn't add too much weight, so we add 25%. - val fundingFeerate = currentFeerates.fundingFeerate * 1.25 + val fundingFeerate = currentFeerates.fundingFeerate * AddLiquidityForIncomingPayment.ChannelOpenFeerateRatio // We don't pay any local on-chain fees, our fee is only for the liquidity lease. val leaseFees = cmd.fees(fundingFeerate, isChannelCreation = true) val totalFees = ChannelManagementFees(miningFee = leaseFees.miningFee, serviceFee = leaseFees.serviceFee) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index 9180e14ef..1ad78651c 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -150,26 +150,26 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) { } /** 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, remoteFundingRates: LiquidityAds.WillFundRates?): ProcessAddResult { - return process(Either.Right(htlc), currentBlockHeight, currentFeerate, remoteFundingRates) + suspend fun process(htlc: UpdateAddHtlc, remoteFeatures: Features, currentBlockHeight: Int, currentFeerate: FeeratePerKw, remoteFundingRates: LiquidityAds.WillFundRates?, currentFeeCredit: MilliSatoshi = 0.msat): ProcessAddResult { + return process(Either.Right(htlc), remoteFeatures, currentBlockHeight, currentFeerate, remoteFundingRates, currentFeeCredit) } /** Process an incoming on-the-fly funding request. */ - suspend fun process(htlc: WillAddHtlc, currentBlockHeight: Int, currentFeerate: FeeratePerKw, remoteFundingRates: LiquidityAds.WillFundRates?): ProcessAddResult { - return process(Either.Left(htlc), currentBlockHeight, currentFeerate, remoteFundingRates) + suspend fun process(htlc: WillAddHtlc, remoteFeatures: Features, currentBlockHeight: Int, currentFeerate: FeeratePerKw, remoteFundingRates: LiquidityAds.WillFundRates?, currentFeeCredit: MilliSatoshi = 0.msat): ProcessAddResult { + return process(Either.Left(htlc), remoteFeatures, currentBlockHeight, currentFeerate, remoteFundingRates, currentFeeCredit) } - private suspend fun process(htlc: Either, currentBlockHeight: Int, currentFeerate: FeeratePerKw, remoteFundingRates: LiquidityAds.WillFundRates?): ProcessAddResult { + private suspend fun process(htlc: Either, remoteFeatures: Features, currentBlockHeight: Int, currentFeerate: FeeratePerKw, remoteFundingRates: LiquidityAds.WillFundRates?, 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, remoteFundingRates) + is Either.Right -> processPaymentPart(res.value, remoteFeatures, currentBlockHeight, currentFeerate, remoteFundingRates, currentFeeCredit) } } /** Main payment processing, that handles payment parts. */ - private suspend fun processPaymentPart(paymentPart: PaymentPart, currentBlockHeight: Int, currentFeerate: FeeratePerKw, remoteFundingRates: LiquidityAds.WillFundRates?): ProcessAddResult { + private suspend fun processPaymentPart(paymentPart: PaymentPart, remoteFeatures: Features, currentBlockHeight: Int, currentFeerate: FeeratePerKw, remoteFundingRates: LiquidityAds.WillFundRates?, currentFeeCredit: MilliSatoshi): ProcessAddResult { val logger = MDCLogger(logger.logger, staticMdc = paymentPart.mdc()) when (paymentPart) { is HtlcPart -> logger.info { "processing htlc part expiry=${paymentPart.htlc.cltvExpiry}" } @@ -232,7 +232,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) { 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, remoteFundingRates)) { + willAddHtlcParts.isNotEmpty() -> when (val result = validateOnTheFlyFundingRate(willAddHtlcParts.map { it.amount }.sum(), remoteFeatures, currentFeeCredit, currentFeerate, remoteFundingRates)) { is Either.Left -> { logger.warning { "rejecting on-the-fly funding: reason=${result.value.reason}" } nodeParams._nodeEvents.emit(result.value) @@ -240,19 +240,65 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) { } is Either.Right -> { val (requestedAmount, fundingRate) = result.value - val actions = listOf(AddLiquidityForIncomingPayment(payment.amountReceived, requestedAmount, fundingRate, incomingPayment.preimage, willAddHtlcParts.map { it.htlc })) - 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 = Features.canUseFeature(nodeParams.features, remoteFeatures, Feature.FundingFeeCredit) + // We may need to use a higher feerate than the current value depending on whether this is a new channel or not, + // and whether we have enough balance. We keep adding to our fee credit until we reach the worst case scenario + // in terms of fees we need to pay, otherwise we may not have enough to actually pay the liquidity fees. + val maxFeerate = currentFeerate * AddLiquidityForIncomingPayment.SpliceWithNoBalanceFeerateRatio + val maxLiquidityFees = fundingRate.fees(maxFeerate, requestedAmount, requestedAmount, isChannelCreation = true).total.toMilliSatoshi() + val maxFeeCredit = when (val policy = nodeParams.liquidityPolicy.value) { + LiquidityPolicy.Disable -> 0.msat + is LiquidityPolicy.Auto -> policy.maxAllowedFeeCredit + } + val nextFeeCredit = willAddHtlcParts.map { it.amount }.sum() + currentFeeCredit + val cannotCoverLiquidityFees = nextFeeCredit <= maxLiquidityFees + val isBelowMaxFeeCredit = nextFeeCredit <= maxFeeCredit + val assessment = featureOk && cannotCoverLiquidityFees && isBelowMaxFeeCredit + logger.info { "fee credit assessment: result=$assessment featureOk=$featureOk cannotCoverLiquidityFees=$cannotCoverLiquidityFees isBelowMaxFeeCredit=$isBelowMaxFeeCredit (nextFeeCredit=$nextFeeCredit, maxLiquidityFees=$maxLiquidityFees, maxFeeCredit=$maxFeeCredit)" } + assessment + } 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, isChannelCreation = true).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, willAddHtlcParts.map { it.htlc })) + 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)) { @@ -262,21 +308,12 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) { 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) } } } @@ -287,6 +324,19 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) { } } + 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 -> @@ -298,7 +348,13 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) { return ProcessAddResult.Rejected(actions, incomingPayment) } - private fun validateOnTheFlyFundingRate(willAddHtlcAmount: MilliSatoshi, currentFeerate: FeeratePerKw, remoteFundingRates: LiquidityAds.WillFundRates?): Either> { + private fun validateOnTheFlyFundingRate( + willAddHtlcAmount: MilliSatoshi, + remoteFeatures: Features, + currentFeeCredit: MilliSatoshi, + currentFeerate: FeeratePerKw, + remoteFundingRates: LiquidityAds.WillFundRates? + ): 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 -> { @@ -313,14 +369,17 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) { // We don't know at that point if we'll need a channel or if we already have one. // We must use the worst case fees that applies to channel creation. val fees = fundingRate.fees(currentFeerate, requestedAmount, requestedAmount, isChannelCreation = true).total + val canAddToFeeCredit = Features.canUseFeature(nodeParams.features, remoteFeatures, Feature.FundingFeeCredit) && (willAddHtlcAmount + currentFeeCredit) <= liquidityPolicy.maxAllowedFeeCredit val rejected = when { + // We never reject if we can add payments to our fee credit until making an on-chain operation becomes acceptable. + canAddToFeeCredit -> null // We only initiate on-the-fly funding if the missing amount is greater than the fees paid. // Otherwise our peer may not be able to claim the funding fees from the relayed HTLCs. - willAddHtlcAmount < fees * 2 -> LiquidityEvents.Rejected( + (willAddHtlcAmount + currentFeeCredit) < fees * 2 -> LiquidityEvents.Rejected( requestedAmount.toMilliSatoshi(), fees.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, - LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow(willAddHtlcAmount) + LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow(willAddHtlcAmount, currentFeeCredit) ) else -> liquidityPolicy.maybeReject(requestedAmount.toMilliSatoshi(), fees.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, logger) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt index 2209b2523..c620176e5 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt @@ -19,8 +19,9 @@ sealed class LiquidityPolicy { * @param maxAbsoluteFee max absolute fee * @param maxRelativeFeeBasisPoints max relative fee (all included: service fee and mining fee) (1_000 bips = 10 %) * @param skipAbsoluteFeeCheck only applies for off-chain payments, being more lax may make sense when the sender doesn't retry payments + * @param maxAllowedFeeCredit maximum amount that can be added to fee credit (see [fr.acinq.lightning.Feature.FundingFeeCredit]) */ - data class Auto(val inboundLiquidityTarget: Satoshi?, val maxAbsoluteFee: Satoshi, val maxRelativeFeeBasisPoints: Int, val skipAbsoluteFeeCheck: Boolean) : LiquidityPolicy() + data class Auto(val inboundLiquidityTarget: Satoshi?, val maxAbsoluteFee: Satoshi, val maxRelativeFeeBasisPoints: Int, val skipAbsoluteFeeCheck: Boolean, val maxAllowedFeeCredit: MilliSatoshi) : LiquidityPolicy() /** Make a decision for a particular liquidity event. */ fun maybeReject(amount: MilliSatoshi, fee: MilliSatoshi, source: LiquidityEvents.Source, logger: MDCLogger): 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 a0d4c40a1..630a42600 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 6261412d8..2ad8be1b4 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 a52873faa..91f7bb76c 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) @@ -786,6 +788,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 @@ -818,6 +821,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, ) @@ -1011,6 +1015,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( @@ -1038,6 +1043,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, ) @@ -1776,6 +1782,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 cd2a7afc5..290ac3ba6 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt @@ -10,6 +10,7 @@ import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.* import fr.acinq.lightning.transactions.Transactions import fr.acinq.lightning.utils.BitField +import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat /** @@ -187,14 +188,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, isChannelCreation: Boolean): WillFundPurchase? { + fun validateRequest(nodeKey: PrivateKey, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding, isChannelCreation: Boolean, 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, isChannelCreation), request.paymentDetails) + val purchase = when (feeCreditUsed) { + 0.msat -> Purchase.Standard(request.requestedAmount, request.fees(fundingFeerate, isChannelCreation), request.paymentDetails) + else -> Purchase.WithFeeCredit(request.requestedAmount, request.fees(fundingFeerate, isChannelCreation), feeCreditUsed, request.paymentDetails) + } WillFundPurchase(WillFund(request.fundingRate, fundingScript, sig), purchase) } else -> null @@ -252,6 +256,7 @@ object LiquidityAds { remoteFundingAmount: Satoshi, fundingFeerate: FeeratePerKw, isChannelCreation: Boolean, + feeCreditUsed: MilliSatoshi, willFund: WillFund? ): Either { return when (willFund) { @@ -266,7 +271,10 @@ object LiquidityAds { else -> { val purchasedAmount = requestedAmount.min(remoteFundingAmount) val fees = fundingRate.fees(fundingFeerate, requestedAmount, remoteFundingAmount, isChannelCreation) - 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)) + } } } } @@ -300,11 +308,12 @@ object LiquidityAds { remoteFundingAmount: Satoshi, fundingFeerate: FeeratePerKw, isChannelCreation: Boolean, + feeCreditUsed: MilliSatoshi, willFund: WillFund?, ): Either { return when (request) { null -> Either.Right(null) - else -> request.validateRemoteFunding(remoteNodeId, channelId, fundingScript, remoteFundingAmount, fundingFeerate, isChannelCreation, willFund) + else -> request.validateRemoteFunding(remoteNodeId, channelId, fundingScript, remoteFundingAmount, fundingFeerate, isChannelCreation, feeCreditUsed, willFund) } } @@ -315,6 +324,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/QuiescenceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt index 53737c775..a27a3cd25 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt @@ -520,6 +520,7 @@ class QuiescenceTestsCommon : LightningTestSuite() { spliceOut = spliceOut?.let { ChannelCommand.Commitment.Splice.Request.SpliceOut(it, Script.write(Script.pay2wpkh(Lightning.randomKey().publicKey())).byteVector()) }, feerate = FeeratePerKw(253.sat), requestRemoteFunding = null, + currentFeeCredit = 0.msat, origins = listOf(), ) } 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 b8f735bdd..6d73e55ab 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.* @@ -162,7 +161,7 @@ class SpliceTestsCommon : LightningTestSuite() { val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) val bobStfu = actionsBob2.findOutgoingMessage() val (_, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(bobStfu)) - actionsAlice3.findOutgoingMessage() + actionsAlice3.findOutgoingMessage() runBlocking { val response = cmd.replyTo.await() assertIs(response) @@ -194,7 +193,7 @@ class SpliceTestsCommon : LightningTestSuite() { paymentTypes = setOf(LiquidityAds.PaymentType.FromChannelBalance), ) val liquidityRequest = LiquidityAds.RequestFunding(200_000.sat, fundingRates.findRate(200_000.sat)!!, LiquidityAds.PaymentDetails.FromChannelBalance) - val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf()) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, 0.msat, FeeratePerKw(1000.sat), listOf()) val (alice1, bob1, spliceInit) = reachQuiescent(cmd, alice, bob) assertEquals(spliceInit.requestFunding, liquidityRequest) // Alice's contribution is negative: she needs to pay on-chain fees for the splice. @@ -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!!, isChannelCreation = false)?.willFund + val willFund = fundingRates.validateRequest(bob.staticParams.nodeParams.nodePrivateKey, fundingScript, cmd.feerate, spliceInit.requestFunding!!, isChannelCreation = false, 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!!, isChannelCreation = false, 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!!, isChannelCreation = false)?.willFund + val willFund = fundingRates.validateRequest(bob.staticParams.nodeParams.nodePrivateKey, ByteVector("deadbeef"), cmd.feerate, spliceInit.requestFunding!!, isChannelCreation = false, 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,86 +242,138 @@ 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, 1000.sat) + val (alice, bob) = reachNormal(channelType = ChannelType.SupportedChannelType.AnchorOutputsZeroReserve, 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% */, 0.sat, 1000.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), isChannelCreation = false).total) - val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf()) + assertEquals(10_000.sat, liquidityRequest.fees(FeeratePerKw(1000.sat), isChannelCreation = false).total) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, 0.msat, FeeratePerKw(1000.sat), listOf()) val (bob1, actionsBob1) = bob.process(cmd) val bobStfu = actionsBob1.findOutgoingMessage() val (_, 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)) + assertIs(bob2.state) + assertEquals(SpliceStatus.Aborted, bob2.state.spliceStatus) + actionsBob2.hasOutgoingMessage() + runBlocking { + val response = cmd.replyTo.await() + assertIs(response) + assertEquals(10_000_000.msat, response.liquidityFees) + } + val (bob3, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(TxAbort(bob.channelId, SpliceAborted(bob.channelId).message))) + assertIs(bob3.state) + assertEquals(SpliceStatus.None, bob3.state.spliceStatus) + assertTrue(actionsBob3.isEmpty()) } 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), isChannelCreation = false).total) - val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf()) + val liquidityRequest = LiquidityAds.RequestFunding(900_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance) + assertEquals(9_000.sat, liquidityRequest.fees(FeeratePerKw(1000.sat), isChannelCreation = false).total) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, 0.msat, 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!!, isChannelCreation = false, 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), isChannelCreation = false).total) - val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf()) + assertEquals(10_000.sat, liquidityRequest.fees(FeeratePerKw(1000.sat), isChannelCreation = false).total) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, 0.msat, 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!!, isChannelCreation = false, 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, 1000.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. + // We don't have enough funds nor fee credit to pay fees from our channel balance. val fundingRequest = LiquidityAds.RequestFunding(100_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(listOf(origin.paymentHash))) - val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, fundingRequest, FeeratePerKw(1000.sat), listOf(origin)) + val currentFeeCredit = 499_999.msat + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, fundingRequest, currentFeeCredit, FeeratePerKw(1000.sat), listOf(origin)) val (bob1, actionsBob1) = bob.process(cmd) val bobStfu = actionsBob1.findOutgoingMessage() val (_, 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)) + assertIs(bob2.state) + assertEquals(SpliceStatus.Aborted, bob2.state.spliceStatus) + actionsBob2.hasOutgoingMessage() + runBlocking { + val response = cmd.replyTo.await() + assertIs(response) + assertEquals(500_000.msat, response.liquidityFees) + assertEquals(currentFeeCredit, response.currentFeeCredit) + } } run { - // We can use future HTLCs to pay fees for the liquidity we're purchasing. - val fundingRequest = LiquidityAds.RequestFunding(100_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(origin.paymentHash))) - val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, fundingRequest, FeeratePerKw(1000.sat), listOf(origin)) + // We can use our fee credit to pay fees for the liquidity we're purchasing. + val fundingRequest = LiquidityAds.RequestFunding(100_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(listOf(origin.paymentHash))) + val currentFeeCredit = 500_000.msat + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, fundingRequest, currentFeeCredit, FeeratePerKw(1000.sat), listOf(origin)) val (bob1, actionsBob1) = bob.process(cmd) val bobStfu = actionsBob1.findOutgoingMessage() val (_, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) val aliceStfu = actionsAlice1.findOutgoingMessage() val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) - assertEquals(actionsBob2.size, 1) actionsBob2.findOutgoingMessage().also { assertEquals(0.sat, it.fundingContribution) assertEquals(fundingRequest, it.requestFunding) } } + run { + // We can use future HTLCs to pay fees for the liquidity we're purchasing. + val fundingRequest = LiquidityAds.RequestFunding(100_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(origin.paymentHash))) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, fundingRequest, 0.msat, FeeratePerKw(1000.sat), listOf(origin)) + val (bob1, actionsBob1) = bob.process(cmd) + val bobStfu = actionsBob1.findOutgoingMessage() + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) + val aliceStfu = actionsAlice1.findOutgoingMessage() + 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!!, isChannelCreation = false, 0.msat)!!.willFund + val (_, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(spliceAck.copy(fundingContribution = fundingRequest.requestedAmount, tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))))) + actionsBob3.hasOutgoingMessage() + } } @Test @@ -1351,6 +1411,7 @@ class SpliceTestsCommon : LightningTestSuite() { spliceIn = null, spliceOut = ChannelCommand.Commitment.Splice.Request.SpliceOut(amount, Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector()), requestRemoteFunding = null, + currentFeeCredit = 0.msat, feerate = spliceFeerate, origins = listOf(), ) @@ -1397,6 +1458,7 @@ class SpliceTestsCommon : LightningTestSuite() { spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(alice.staticParams.nodeParams.keyManager, amounts)), spliceOut = null, requestRemoteFunding = null, + currentFeeCredit = 0.msat, feerate = spliceFeerate, origins = listOf(), ) @@ -1435,6 +1497,7 @@ class SpliceTestsCommon : LightningTestSuite() { spliceIn = null, spliceOut = null, requestRemoteFunding = null, + currentFeeCredit = 0.msat, feerate = spliceFeerate, origins = listOf(), ) @@ -1470,6 +1533,7 @@ class SpliceTestsCommon : LightningTestSuite() { spliceOut = ChannelCommand.Commitment.Splice.Request.SpliceOut(outAmount, Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector()), feerate = spliceFeerate, requestRemoteFunding = null, + currentFeeCredit = 0.msat, origins = listOf(), ) val (alice1, bob1, spliceInit) = reachQuiescent(cmd, alice, bob) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/io/peer/PeerTest.kt b/src/commonTest/kotlin/fr/acinq/lightning/io/peer/PeerTest.kt index 56032a7c6..ffcfed201 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/io/peer/PeerTest.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/io/peer/PeerTest.kt @@ -222,7 +222,7 @@ class PeerTest : LightningTestSuite() { @Test fun `swap funds into a channel`() = runSuspendTest { val nodeParams = Pair(TestConstants.Alice.nodeParams, TestConstants.Bob.nodeParams) - nodeParams.second.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = 100_000.sat, maxAbsoluteFee = 20_000.sat, maxRelativeFeeBasisPoints = 1000, skipAbsoluteFeeCheck = false)) + nodeParams.second.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = 100_000.sat, maxAbsoluteFee = 20_000.sat, maxRelativeFeeBasisPoints = 1000, skipAbsoluteFeeCheck = false, maxAllowedFeeCredit = 0.msat)) val walletParams = Pair(TestConstants.Alice.walletParams, TestConstants.Bob.walletParams) val (_, bob, _, bob2alice) = newPeers(this, nodeParams, walletParams, automateMessaging = false) @@ -248,7 +248,8 @@ class PeerTest : LightningTestSuite() { inboundLiquidityTarget = 500_000.sat, maxAbsoluteFee = 100.sat, maxRelativeFeeBasisPoints = 10, - skipAbsoluteFeeCheck = false + skipAbsoluteFeeCheck = false, + maxAllowedFeeCredit = 0.msat, ) nodeParams.second.liquidityPolicy.emit(bobPolicy) val walletBob = createWallet(nodeParams.second.keyManager, 1_000_000.sat).second diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt index 594eab8ed..a3aa4855a 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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) 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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) 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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) 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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) 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, remoteFundingRates = null) + val result = paymentHandler.process(add1, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) 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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) 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, TestConstants.fundingRates) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertEquals(1, result.actions.size) val addLiquidity = result.actions.first() @@ -258,10 +258,10 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { fun `receive will_add_htlc -- rounding without liquidity purchase`() = runSuspendTest { val paymentAmount = 555_555_555.msat val (paymentHandler, incomingPayment, paymentSecret) = createFixture(paymentAmount) - paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = 0.sat, maxAbsoluteFee = 10_000.sat, maxRelativeFeeBasisPoints = 500, skipAbsoluteFeeCheck = false)) + paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = 0.sat, maxAbsoluteFee = 10_000.sat, maxRelativeFeeBasisPoints = 500, skipAbsoluteFeeCheck = false, maxAllowedFeeCredit = 0.msat)) checkDbPayment(incomingPayment, paymentHandler.db) val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(paymentAmount, paymentAmount, paymentSecret)) - val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertEquals(1, result.actions.size) val addLiquidity = result.actions.first() @@ -282,7 +282,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, TestConstants.fundingRates) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -292,7 +292,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, TestConstants.fundingRates) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertEquals(1, result.actions.size) val addLiquidity = result.actions.first() as AddLiquidityForIncomingPayment @@ -316,7 +316,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, TestConstants.fundingRates) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -326,7 +326,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, TestConstants.fundingRates) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertEquals(1, result.actions.size) val addLiquidity = result.actions.first() as AddLiquidityForIncomingPayment @@ -352,7 +352,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, TestConstants.fundingRates) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertEquals(1, result.actions.size) val addLiquidity = result.actions.first() @@ -368,7 +368,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, TestConstants.fundingRates) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertNull(result.incomingPayment) val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(paymentHandler.nodeParams.nodePrivateKey, willAddHtlc, IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())) @@ -380,7 +380,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, TestConstants.fundingRates) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertEquals(incomingPayment, result.incomingPayment) val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(paymentHandler.nodeParams.nodePrivateKey, willAddHtlc, IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())) @@ -403,7 +403,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, TestConstants.fundingRates) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertEquals(incomingPayment, result.incomingPayment) val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(paymentHandler.nodeParams.nodePrivateKey, willAddHtlc, IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())) @@ -421,12 +421,12 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { ) val inboundLiquidityTarget = 100_000.sat assertEquals(5_000.sat, fundingRates.fundingRates.first().fees(TestConstants.feeratePerKw, inboundLiquidityTarget, inboundLiquidityTarget, isChannelCreation = false).total) - val defaultPolicy = LiquidityPolicy.Auto(inboundLiquidityTarget, maxAbsoluteFee = 5_000.sat, maxRelativeFeeBasisPoints = 500, skipAbsoluteFeeCheck = false) + val defaultPolicy = LiquidityPolicy.Auto(inboundLiquidityTarget, maxAbsoluteFee = 5_000.sat, maxRelativeFeeBasisPoints = 500, skipAbsoluteFeeCheck = false, maxAllowedFeeCredit = 0.msat) val testCases = listOf( // If payment amount is at least twice the fees, we accept the payment. Triple(defaultPolicy, 10_000_000.msat, null), // If payment is too close to the fee, we reject the payment. - Triple(defaultPolicy, 9_999_999.msat, LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow(9_999_999.msat)), + Triple(defaultPolicy, 9_999_999.msat, LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow(9_999_999.msat, 0.msat)), // If our peer doesn't advertise funding rates for the payment amount, we reject the payment. Triple(defaultPolicy, 200_000_000.msat, LiquidityEvents.Rejected.Reason.NoMatchingFundingRate), // If fee is above our liquidity policy maximum fee, we reject the payment. @@ -441,7 +441,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, fundingRates) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, fundingRates) when (failure) { null -> { assertIs(result) @@ -474,7 +474,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, TestConstants.fundingRates) + val result = paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -484,7 +484,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, TestConstants.fundingRates) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertEquals(1, result.actions.size) val addLiquidity = result.actions.first() as AddLiquidityForIncomingPayment @@ -499,7 +499,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, TestConstants.fundingRates) + val result = paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off @@ -527,7 +527,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, TestConstants.fundingRates) + val result = paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -537,8 +537,8 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob fails everything because the funding fee is too high 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, TestConstants.fundingRates) + paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(null, 100.sat, 100, skipAbsoluteFeeCheck = false, maxAllowedFeeCredit = 0.msat)) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertEquals(2, result.actions.size) val willFailHtlc = result.actions.filterIsInstance().firstOrNull()?.message @@ -553,7 +553,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, TestConstants.fundingRates) + val result = paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -563,7 +563,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, TestConstants.fundingRates) + val result = paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off @@ -582,14 +582,14 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { fun `receive multipart payment with a mix of HTLC and will_add_htlc -- too many parts`() = runSuspendTest { val channelId = randomBytes32() val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams.copy(maxAcceptedHtlcs = 5), InMemoryPaymentsDb()) - paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = null, maxAbsoluteFee = 10_000.sat, maxRelativeFeeBasisPoints = 1000, skipAbsoluteFeeCheck = false)) + paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = null, maxAbsoluteFee = 10_000.sat, maxRelativeFeeBasisPoints = 1000, skipAbsoluteFeeCheck = false, maxAllowedFeeCredit = 0.msat)) val partialAmount = 25_000_000.msat val totalAmount = partialAmount * 6 val (incomingPayment, paymentSecret) = makeIncomingPayment(paymentHandler, totalAmount) // 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, TestConstants.fundingRates).also { result -> + paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates).also { result -> assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -597,13 +597,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, TestConstants.fundingRates) + val result = paymentHandler.process(it, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) 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, TestConstants.fundingRates) + val result = paymentHandler.process(willAddHtlcs.last(), Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertEquals(6, result.actions.size) val willFailHtlcs = result.actions.filterIsInstance().map { it.message }.filterIsInstance() @@ -614,6 +614,222 @@ 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, maxAllowedFeeCredit = 50_000_000.msat) + 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, TestConstants.fundingRates, 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(100_000.sat, 50.sat, 100, skipAbsoluteFeeCheck = false, maxAllowedFeeCredit = 6_000.msat)) + + // 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, TestConstants.fundingRates, 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, TestConstants.fundingRates, 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 multipart payment with a mix of HTLC and will_add_htlc above max_allowed_fee_credit`() = runSuspendTest { + val channelId = randomBytes32() + val currentFeeCredit = 3_000_000.msat + val maxAllowedFeeCredit = 6_000_000.msat + val (amount1, amount2, amount3) = listOf(15_000_000.msat, 2_400_000.msat, 2_600_000.msat) + val totalAmount = amount1 + amount2 + amount3 + val (paymentHandler, incomingPayment, paymentSecret) = createFeeCreditFixture(totalAmount, LiquidityPolicy.Auto(100_000.sat, 5_000.sat, 500, skipAbsoluteFeeCheck = false, maxAllowedFeeCredit)) + + // 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, TestConstants.fundingRates, currentFeeCredit) + assertIs(result) + assertTrue(result.actions.isEmpty()) + } + + // Step 2 of 3: + // - Alice sends will_add_htlc to Bob + // - Bob doesn't accept the MPP set yet + run { + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, feeCreditFeatures, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates, currentFeeCredit) + assertIs(result) + assertTrue(result.actions.isEmpty()) + } + + // Step 3 of 3: + // - Alice sends will_add_htlc to Bob + // - Bob accepts the MPP set + run { + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount3, totalAmount, paymentSecret)) + // The current fee credit combined with the will_add_htlc amount can cover the liquidity fees. + // The current fee credit cannot cover the fees alone. + val expectedFees = TestConstants.fundingRates.findRate(105_000.sat)!!.fees(TestConstants.feeratePerKw, 105_000.sat, 105_000.sat, isChannelCreation = true) + assertTrue(3_500.sat <= expectedFees.total && expectedFees.total <= 4_000.sat) + val result = paymentHandler.process(willAddHtlc, feeCreditFeatures, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates, currentFeeCredit) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() + assertIs(addLiquidity) + assertEquals(totalAmount, addLiquidity.paymentAmount) + assertEquals(105_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 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, maxAllowedFeeCredit = 21_000_000.msat)) + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(totalAmount, totalAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, feeCreditFeatures, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates, currentFeeCredit) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() + assertIs(addLiquidity) + assertEquals(totalAmount, addLiquidity.paymentAmount) + assertEquals(100_001.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 until fee credit threshold is reached`() = runSuspendTest { + val policy = LiquidityPolicy.Auto(100_000.sat, 10_000.sat, 1000, skipAbsoluteFeeCheck = false, maxAllowedFeeCredit = 10_000_000.msat) + + // Step 1 of 2: + // - Alice sends will_add_htlc to Bob + // - Bob adds it to its fee credit + run { + val amount = 4_000_000.msat + // The amount is greater than the liquidity fees, but we take a safety margin before opening a channel. + val expectedFees = TestConstants.fundingRates.findRate(104_000.sat)!!.fees(TestConstants.feeratePerKw, 104_000.sat, 104_000.sat, isChannelCreation = true) + assertTrue(expectedFees.total < amount.truncateToSatoshi() && amount.truncateToSatoshi() < expectedFees.total * 2) + val (paymentHandler, incomingPayment, paymentSecret) = createFeeCreditFixture(amount, policy) + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount, amount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, feeCreditFeatures, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates, currentFeeCredit = 0.msat) + assertIs(result) + val addFeeCredit = result.actions.first() + assertIs(addFeeCredit) + assertIs(addFeeCredit.message) + assertEquals(amount, result.received.amount) + checkDbPayment(result.incomingPayment, paymentHandler.db) + } + + // Step 2 of 2: + // - Alice sends will_add_htlc to Bob + // - Bob purchases a channel using its fee credit and this additional HTLC + run { + val amount = 4_000_000.msat + val (paymentHandler, incomingPayment, paymentSecret) = createFeeCreditFixture(amount, policy) + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount, amount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, feeCreditFeatures, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates, currentFeeCredit = 4_000_000.msat) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() + assertIs(addLiquidity) + assertEquals(amount, addLiquidity.paymentAmount) + assertEquals(104_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 liquidity fees`() = runSuspendTest { + // Large payments shouldn't be added to fee credit. + val totalAmount = 10_000_000.msat + val (paymentHandler, incomingPayment, paymentSecret) = createFeeCreditFixture(totalAmount, LiquidityPolicy.Auto(100_000.sat, 5000.sat, 1000, skipAbsoluteFeeCheck = false, maxAllowedFeeCredit = 15_000_000.msat)) + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(totalAmount, totalAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, feeCreditFeatures, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates, currentFeeCredit = 0.msat) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() + assertIs(addLiquidity) + assertEquals(totalAmount, addLiquidity.paymentAmount) + assertEquals(110_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 max_allowed_fee_credit but lower than liquidity fees`() = runSuspendTest { + val totalAmount = 4_500_000.msat + val currentFeeCredit = 2_500_000.msat + val (paymentHandler, incomingPayment, paymentSecret) = createFeeCreditFixture(totalAmount, LiquidityPolicy.Auto(100_000.sat, 5000.sat, 1000, skipAbsoluteFeeCheck = false, maxAllowedFeeCredit = 5_000_000.msat)) + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(totalAmount, totalAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, feeCreditFeatures, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates, currentFeeCredit) + assertIs(result) + assertEquals(1, result.actions.size) + val willFailHtlc = result.actions.filterIsInstance().firstOrNull()?.message + assertIs(willFailHtlc) + assertEquals(willAddHtlc.id, willFailHtlc.id) + } + @Test fun `receive multipart payment with funding fee`() = runSuspendTest { val channelId = randomBytes32() @@ -627,7 +843,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, TestConstants.fundingRates) + val result = paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -637,7 +853,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, TestConstants.fundingRates) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertEquals(1, result.actions.size) val splice = result.actions.first() as AddLiquidityForIncomingPayment @@ -658,7 +874,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, TestConstants.fundingRates) + val result = paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off @@ -685,7 +901,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob triggers an open/splice val purchase = run { val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount, amount, paymentSecret)) - val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertEquals(1, result.actions.size) val splice = result.actions.first() as AddLiquidityForIncomingPayment @@ -707,7 +923,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val fundingFee = purchase.fundingFee.copy(amount = 0.msat) val htlc = makeUpdateAddHtlc(7, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount, amount, paymentSecret), fundingFee = fundingFee) assertEquals(htlc.amountMsat, amount) - val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + val result = paymentHandler.process(htlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off @@ -729,7 +945,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, TestConstants.fundingRates) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) 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()) @@ -754,7 +970,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, TestConstants.fundingRates) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) 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()) @@ -762,7 +978,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, TestConstants.fundingRates) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) 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) @@ -787,7 +1003,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, TestConstants.fundingRates) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) 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()) @@ -800,16 +1016,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(), 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, TestConstants.fundingRates) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) 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()) @@ -827,7 +1044,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -837,7 +1054,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expected = setOf( WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(7, incomingPayment.preimage, commit = true)), @@ -866,7 +1083,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -875,7 +1092,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, remoteFundingRates = null) + val result = paymentHandler.process(add3, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expected = setOf( WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(3, incomingPayment.preimage, commit = true)), @@ -891,7 +1108,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, remoteFundingRates = null) + val result = paymentHandler.process(addGreaterExpiry, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expected = WrappedChannelCommand(add.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true)) assertEquals(setOf(expected), result.actions.toSet()) @@ -903,18 +1120,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, remoteFundingRates = null) + val result1 = paymentHandler.process(add1, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) 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, remoteFundingRates = null) + val result1b = paymentHandler.process(add1, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) 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, remoteFundingRates = null) + val result2 = paymentHandler.process(add2, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result2) assertEquals(defaultAmount, result2.received.amount) val expected = setOf( @@ -924,7 +1141,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, remoteFundingRates = null) + val result2b = paymentHandler.process(add2, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result2b) assertEquals(defaultAmount, result2b.received.amount) assertEquals(listOf(WrappedChannelCommand(add2.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add2.id, incomingPayment.preimage, commit = true))), result2b.actions) @@ -936,7 +1153,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, remoteFundingRates = null) + val result1 = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result1) assertTrue(result1.actions.isEmpty()) @@ -946,7 +1163,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, remoteFundingRates = null) + val result2 = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result2) assertTrue(result2.actions.isEmpty()) @@ -956,7 +1173,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, remoteFundingRates = null) + val result3 = paymentHandler.process(add, Features.empty, currentBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) 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) @@ -972,7 +1189,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) @@ -983,7 +1200,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) @@ -996,7 +1213,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) // The current flow of error checking within the codebase would be: @@ -1013,7 +1230,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) @@ -1031,7 +1248,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) 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()) @@ -1050,7 +1267,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -1062,7 +1279,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val failure = IncorrectOrUnknownPaymentDetails(totalAmount, TestConstants.defaultBlockHeight.toLong()) val expected = setOf( @@ -1090,7 +1307,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -1101,7 +1318,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) 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()) @@ -1119,7 +1336,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -1158,7 +1375,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -1176,7 +1393,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -1186,7 +1403,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expected = setOf( WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(3, incomingPayment.preimage, commit = true)), @@ -1210,11 +1427,11 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 1 of 2: // - Alice receives complete mpp set run { - val result1 = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) + val result1 = paymentHandler.process(htlc1, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result1) assertTrue(result1.actions.isEmpty()) - val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) + val result2 = paymentHandler.process(htlc2, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result2) val expected = setOf( @@ -1227,7 +1444,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, remoteFundingRates = null) + val result = paymentHandler.process(htlc1, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expected = WrappedChannelCommand(channelId1, ChannelCommand.Htlc.Settlement.Fulfill(htlc1.id, incomingPayment.preimage, commit = true)) assertEquals(setOf(expected), result.actions.toSet()) @@ -1248,11 +1465,11 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 1 of 2: // - Alice receives complete mpp set run { - val result1 = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) + val result1 = paymentHandler.process(htlc1, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result1) assertTrue(result1.actions.isEmpty()) - val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) + val result2 = paymentHandler.process(htlc2, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result2) val expected = setOf( @@ -1266,7 +1483,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expected = WrappedChannelCommand( channelId1, ChannelCommand.Htlc.Settlement.Fail( @@ -1282,7 +1499,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val channelId3 = randomBytes32() val add = htlc2.copy(channelId = channelId3) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expected = WrappedChannelCommand( channelId3, ChannelCommand.Htlc.Settlement.Fail( @@ -1352,7 +1569,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fulfill(add.id, preimage, commit = true) @@ -1381,7 +1598,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) @@ -1393,7 +1610,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off @@ -1416,7 +1633,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, TestConstants.fundingRates) + val result = paymentHandler.process(willAddHtlc, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertEquals(1, result.actions.size) val addLiquidity = result.actions.first() @@ -1435,9 +1652,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(), 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) @@ -1446,7 +1664,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, TestConstants.fundingRates) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) val fulfill = ChannelCommand.Htlc.Settlement.Fulfill(add.id, preimage, commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, fulfill)), result.actions.toSet()) @@ -1463,7 +1681,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket)) @@ -1487,7 +1705,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) @@ -1498,7 +1716,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) 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()) @@ -1514,7 +1732,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket)) @@ -1530,7 +1748,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, remoteFundingRates = null) + val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket)) @@ -1542,6 +1760,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() @@ -1666,7 +1885,15 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { private suspend fun createFixture(invoiceAmount: MilliSatoshi?): Triple { val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) // We use a liquidity policy that accepts payment values used by default in this test file. - paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = null, maxAbsoluteFee = 5_000.sat, maxRelativeFeeBasisPoints = 500, skipAbsoluteFeeCheck = false)) + paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = null, maxAbsoluteFee = 5_000.sat, maxRelativeFeeBasisPoints = 500, skipAbsoluteFeeCheck = false, maxAllowedFeeCredit = 0.msat)) + 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()) val (incomingPayment, paymentSecret) = makeIncomingPayment(paymentHandler, invoiceAmount) return Triple(paymentHandler, incomingPayment, paymentSecret) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt index 0a7b14a91..0a4d99b5b 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt @@ -15,7 +15,7 @@ class LiquidityPolicyTestsCommon : LightningTestSuite() { @Test fun `policy rejection`() { - val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false, inboundLiquidityTarget = null) + val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false, inboundLiquidityTarget = null, maxAllowedFeeCredit = 0.msat) // fee over both absolute and relative assertEquals( expected = LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(policy.maxRelativeFeeBasisPoints), @@ -36,7 +36,7 @@ class LiquidityPolicyTestsCommon : LightningTestSuite() { @Test fun `policy rejection skip absolute check`() { - val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 1_000.sat, maxRelativeFeeBasisPoints = 5_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = true, inboundLiquidityTarget = null) + val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 1_000.sat, maxRelativeFeeBasisPoints = 5_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = true, inboundLiquidityTarget = null, maxAllowedFeeCredit = 0.msat) // fee is over absolute, and it's an offchain payment so the check passes assertNull(policy.maybeReject(amount = 4_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)) // fee is over absolute, but it's an on-chain payment so the check fails diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt index 0f5b897aa..1d3bbba2f 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt @@ -475,9 +475,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, remoteFundingRates = null) + val process1 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[0].first, adds[0].second, 3), Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertTrue(process1 is IncomingPaymentHandler.ProcessAddResult.Pending) - val process2 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[1].first, adds[1].second, 5), TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) + val process2 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[1].first, adds[1].second, 5), Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) 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 2a60e6245..32c89e500 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, 1_500.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, isChannelCreation = true)!!.willFund + val willFund = LiquidityAds.WillFundRates(listOf(fundingLease), setOf(LiquidityAds.PaymentType.FromChannelBalance)).validateRequest(nodeKey, fundingScript, FeeratePerKw(5000.sat), requestFunds, isChannelCreation = true, 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 fd053b780007a120004c4b40044c004b00000000000005dc002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103cc57cf393f6bd534472ec08cbfbbc7268501b32f563a21cdf02a99127c4f25168249acd6509f96b2e93843c3b838ee4808c75d0a15ff71ba886fda980b8ca954f")), + 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 fd053b5a000186a0000186a00190009600000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), + 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 69c619612..27a230146 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 @@ -33,7 +34,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, isChannelCreation = true)?.willFund + val willFund = fundingRates.validateRequest(nodeKey, fundingScript, FeeratePerKw(1000.sat), request, isChannelCreation = true, 0.msat)?.willFund assertNotNull(willFund) assertEquals(fundingScript, willFund.fundingScript) assertEquals(fundingRate, willFund.fundingRate) @@ -49,7 +50,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), isChannelCreation = true, it.willFund) + val result = request.validateRemoteFunding(nodeKey.publicKey(), channelId, fundingScript, it.remoteFundingAmount, FeeratePerKw(2500.sat), isChannelCreation = true, 0.msat, it.willFund) assertEquals(it.failure, result.left) } }