From 7f95eb471ec43808e063bd5deb30dcedae9d867e Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 29 May 2024 17:16:59 +0200 Subject: [PATCH] Replace `pay_to_open` with `will_add_htlc` We replace the previous pay-to-open protocol with a new protocol that only relies on liquidity ads for paying fees. We simply transmit HTLCs that cannot be relayed on existing channels with a new message called `will_add_htlc` that contains all the HTLC data. The recipient can verify that the HTLC that would match this promise is valid, and if it wishes to accept that payment, it can trigger a channel open or a splice to purchase the required inbound liquidity. Once that transaction completes, the sender will relay HTLCs matching the proposed `will_add_htlc`, which completes the payment. If the fees for the inbound liquidity purchase couldn't be paid from the previous channel balance, they can be taken from the HTLCs relayed after the funding transaction. When that happens, one side needs to trust that the other will comply. Each side can independently configure the options they're comfortable with, depending on whether they trust their peer or not. --- .../kotlin/fr/acinq/lightning/Features.kt | 23 +- .../kotlin/fr/acinq/lightning/NodeEvents.kt | 5 +- .../kotlin/fr/acinq/lightning/NodeParams.kt | 6 +- .../lightning/channel/ChannelException.kt | 2 + .../acinq/lightning/channel/InteractiveTx.kt | 37 +- .../acinq/lightning/channel/states/Normal.kt | 17 +- .../channel/states/WaitForAcceptChannel.kt | 10 +- .../fr/acinq/lightning/db/PaymentsDb.kt | 13 +- .../kotlin/fr/acinq/lightning/io/Peer.kt | 190 ++- .../payment/IncomingPaymentHandler.kt | 338 +++--- .../payment/IncomingPaymentPacket.kt | 57 +- .../payment/OutgoingPaymentPacket.kt | 13 +- .../serialization/v2/ChannelState.kt | 2 +- .../serialization/v3/ChannelState.kt | 2 +- .../fr/acinq/lightning/wire/ChannelTlv.kt | 14 - .../kotlin/fr/acinq/lightning/wire/HtlcTlv.kt | 37 + .../acinq/lightning/wire/LightningMessages.kt | 201 ++-- .../fr/acinq/lightning/wire/LiquidityAds.kt | 4 + .../channel/InteractiveTxTestsCommon.kt | 116 +- .../channel/states/NormalTestsCommon.kt | 19 +- .../channel/states/SpliceTestsCommon.kt | 53 +- .../acinq/lightning/db/InMemoryPaymentsDb.kt | 10 +- .../lightning/db/PaymentsDbTestsCommon.kt | 140 +-- .../fr/acinq/lightning/io/peer/PeerTest.kt | 4 +- .../IncomingPaymentHandlerTestsCommon.kt | 1033 ++++++++++------- .../OutgoingPaymentHandlerTestsCommon.kt | 6 +- .../payment/PaymentPacketTestsCommon.kt | 6 +- .../fr/acinq/lightning/tests/TestConstants.kt | 5 +- .../wire/LightningCodecsTestsCommon.kt | 31 +- 29 files changed, 1475 insertions(+), 919 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/Features.kt b/src/commonMain/kotlin/fr/acinq/lightning/Features.kt index 86c8235e4..90c98d16a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/Features.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/Features.kt @@ -126,6 +126,13 @@ sealed class Feature { override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) } + @Serializable + object Quiescence : Feature() { + override val rfcName get() = "option_quiescence" + override val mandatory get() = 34 + override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) + } + @Serializable object ChannelType : Feature() { override val rfcName get() = "option_channel_type" @@ -185,7 +192,7 @@ sealed class Feature { override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) } - /** This feature bit should be activated when a node accepts on-the-fly channel creation. */ + /** DEPRECATED: this feature bit was used for the legacy pay-to-open protocol. */ @Serializable object PayToOpenClient : Feature() { override val rfcName get() = "pay_to_open_client" @@ -193,7 +200,7 @@ sealed class Feature { override val scopes: Set get() = setOf(FeatureScope.Init) } - /** This feature bit should be activated when a node supports opening channels on-the-fly when liquidity is missing to receive a payment. */ + /** DEPRECATED: this feature bit was used for the legacy pay-to-open protocol. */ @Serializable object PayToOpenProvider : Feature() { override val rfcName get() = "pay_to_open_provider" @@ -250,9 +257,9 @@ sealed class Feature { } @Serializable - object Quiescence : Feature() { - override val rfcName get() = "option_quiescence" - override val mandatory get() = 34 + object OnTheFlyFunding : Feature() { + override val rfcName get() = "on_the_fly_funding" + override val mandatory get() = 560 override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) } @@ -322,6 +329,7 @@ data class Features(val activated: Map, val unknown: Se Feature.RouteBlinding, Feature.ShutdownAnySegwit, Feature.DualFunding, + Feature.Quiescence, Feature.ChannelType, Feature.PaymentMetadata, Feature.TrampolinePayment, @@ -337,7 +345,7 @@ data class Features(val activated: Map, val unknown: Se Feature.ChannelBackupClient, Feature.ChannelBackupProvider, Feature.ExperimentalSplice, - Feature.Quiescence + Feature.OnTheFlyFunding ) operator fun invoke(bytes: ByteVector): Features = invoke(bytes.toByteArray()) @@ -369,7 +377,8 @@ data class Features(val activated: Map, val unknown: Se Feature.BasicMultiPartPayment to listOf(Feature.PaymentSecret), Feature.AnchorOutputs to listOf(Feature.StaticRemoteKey), Feature.TrampolinePayment to listOf(Feature.PaymentSecret), - Feature.ExperimentalTrampolinePayment to listOf(Feature.PaymentSecret) + Feature.ExperimentalTrampolinePayment to listOf(Feature.PaymentSecret), + Feature.OnTheFlyFunding to listOf(Feature.ExperimentalSplice) ) 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 f8e882653..925e38b1c 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt @@ -48,7 +48,10 @@ sealed interface LiquidityEvents : NodeEvents { data class OverAbsoluteFee(val maxAbsoluteFee: Satoshi) : TooExpensive() data class OverRelativeFee(val maxRelativeFeeBasisPoints: Int) : TooExpensive() } - data object ChannelInitializing : Reason() + data object ChannelFundingInProgress : Reason() + data object NoMatchingFundingRate : Reason() + data class MissingOffChainAmountTooLow(val missingOffChainAmount: MilliSatoshi) : Reason() + data class TooManyParts(val parts: Int) : Reason() } } data class Accepted(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source) : LiquidityEvents diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index 24536f4fa..574c2b0f8 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -176,6 +176,8 @@ data class NodeParams( require(!features.hasFeature(Feature.ZeroConfChannels)) { "${Feature.ZeroConfChannels.rfcName} has been deprecated: use the zeroConfPeers whitelist instead" } require(!features.hasFeature(Feature.TrustedSwapInClient)) { "${Feature.TrustedSwapInClient.rfcName} has been deprecated" } require(!features.hasFeature(Feature.TrustedSwapInProvider)) { "${Feature.TrustedSwapInProvider.rfcName} has been deprecated" } + require(!features.hasFeature(Feature.PayToOpenClient)) { "${Feature.PayToOpenClient.rfcName} has been deprecated" } + require(!features.hasFeature(Feature.PayToOpenProvider)) { "${Feature.PayToOpenProvider.rfcName} has been deprecated" } Features.validateFeatureGraph(features) } @@ -197,15 +199,15 @@ data class NodeParams( Feature.RouteBlinding to FeatureSupport.Optional, Feature.DualFunding to FeatureSupport.Mandatory, Feature.ShutdownAnySegwit to FeatureSupport.Mandatory, + Feature.Quiescence to FeatureSupport.Mandatory, Feature.ChannelType to FeatureSupport.Mandatory, Feature.PaymentMetadata to FeatureSupport.Optional, Feature.ExperimentalTrampolinePayment to FeatureSupport.Optional, Feature.ZeroReserveChannels to FeatureSupport.Optional, Feature.WakeUpNotificationClient to FeatureSupport.Optional, - Feature.PayToOpenClient to FeatureSupport.Optional, Feature.ChannelBackupClient to FeatureSupport.Optional, Feature.ExperimentalSplice to FeatureSupport.Optional, - Feature.Quiescence to FeatureSupport.Mandatory + Feature.OnTheFlyFunding to FeatureSupport.Optional, ), dustLimit = 546.sat, maxRemoteDustLimit = 600.sat, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt index 49fc17312..18a657254 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt @@ -28,6 +28,8 @@ data class ToSelfDelayTooHigh (override val channelId: Byte data class MissingLiquidityAds (override val channelId: ByteVector32) : ChannelException(channelId, "liquidity ads field is missing") data class InvalidLiquidityAdsSig (override val channelId: ByteVector32) : ChannelException(channelId, "liquidity ads signature is invalid") data class InvalidLiquidityAdsAmount (override val channelId: ByteVector32, val proposed: Satoshi, val min: Satoshi) : ChannelException(channelId, "liquidity ads funding amount is too low (expected at least $min, got $proposed)") +data class UnexpectedLiquidityAdsFundingFee (override val channelId: ByteVector32, val fundingTxId: TxId) : ChannelException(channelId, "unexpected liquidity ads funding fee for txId=$fundingTxId (transaction not found)") +data class InvalidLiquidityAdsFundingFee (override val channelId: ByteVector32, val fundingTxId: TxId, val paymentHash: ByteVector32, val expected: Satoshi, val proposed: MilliSatoshi) : ChannelException(channelId, "invalid liquidity ads funding fee for txId=$fundingTxId and paymentHash=$paymentHash (expected $expected, got $proposed)") data class ChannelFundingError (override val channelId: ByteVector32) : ChannelException(channelId, "channel funding error") data class RbfAttemptAborted (override val channelId: ByteVector32) : ChannelException(channelId, "rbf attempt aborted") data class SpliceAborted (override val channelId: ByteVector32) : ChannelException(channelId, "splice aborted") diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt index 3e8e09806..59c6946ff 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt @@ -5,22 +5,17 @@ import fr.acinq.bitcoin.Script.tail import fr.acinq.bitcoin.crypto.musig2.IndividualNonce import fr.acinq.bitcoin.crypto.musig2.Musig2 import fr.acinq.bitcoin.crypto.musig2.SecretNonce -import fr.acinq.bitcoin.utils.getOrDefault -import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.bitcoin.utils.Either import fr.acinq.bitcoin.utils.Try +import fr.acinq.bitcoin.utils.getOrDefault import fr.acinq.bitcoin.utils.runTrying +import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.blockchain.electrum.WalletState import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.crypto.Bolt3Derivation.deriveForCommitment import fr.acinq.lightning.crypto.KeyManager -import fr.acinq.lightning.logging.* -import fr.acinq.lightning.transactions.CommitmentSpec -import fr.acinq.lightning.transactions.DirectedHtlc -import fr.acinq.lightning.transactions.Scripts -import fr.acinq.lightning.transactions.SwapInProtocol -import fr.acinq.lightning.transactions.Transactions +import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.transactions.* import fr.acinq.lightning.utils.* import fr.acinq.lightning.wire.* @@ -227,7 +222,6 @@ sealed class FundingContributionFailure { data class InputBelowDust(val txId: TxId, val outputIndex: Int, val amount: Satoshi, val dustLimit: Satoshi) : FundingContributionFailure() { override fun toString(): String = "invalid input $txId:$outputIndex (below dust: amount=$amount, dust=$dustLimit)" } data class InputTxTooLarge(val tx: Transaction) : FundingContributionFailure() { override fun toString(): String = "invalid input tx ${tx.txid} (too large)" } data class NotEnoughFunding(val fundingAmount: Satoshi, val nonFundingAmount: Satoshi, val providedAmount: Satoshi) : FundingContributionFailure() { override fun toString(): String = "not enough funds provided (expected at least $fundingAmount + $nonFundingAmount, got $providedAmount)" } - data class NotEnoughFees(val currentFees: Satoshi, val expectedFees: Satoshi) : FundingContributionFailure() { override fun toString(): String = "not enough funds to pay fees (expected at least $expectedFees, got $currentFees)" } data class InvalidFundingBalances(val fundingAmount: Satoshi, val localBalance: MilliSatoshi, val remoteBalance: MilliSatoshi) : FundingContributionFailure() { override fun toString(): String = "invalid balances funding_amount=$fundingAmount local=$localBalance remote=$remoteBalance" } // @formatter:on } @@ -239,7 +233,14 @@ data class FundingContributions(val inputs: List, v fun computeSpliceContribution(isInitiator: Boolean, commitment: Commitment, walletInputs: List, localOutputs: List, targetFeerate: FeeratePerKw): Satoshi { val weight = computeWeightPaid(isInitiator, commitment, walletInputs, localOutputs) val fees = Transactions.weight2fee(targetFeerate, weight) - return walletInputs.map { it.amount }.sum() - localOutputs.map { it.amount }.sum() - fees + return when { + // When buying inbound liquidity, we may not have enough funds in our current balance to pay on-chain fees. + // The maximum amount we can use for on-chain fees is our current balance, which is fine because: + // - this will simply result in a splice transaction with a lower feerate than expected + // - liquidity fees will be paid later from future HTLCs relayed to us + walletInputs.isEmpty() && localOutputs.isEmpty() -> -(fees.min(commitment.localCommit.spec.toLocal.truncateToSatoshi())) + else -> walletInputs.map { it.amount }.sum() - localOutputs.map { it.amount }.sum() - fees + } } /** @@ -276,27 +277,19 @@ data class FundingContributions(val inputs: List, v return Either.Left(FundingContributionFailure.NotEnoughFunding(params.localContribution, localOutputs.map { it.amount }.sum(), totalAmountIn)) } - // We compute the fees that we should pay in the shared transaction. - val fundingPubkeyScript = params.fundingPubkeyScript(channelKeys) - val weightWithoutChange = computeWeightPaid(params.isInitiator, sharedUtxo?.first, fundingPubkeyScript, walletInputs, localOutputs) - val weightWithChange = computeWeightPaid(params.isInitiator, sharedUtxo?.first, fundingPubkeyScript, walletInputs, localOutputs + listOf(TxOut(0.sat, Script.pay2wpkh(Transactions.PlaceHolderPubKey)))) - val feesWithoutChange = totalAmountIn - totalAmountOut - // If we're not the initiator, we don't return an error when we're unable to meet the desired feerate. - if (params.isInitiator && feesWithoutChange < Transactions.weight2fee(params.targetFeerate, weightWithoutChange)) { - return Either.Left(FundingContributionFailure.NotEnoughFees(feesWithoutChange, Transactions.weight2fee(params.targetFeerate, weightWithoutChange))) - } - 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 fundingPubkeyScript = params.fundingPubkeyScript(channelKeys) val sharedOutput = listOf(InteractiveTxOutput.Shared(0, fundingPubkeyScript, nextLocalBalance, nextRemoteBalance, sharedUtxo?.second?.toHtlcs ?: 0.msat)) val nonChangeOutputs = localOutputs.map { o -> InteractiveTxOutput.Local.NonChange(0, o.amount, o.publicKeyScript) } val changeOutput = when (changePubKey) { null -> listOf() else -> { + val weightWithChange = computeWeightPaid(params.isInitiator, sharedUtxo?.first, fundingPubkeyScript, walletInputs, localOutputs + listOf(TxOut(0.sat, Script.pay2wpkh(Transactions.PlaceHolderPubKey)))) val changeAmount = totalAmountIn - totalAmountOut - Transactions.weight2fee(params.targetFeerate, weightWithChange) if (params.dustLimit <= changeAmount) { listOf(InteractiveTxOutput.Local.Change(0, changeAmount, Script.write(Script.pay2wpkh(changePubKey)).byteVector())) @@ -940,8 +933,10 @@ data class InteractiveTxSession( return InteractiveTxSessionAction.InvalidTxFeerate(fundingParams.channelId, tx.txid, fundingParams.targetFeerate, nextFeerate) } } else { + // We allow the feerate to be lower than requested: when using on-the-fly liquidity, we may not be able to contribute + // as much as we expected, but that's fine because we instead overshoot the feerate and pays liquidity fees accordingly. val minimumFee = Transactions.weight2fee(fundingParams.targetFeerate, tx.weight()) - if (sharedTx.fees < minimumFee) { + if (sharedTx.fees < minimumFee * 0.5) { return InteractiveTxSessionAction.InvalidTxFeerate(fundingParams.channelId, tx.txid, fundingParams.targetFeerate, Transactions.fee2rate(sharedTx.fees, tx.weight())) } } 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 83ff59a0a..2941147b5 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt @@ -409,7 +409,11 @@ data class Normal( val missing = spliceStatus.command.requestRemoteFunding?.let { r -> r.fees(spliceStatus.command.feerate).total - parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() } logger.warning { "cannot do splice: balance is too low to pay for inbound liquidity (missing=$missing)" } spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds) - Pair(this@Normal, emptyList()) + val actions = buildList { + add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceRequest(channelId).message))) + add(ChannelAction.Disconnect) + } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) } else { val spliceInit = SpliceInit( channelId, @@ -768,6 +772,17 @@ data class Normal( Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) } } + is CancelOnTheFlyFunding -> when (spliceStatus) { + is SpliceStatus.Requested -> { + logger.info { "our peer rejected our on-the-fly splice request: ascii='${cmd.message.toAscii()}'" } + spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer(cmd.message.toAscii())) + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), endQuiescence()) + } + else -> { + logger.warning { "received unexpected cancel_on_the_fly_funding (spliceStatus=${spliceStatus::class.simpleName}, message='${cmd.message.toAscii()}')" } + Pair(this@Normal, listOf(ChannelAction.Disconnect)) + } + } is SpliceLocked -> { when (val res = commitments.run { updateRemoteFundingStatus(cmd.message.fundingTxId) }) { is Either.Left -> Pair(this@Normal, emptyList()) 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 843b77cf6..ba64d90e6 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt @@ -6,10 +6,7 @@ import fr.acinq.lightning.ChannelEvents import fr.acinq.lightning.channel.* import fr.acinq.lightning.channel.Helpers.Funding.computeChannelId import fr.acinq.lightning.utils.msat -import fr.acinq.lightning.wire.AcceptDualFundedChannel -import fr.acinq.lightning.wire.Error -import fr.acinq.lightning.wire.LiquidityAds -import fr.acinq.lightning.wire.OpenDualFundedChannel +import fr.acinq.lightning.wire.* /* * We initiated a channel open and are waiting for our peer to accept it. @@ -123,6 +120,11 @@ data class WaitForAcceptChannel( } } } + is CancelOnTheFlyFunding -> { + // Our peer won't accept this on-the-fly funding attempt: they probably already failed the corresponding HTLCs. + logger.warning { "on-the-fly funding was rejected by our peer: ${cmd.message.toAscii()}" } + Pair(Aborted, listOf()) + } is Error -> handleRemoteError(cmd.message) else -> unhandled(cmd) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt index 6feb9bc33..dc0fb48fd 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt @@ -41,10 +41,6 @@ interface IncomingPaymentsDb { * Mark an incoming payment as received (paid). * Note that this function assumes that there is a matching payment request in the DB, otherwise it will be a no-op. * - * With pay-to-open, there is a delay before we receive the parts, and we may not receive any parts at all if the pay-to-open - * was cancelled due to a disconnection. That is why the payment should not be considered received (and not be displayed to - * the user) if there are no parts. - * * This method is additive: * - receivedWith set is appended to the existing set in database. * - receivedAt must be updated in database. @@ -67,6 +63,9 @@ interface OutgoingPaymentsDb { /** Get information about an outgoing payment (settled or not). */ suspend fun getLightningOutgoingPayment(id: UUID): LightningOutgoingPayment? + /** Get information about a liquidity purchase (for which the funding transaction has been signed). */ + suspend fun getInboundLiquidityPurchase(fundingTxId: TxId): InboundLiquidityOutgoingPayment? + /** Mark an outgoing payment as completed over Lightning. */ suspend fun completeOutgoingPaymentOffchain(id: UUID, preimage: ByteVector32, completedAt: Long = currentTimestampMillis()) @@ -171,8 +170,9 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r abstract val fees: MilliSatoshi /** Payment was received via existing lightning channels. */ - data class LightningPayment(override val amount: MilliSatoshi, val channelId: ByteVector32, val htlcId: Long) : ReceivedWith() { - override val fees: MilliSatoshi = 0.msat // with Lightning, the fee is paid by the sender + data class LightningPayment(override val amount: MilliSatoshi, val channelId: ByteVector32, val htlcId: Long, val fundingFee: LiquidityAds.FundingFee?) : ReceivedWith() { + // If there is no funding fee, the fees are paid by the sender for lightning payments. + override val fees: MilliSatoshi = fundingFee?.amount ?: 0.msat } sealed class OnChainIncomingPayment : ReceivedWith() { @@ -426,6 +426,7 @@ data class InboundLiquidityOutgoingPayment( override val fees: MilliSatoshi = (miningFees + purchase.fees.serviceFee).toMilliSatoshi() override val amount: MilliSatoshi = fees override val completedAt: Long? = lockedAt + val fundingFee: LiquidityAds.FundingFee = LiquidityAds.FundingFee(purchase.fees.total.toMilliSatoshi(), txId) } enum class ChannelClosingType { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index 7d18b5eea..d6cb65874 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -12,7 +12,6 @@ import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.blockchain.fee.OnChainFeerates import fr.acinq.lightning.blockchain.mempool.MempoolSpaceClient import fr.acinq.lightning.channel.* -import fr.acinq.lightning.channel.ChannelCommand.Commitment.Splice.Response import fr.acinq.lightning.channel.states.* import fr.acinq.lightning.crypto.noise.* import fr.acinq.lightning.db.* @@ -54,6 +53,21 @@ data class AddWalletInputsToChannel(val walletInputs: List) : val totalAmount: Satoshi = walletInputs.map { it.amount }.sum() } +/** + * Initiate a channel open or a splice to allow receiving an off-chain payment. + * + * @param paymentAmount total amount of the off-chain payment (before fees are paid). + * @param requestedAmount requested inbound liquidity, which will allow receiving the off-chain payment. + * @param fundingRate funding rate applied by our peer for this amount. + * @param preimage preimage of the off-chain payment. + * @param willAddHtlcs HTLCs that will be relayed to us once additional liquidity is available. + */ +data class AddLiquidityForIncomingPayment(val paymentAmount: MilliSatoshi, val requestedAmount: Satoshi, val fundingRate: LiquidityAds.FundingRate, val preimage: ByteVector32, val willAddHtlcs: List) : PeerCommand() { + val paymentHash: ByteVector32 = Crypto.sha256(preimage.toByteArray()).byteVector32() + + fun fees(fundingFeerate: FeeratePerKw): LiquidityAds.Fees = fundingRate.fees(fundingFeerate, requestedAmount, requestedAmount) +} + data class PeerConnection(val id: Long, val output: Channel, val logger: MDCLogger) { fun send(msg: LightningMessage) { // We can safely use trySend because we use unlimited channel buffers. @@ -77,7 +91,6 @@ data object Disconnected : PeerCommand() sealed class PaymentCommand : PeerCommand() private data object CheckPaymentsTimeout : PaymentCommand() private data class CheckInvoiceRequestTimeout(val pathId: ByteVector32, val payOffer: PayOffer) : PaymentCommand() -data class PayToOpenResponseCommand(val payToOpenResponse: PayToOpenResponse) : PeerCommand() // @formatter:off sealed class SendPayment : PaymentCommand() { @@ -94,6 +107,7 @@ data class PayOffer(override val paymentId: UUID, val payerKey: PrivateKey, val data class PurgeExpiredPayments(val fromCreatedAt: Long, val toCreatedAt: Long) : PaymentCommand() data class SendOnionMessage(val message: OnionMessage) : PeerCommand() +data class SendOnTheFlyFundingMessage(val message: OnTheFlyFundingMessage) : PeerCommand() sealed class PeerEvent @@ -184,7 +198,7 @@ class Peer( val eventsFlow: SharedFlow get() = _eventsFlow.asSharedFlow() // encapsulates logic for validating incoming payments - private val incomingPaymentHandler = IncomingPaymentHandler(nodeParams, db.payments) + private val incomingPaymentHandler = IncomingPaymentHandler(nodeParams, db.payments, walletParams.remoteFundingRates) // encapsulates logic for sending payments private val outgoingPaymentHandler = OutgoingPaymentHandler(nodeParams, walletParams, db.payments) @@ -585,7 +599,7 @@ class Peer( val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = channel.commitments.active.first(), walletInputs = emptyList(), localOutputs = emptyList()) + fundingRate.fundingWeight // The mining fee below pays for the entirety of the splice transaction, including inputs and outputs from the liquidity provider. val (actualFeerate, miningFee) = client.computeSpliceCpfpFeerate(channel.commitments, targetFeerate, spliceWeight = weight, logger) - // The mining fee in the lease only covers the remote node's inputs and outputs, they are already included in the mining fee above. + // The mining fee below only covers the remote node's inputs and outputs, which are already included in the mining fee above. val fundingFees = fundingRate.fees(actualFeerate, amount, amount) Pair(actualFeerate, ChannelManagementFees(miningFee, fundingFees.serviceFee)) } @@ -894,11 +908,12 @@ class Peer( } } - private suspend fun processIncomingPayment(item: Either) { + private suspend fun processIncomingPayment(item: Either) { val currentBlockHeight = currentTipFlow.filterNotNull().first() + val currentFeerate = peerFeeratesFlow.filterNotNull().first().fundingFeerate val result = when (item) { - is Either.Right -> incomingPaymentHandler.process(item.value, currentBlockHeight) - is Either.Left -> incomingPaymentHandler.process(item.value, currentBlockHeight) + is Either.Right -> incomingPaymentHandler.process(item.value, currentBlockHeight, currentFeerate) + is Either.Left -> incomingPaymentHandler.process(item.value, currentBlockHeight, currentFeerate) } when (result) { is IncomingPaymentHandler.ProcessAddResult.Accepted -> { @@ -1122,23 +1137,31 @@ class Peer( _channels = _channels + (state.channelId to state1) } } - is PayToOpenRequest -> { - logger.info { "received ${msg::class.simpleName}" } - when (selectChannelForSplicing()) { - is SelectChannelResult.Available -> processIncomingPayment(Either.Left(msg)) - SelectChannelResult.None -> processIncomingPayment(Either.Left(msg)) - SelectChannelResult.NotReady -> { - // If a channel is currently being created, it can't process splices yet. We could accept this payment, but - // it wouldn't be reflected in the user balance until the channel is ready, because we only insert - // the payment in db when we will process the corresponding splice and see the pay-to-open origin. This - // can take a long time depending on the confirmation speed. It is better and simpler to reject the incoming - // payment rather that having the user wonder where their money went. - val rejected = LiquidityEvents.Rejected(msg.amountMsat, msg.payToOpenFeeSatoshis.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.ChannelInitializing) - logger.info { "rejecting pay-to-open: reason=${rejected.reason}" } - nodeParams._nodeEvents.emit(rejected) - val action = IncomingPaymentHandler.actionForPayToOpenFailure(nodeParams.nodePrivateKey, TemporaryNodeFailure, msg) - input.send(action) + is WillAddHtlc -> when { + nodeParams.features.hasFeature(Feature.OnTheFlyFunding) -> when { + nodeParams.liquidityPolicy.value is LiquidityPolicy.Disable -> { + logger.warning { "cannot accept on-the-fly funding: policy set to disabled" } + val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(nodeParams.nodePrivateKey, msg, TemporaryNodeFailure) + input.send(SendOnTheFlyFundingMessage(failure)) + nodeParams._nodeEvents.emit(LiquidityEvents.Rejected(msg.amount, 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.PolicySetToDisabled)) } + else -> when (selectChannelForSplicing()) { + is SelectChannelResult.Available -> processIncomingPayment(Either.Left(msg)) + SelectChannelResult.None -> processIncomingPayment(Either.Left(msg)) + SelectChannelResult.NotReady -> { + // Once the channel will be ready, we may have enough inbound liquidity to receive the payment without + // an on-chain operation, which is more efficient. We thus reject that payment and wait for the sender to retry. + logger.warning { "cannot accept on-the-fly funding: another funding attempt is already in-progress" } + val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(nodeParams.nodePrivateKey, msg, TemporaryNodeFailure) + input.send(SendOnTheFlyFundingMessage(failure)) + nodeParams._nodeEvents.emit(LiquidityEvents.Rejected(msg.amount, 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.ChannelFundingInProgress)) + } + } + } + else -> { + // If we don't support on-the-fly funding, we simply ignore that proposal. + // Our peer will fail the corresponding HTLCs after a small delay. + logger.info { "ignoring on-the-fly funding (amount=${msg.amount}): on-the-fly funding is disabled" } } } is PhoenixAndroidLegacyInfo -> { @@ -1314,9 +1337,123 @@ class Peer( } } } - is PayToOpenResponseCommand -> { - logger.info { "sending ${cmd.payToOpenResponse::class.simpleName}" } - peerConnection?.send(cmd.payToOpenResponse) + is AddLiquidityForIncomingPayment -> { + val currentFeerates = peerFeeratesFlow.filterNotNull().first() + 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. + // We pay those on-chain fees using our current channel balance. + val localBalance = available.channel.commitments.active.first().localCommit.spec.toLocal + val spliceWeight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = available.channel.commitments.active.first(), walletInputs = listOf(), localOutputs = listOf()) + val (fundingFeerate, localMiningFee) = client.computeSpliceCpfpFeerate(available.channel.commitments, currentFeerates.fundingFeerate, spliceWeight, logger) + val (targetFeerate, paymentDetails) = when { + localBalance >= localMiningFee + cmd.fees(fundingFeerate).total -> { + // 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))) + } + else -> { + 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 cannot pay the liquidity fees from our channel balance, so we fall back to future HTLCs. + val paymentDetails = when { + walletParams.remoteFundingRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlc) -> LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(cmd.paymentHash)) + walletParams.remoteFundingRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlcWithPreimage) -> LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(listOf(cmd.preimage)) + else -> null + } + Pair(targetFeerate, paymentDetails) + } + } + when (paymentDetails) { + null -> { + // Our peer doesn't allow paying liquidity fees from future HTLCs. + // We'll need to wait until we have more channel balance or do a splice-in to purchase more inbound liquidity. + logger.warning { "cannot request on-the-fly splice: payment types not supported (${walletParams.remoteFundingRates.paymentTypes.joinToString()})" } + } + else -> { + val leaseFees = cmd.fees(targetFeerate) + val totalFees = ChannelManagementFees(miningFee = localMiningFee.min(localBalance.truncateToSatoshi()) + leaseFees.miningFee, serviceFee = leaseFees.serviceFee) + logger.info { "requesting on-the-fly splice for paymentHash=${cmd.paymentHash} feerate=$targetFeerate fee=${totalFees.total} paymentType=${paymentDetails.paymentType}" } + val spliceCommand = ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = null, + spliceOut = null, + requestRemoteFunding = LiquidityAds.RequestFunding(cmd.requestedAmount, cmd.fundingRate, paymentDetails), + feerate = targetFeerate, + origins = listOf(Origin.OffChainPayment(cmd.preimage, cmd.paymentAmount, totalFees)) + ) + val (state, actions) = available.channel.process(spliceCommand) + _channels = _channels + (available.channel.channelId to state) + processActions(available.channel.channelId, peerConnection, actions) + } + } + } + SelectChannelResult.None -> { + // We ask our peer to pay the commit tx fees. + val localParams = LocalParams(nodeParams, isChannelOpener = true, payCommitTxFees = false) + 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 + // We don't pay any local on-chain fees, our fee is only for the liquidity lease. + val leaseFees = cmd.fees(fundingFeerate) + val totalFees = ChannelManagementFees(miningFee = leaseFees.miningFee, serviceFee = leaseFees.serviceFee) + // We cannot pay the liquidity fees from our channel balance, so we fall back to future HTLCs. + val paymentDetails = when { + walletParams.remoteFundingRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlc) -> LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(cmd.paymentHash)) + walletParams.remoteFundingRates.paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlcWithPreimage) -> LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(listOf(cmd.preimage)) + else -> null + } + when (paymentDetails) { + null -> { + // Our peer doesn't allow paying liquidity fees from future HTLCs. + // We'll need to swap-in some funds to create a new channel. + logger.warning { "cannot request on-the-fly channel: payment types not supported (${walletParams.remoteFundingRates.paymentTypes.joinToString()})" } + } + else -> { + logger.info { "requesting on-the-fly channel for paymentHash=${cmd.paymentHash} feerate=$fundingFeerate fee=${totalFees.total} paymentType=${paymentDetails.paymentType}" } + val (state, actions) = WaitForInit.process( + ChannelCommand.Init.Initiator( + fundingAmount = 0.sat, // we don't have funds to contribute + pushAmount = 0.msat, + walletInputs = listOf(), + commitTxFeerate = currentFeerates.commitmentFeerate, + fundingTxFeerate = fundingFeerate, + localParams = localParams, + remoteInit = theirInit!!, + channelFlags = channelFlags, + channelConfig = ChannelConfig.standard, + channelType = ChannelType.SupportedChannelType.AnchorOutputsZeroReserve, + requestRemoteFunding = LiquidityAds.RequestFunding(cmd.requestedAmount, cmd.fundingRate, paymentDetails), + channelOrigin = Origin.OffChainPayment(cmd.preimage, cmd.paymentAmount, totalFees), + ) + ) + val msg = actions.filterIsInstance().map { it.message }.filterIsInstance().first() + _channels = _channels + (msg.temporaryChannelId to state) + processActions(msg.temporaryChannelId, peerConnection, actions) + } + } + } + SelectChannelResult.NotReady -> { + // There is an existing channel but not immediately usable (e.g. we're already in the process of funding it). + logger.warning { "cancelling on-the-fly funding, existing channels are not ready for splice-in: ${channels.values.map { it::class.simpleName }}" } + cmd.willAddHtlcs.forEach { + val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(nodeParams.nodePrivateKey, it, TemporaryNodeFailure) + input.send(SendOnTheFlyFundingMessage(failure)) + } + nodeParams._nodeEvents.emit(LiquidityEvents.Rejected(cmd.requestedAmount.toMilliSatoshi(), 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.ChannelFundingInProgress)) + } + } } is PayInvoice -> { val currentTip = currentTipFlow.filterNotNull().first() @@ -1381,6 +1518,7 @@ class Peer( } is CheckInvoiceRequestTimeout -> offerManager.checkInvoiceRequestTimeout(cmd.pathId, cmd.payOffer) is SendOnionMessage -> peerConnection?.send(cmd.message) + is SendOnTheFlyFundingMessage -> peerConnection?.send(cmd.message) } } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index 2e8b53633..aa4eb4885 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -1,20 +1,17 @@ package fr.acinq.lightning.payment -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.Crypto -import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 -import fr.acinq.lightning.channel.ChannelAction -import fr.acinq.lightning.channel.ChannelCommand -import fr.acinq.lightning.channel.Origin +import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.channel.* import fr.acinq.lightning.crypto.sphinx.Sphinx import fr.acinq.lightning.db.IncomingPayment -import fr.acinq.lightning.db.IncomingPaymentsDb -import fr.acinq.lightning.io.PayToOpenResponseCommand +import fr.acinq.lightning.db.PaymentsDb +import fr.acinq.lightning.io.AddLiquidityForIncomingPayment import fr.acinq.lightning.io.PeerCommand +import fr.acinq.lightning.io.SendOnTheFlyFundingMessage import fr.acinq.lightning.io.WrappedChannelCommand import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.logging.mdc @@ -37,24 +34,22 @@ data class HtlcPart(val htlc: UpdateAddHtlc, override val finalPayload: PaymentO override fun toString(): String = "htlc(channelId=${htlc.channelId},id=${htlc.id})" } -data class PayToOpenPart(val payToOpenRequest: PayToOpenRequest, override val finalPayload: PaymentOnion.FinalPayload) : PaymentPart() { - override val amount: MilliSatoshi = payToOpenRequest.amountMsat +data class WillAddHtlcPart(val htlc: WillAddHtlc, override val finalPayload: PaymentOnion.FinalPayload) : PaymentPart() { + override val amount: MilliSatoshi = htlc.amount override val totalAmount: MilliSatoshi = finalPayload.totalAmount - override val paymentHash: ByteVector32 = payToOpenRequest.paymentHash - override val onionPacket: OnionRoutingPacket = payToOpenRequest.finalPacket - override fun toString(): String = "pay-to-open(amount=${payToOpenRequest.amountMsat})" + override val paymentHash: ByteVector32 = htlc.paymentHash + override val onionPacket: OnionRoutingPacket = htlc.finalPacket + override fun toString(): String = "future-htlc(id=${htlc.id},amount=${htlc.amount})" } -class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPaymentsDb) { +class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, private val remoteFundingRates: LiquidityAds.WillFundRates) { sealed class ProcessAddResult { abstract val actions: List data class Accepted(override val actions: List, val incomingPayment: IncomingPayment, val received: IncomingPayment.Received) : ProcessAddResult() data class Rejected(override val actions: List, val incomingPayment: IncomingPayment?) : ProcessAddResult() - data class Pending(val incomingPayment: IncomingPayment, val pendingPayment: PendingPayment) : ProcessAddResult() { - override val actions: List = listOf() - } + data class Pending(val incomingPayment: IncomingPayment, val pendingPayment: PendingPayment, override val actions: List = listOf()) : ProcessAddResult() } /** @@ -70,6 +65,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment constructor(firstPart: PaymentPart) : this(setOf(firstPart), firstPart.totalAmount, currentTimestampSeconds()) val amountReceived: MilliSatoshi = parts.map { it.amount }.sum() + val fundingFee: MilliSatoshi = parts.filterIsInstance().map { it.htlc.fundingFee?.amount ?: 0.msat }.sum() fun add(part: PaymentPart): PendingPayment = copy(parts = parts + part) } @@ -108,12 +104,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment return pr } - /** - * Save the "received-with" details of an incoming amount. - * - * - for a pay-to-open origin, the payment already exists and we only add a received-with. - * - for a swap-in origin, a new incoming payment must be created. We use a random. - */ + /** Save the "received-with" details of an incoming on-chain amount. */ suspend fun process(channelId: ByteVector32, action: ChannelAction.Storage.StoreIncomingPayment) { val receivedWith = when (action) { is ChannelAction.Storage.StoreIncomingPayment.ViaNewChannel -> @@ -137,16 +128,8 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment lockedAt = null, ) } - when (val origin = action.origin) { - is Origin.OffChainPayment -> { - // there already is a corresponding Lightning invoice in the db - db.receivePayment( - paymentHash = origin.paymentHash, - receivedWith = listOf(receivedWith) - ) - nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(origin.paymentHash, listOf(receivedWith))) - } - else -> { + when (action.origin) { + is Origin.OnChainWallet -> { // this is a swap, there was no pre-existing invoice, we need to create a fake one val incomingPayment = db.addIncomingPayment( preimage = randomBytes32(), // not used, placeholder @@ -158,46 +141,35 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment ) nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(incomingPayment.paymentHash, listOf(receivedWith))) } + is Origin.OffChainPayment -> { + // There is nothing to do, since we haven't been paid anything in the funding/splice transaction. + // We will receive HTLCs later for the payment that triggered the on-the-fly funding transaction. + } + null -> {} } } - /** - * Process an incoming htlc. - * Before calling this, the htlc must be committed and ack-ed by both sides. - * - * @return A result that indicates whether or not the packet was - * accepted, rejected, or still pending (as the case may be for multipart payments). - * Also includes the list of actions to be queued. - */ - suspend fun process(htlc: UpdateAddHtlc, currentBlockHeight: Int): ProcessAddResult { - // Security note: - // There are several checks we could perform before decrypting the onion. - // However an error message here would differ from an error message below, - // as we don't know the `onion.totalAmount` yet. - // So to prevent any kind of information leakage, 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) - } - } + /** Process an incoming htlc. Before calling this, the htlc must be committed and ack-ed by both peers. */ + suspend fun process(htlc: UpdateAddHtlc, currentBlockHeight: Int, currentFeerate: FeeratePerKw): ProcessAddResult = process(Either.Right(htlc), currentBlockHeight, currentFeerate) - /** - * Process an incoming pay-to-open request. - * This is very similar to the processing of an htlc. - */ - suspend fun process(payToOpenRequest: PayToOpenRequest, currentBlockHeight: Int): ProcessAddResult { - return when (val res = toPaymentPart(privateKey, payToOpenRequest)) { + /** Process an incoming on-the-fly funding request. */ + suspend fun process(htlc: WillAddHtlc, currentBlockHeight: Int, currentFeerate: FeeratePerKw): ProcessAddResult = process(Either.Left(htlc), currentBlockHeight, currentFeerate) + + private suspend fun process(htlc: Either, currentBlockHeight: Int, currentFeerate: FeeratePerKw): 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) + is Either.Right -> processPaymentPart(res.value, currentBlockHeight, currentFeerate) } } /** Main payment processing, that handles payment parts. */ - private suspend fun processPaymentPart(paymentPart: PaymentPart, currentBlockHeight: Int): ProcessAddResult { + private suspend fun processPaymentPart(paymentPart: PaymentPart, currentBlockHeight: Int, currentFeerate: FeeratePerKw): ProcessAddResult { val logger = MDCLogger(logger.logger, staticMdc = paymentPart.mdc()) when (paymentPart) { is HtlcPart -> logger.info { "processing htlc part expiry=${paymentPart.htlc.cltvExpiry}" } - is PayToOpenPart -> logger.info { "processing pay-to-open part amount=${paymentPart.payToOpenRequest.amountMsat} fees=${paymentPart.payToOpenRequest.payToOpenFeeSatoshis}" } + is WillAddHtlcPart -> logger.info { "processing on-the-fly funding part amount=${paymentPart.amount} expiry=${paymentPart.htlc.expiry}" } } return when (val validationResult = validatePaymentPart(paymentPart, currentBlockHeight)) { is Either.Left -> validationResult.value @@ -226,94 +198,128 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment ProcessAddResult.Rejected(listOf(action), incomingPayment) } } - is PayToOpenPart -> { - logger.info { "rejecting pay-to-open part for an invoice that has already been paid" } - val action = actionForPayToOpenFailure(privateKey, IncorrectOrUnknownPaymentDetails(paymentPart.totalAmount, currentBlockHeight.toLong()), paymentPart.payToOpenRequest) + is WillAddHtlcPart -> { + logger.info { "rejecting on-the-fly funding part for an invoice that has already been paid" } + val action = actionForWillAddHtlcFailure(privateKey, IncorrectOrUnknownPaymentDetails(paymentPart.totalAmount, currentBlockHeight.toLong()), paymentPart.htlc) ProcessAddResult.Rejected(listOf(action), incomingPayment) } } } else { val payment = pending[paymentPart.paymentHash]?.add(paymentPart) ?: PendingPayment(paymentPart) - when { + return when { paymentPart.totalAmount != payment.totalAmount -> { // Bolt 04: // - SHOULD fail the entire HTLC set if `total_msat` is not the same for all HTLCs in the set. logger.warning { "invalid total_amount_msat: ${paymentPart.totalAmount}, expected ${payment.totalAmount}" } - val actions = payment.parts.map { part -> - val failureMsg = IncorrectOrUnknownPaymentDetails(part.totalAmount, currentBlockHeight.toLong()) - when (part) { - is HtlcPart -> actionForFailureMessage(failureMsg, part.htlc) - is PayToOpenPart -> actionForPayToOpenFailure(privateKey, failureMsg, part.payToOpenRequest) // NB: this will fail all parts, we could only return one - } - } - pending.remove(paymentPart.paymentHash) - return ProcessAddResult.Rejected(actions, incomingPayment) + val failure = IncorrectOrUnknownPaymentDetails(payment.totalAmount, currentBlockHeight.toLong()) + rejectPayment(payment, incomingPayment, failure) } - payment.amountReceived < payment.totalAmount -> { + payment.amountReceived + payment.fundingFee < payment.totalAmount -> { // Still waiting for more payments. pending[paymentPart.paymentHash] = payment - return ProcessAddResult.Pending(incomingPayment, payment) + ProcessAddResult.Pending(incomingPayment, payment) } else -> { - if (payment.parts.filterIsInstance().isNotEmpty()) { - // We consider the total amount received (not only the pay-to-open parts) to evaluate whether or not to accept the payment - val payToOpenFee = payment.parts.filterIsInstance().map { it.payToOpenRequest.payToOpenFeeSatoshis }.sum() - nodeParams.liquidityPolicy.value.maybeReject(payment.amountReceived, payToOpenFee.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, logger)?.let { rejected -> - logger.info { "rejecting pay-to-open: reason=${rejected.reason}" } - nodeParams._nodeEvents.emit(rejected) - val actions = payment.parts.map { part -> - val failureMsg = TemporaryNodeFailure - when (part) { - is HtlcPart -> actionForFailureMessage(failureMsg, part.htlc) - is PayToOpenPart -> actionForPayToOpenFailure(privateKey, failureMsg, part.payToOpenRequest) // NB: this will fail all parts, we could only return one + val htlcParts = payment.parts.filterIsInstance() + val willAddHtlcParts = payment.parts.filterIsInstance() + when { + payment.parts.size > nodeParams.maxAcceptedHtlcs -> { + logger.warning { "rejecting on-the-fly funding: too many parts (${payment.parts.size} > ${nodeParams.maxAcceptedHtlcs}" } + 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)) { + is Either.Left -> { + logger.warning { "rejecting on-the-fly funding: reason=${result.value.reason}" } + nodeParams._nodeEvents.emit(result.value) + rejectPayment(payment, incomingPayment, TemporaryNodeFailure) + } + 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() + ) + when { + paymentOnlyHtlcs.parts.isNotEmpty() -> pending[paymentPart.paymentHash] = paymentOnlyHtlcs + else -> pending.remove(paymentPart.paymentHash) } + ProcessAddResult.Pending(incomingPayment, paymentOnlyHtlcs, actions) } - pending.remove(paymentPart.paymentHash) - return ProcessAddResult.Rejected(actions, incomingPayment) - } - } - - when (val finalPayload = paymentPart.finalPayload) { - is PaymentOnion.FinalPayload.Standard -> when (finalPayload.paymentMetadata) { - null -> logger.info { "payment received (${payment.amountReceived}) without payment metadata" } - else -> logger.info { "payment received (${payment.amountReceived}) with payment metadata (${finalPayload.paymentMetadata})" } - } - is PaymentOnion.FinalPayload.Blinded -> logger.info { "payment received (${payment.amountReceived}) with blinded route" } - } - val htlcParts = payment.parts.filterIsInstance() - val payToOpenParts = payment.parts.filterIsInstance() - // We only fill the DB with htlc parts, because we cannot be sure yet that our peer will honor the pay-to-open part(s). - // When the payment contains pay-to-open parts, it will be considered received, but the sum of all parts will be smaller - // than the expected amount. The pay-to-open part(s) will be added once we received the corresponding new channel or a splice-in. - val receivedWith = htlcParts.map { part -> - IncomingPayment.ReceivedWith.LightningPayment( - amount = part.amount, - htlcId = part.htlc.id, - channelId = part.htlc.channelId - ) - } - val actions = buildList { - htlcParts.forEach { part -> - val cmd = ChannelCommand.Htlc.Settlement.Fulfill(part.htlc.id, incomingPayment.preimage, true) - add(WrappedChannelCommand(part.htlc.channelId, cmd)) } - // We avoid sending duplicate pay-to-open responses, since the preimage is the same for every part. - if (payToOpenParts.isNotEmpty()) { - val response = PayToOpenResponse(nodeParams.chainHash, incomingPayment.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage)) - add(PayToOpenResponseCommand(response)) + else -> when (val fundingFee = validateFundingFee(htlcParts)) { + is Either.Left -> { + logger.warning { "rejecting htlcs with invalid on-the-fly funding fee: ${fundingFee.value.message}" } + val failure = IncorrectOrUnknownPaymentDetails(paymentPart.totalAmount, currentBlockHeight.toLong()) + 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) + } } } + } + } + } + } + } + } - pending.remove(paymentPart.paymentHash) - val received = IncomingPayment.Received(receivedWith = receivedWith) - 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)) - 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 -> + when (part) { + is HtlcPart -> actionForFailureMessage(failure, part.htlc) + is WillAddHtlcPart -> actionForWillAddHtlcFailure(nodeParams.nodePrivateKey, failure, part.htlc) + } + } + return ProcessAddResult.Rejected(actions, incomingPayment) + } + + private fun validateOnTheFlyFundingRate(willAddHtlcAmount: MilliSatoshi, currentFeerate: FeeratePerKw): Either> { + return when (val liquidityPolicy = nodeParams.liquidityPolicy.value) { + is LiquidityPolicy.Disable -> Either.Left(LiquidityEvents.Rejected(willAddHtlcAmount, 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.PolicySetToDisabled)) + is LiquidityPolicy.Auto -> { + // Whenever we receive on-the-fly funding, we take this opportunity to purchase inbound liquidity. + // This reduces the frequency of on-chain funding and thus the overall on-chain fees paid. + val additionalInboundLiquidity = liquidityPolicy.inboundLiquidityTarget ?: LiquidityPolicy.minInboundLiquidityTarget + val requestedAmount = willAddHtlcAmount.truncateToSatoshi() + additionalInboundLiquidity + when (val fundingRate = remoteFundingRates.findRate(requestedAmount)) { + null -> Either.Left(LiquidityEvents.Rejected(requestedAmount.toMilliSatoshi(), 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.NoMatchingFundingRate)) + else -> { + val fees = fundingRate.fees(currentFeerate, requestedAmount, requestedAmount).total + val rejected = when { + // 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( + requestedAmount.toMilliSatoshi(), + fees.toMilliSatoshi(), + LiquidityEvents.Source.OffChainPayment, + LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow(willAddHtlcAmount) + ) + else -> liquidityPolicy.maybeReject(requestedAmount.toMilliSatoshi(), fees.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, logger) + } + when (rejected) { + null -> Either.Right(Pair(requestedAmount, fundingRate)) + else -> Either.Left(rejected) } } } @@ -321,6 +327,33 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment } } + private suspend fun validateFundingFee(parts: List): Either { + return when (val fundingTxId = parts.map { it.htlc.fundingFee?.fundingTxId }.firstOrNull()) { + is TxId -> { + val channelId = parts.first().htlc.channelId + val paymentHash = parts.first().htlc.paymentHash + val fundingFee = parts.map { it.htlc.fundingFee?.amount ?: 0.msat }.sum() + when (val purchase = db.getInboundLiquidityPurchase(fundingTxId)?.purchase) { + null -> Either.Left(UnexpectedLiquidityAdsFundingFee(channelId, fundingTxId)) + else -> { + val paymentHashOk = when (val details = purchase.paymentDetails) { + is LiquidityAds.PaymentDetails.FromFutureHtlc -> details.paymentHashes.contains(paymentHash) + is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> details.preimages.any { Crypto.sha256(it).byteVector32() == paymentHash } + is LiquidityAds.PaymentDetails.FromChannelBalance -> false + is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> false + } + val feeAmountOk = fundingFee <= purchase.fees.total.toMilliSatoshi() + when { + paymentHashOk && feeAmountOk -> Either.Right(LiquidityAds.FundingFee(fundingFee, fundingTxId)) + else -> Either.Left(InvalidLiquidityAdsFundingFee(channelId, fundingTxId, paymentHash, purchase.fees.total, fundingFee)) + } + } + } + } + else -> Either.Right(null) + } + } + private suspend fun validatePaymentPart(paymentPart: PaymentPart, currentBlockHeight: Int): Either { val logger = MDCLogger(logger.logger, staticMdc = paymentPart.mdc()) when (val finalPayload = paymentPart.finalPayload) { @@ -433,7 +466,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment payment.parts.forEach { part -> when (part) { is HtlcPart -> actions += actionForFailureMessage(PaymentTimeout, part.htlc) - is PayToOpenPart -> actions += actionForPayToOpenFailure(privateKey, PaymentTimeout, part.payToOpenRequest) + is WillAddHtlcPart -> actions += actionForWillAddHtlcFailure(privateKey, PaymentTimeout, part.htlc) } } } @@ -459,38 +492,29 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment /** * If we are disconnected, we must forget pending payment parts. - * Pay-to-open requests will be forgotten by the LSP, so we need to do the same otherwise we will accept outdated ones. + * On-the-fly funding proposals will be forgotten by our peer, so we need to do the same. * Offered HTLCs that haven't been resolved will be re-processed when we reconnect. */ fun purgePendingPayments() { - pending.forEach { (paymentHash, pending) -> logger.info { "purging pending incoming payments for paymentHash=$paymentHash: ${pending.parts.map { it.toString() }.joinToString(", ")}" } } + pending.forEach { (paymentHash, pending) -> logger.info { "purging pending incoming payments for paymentHash=$paymentHash: ${pending.parts.joinToString(", ") { it.toString() }}" } } pending.clear() } companion object { /** Convert an incoming htlc to a payment part abstraction. Payment parts are then summed together to reach the full payment amount. */ - private fun toPaymentPart(privateKey: PrivateKey, htlc: UpdateAddHtlc): Either { - // NB: IncomingPacket.decrypt does additional validation on top of IncomingPacket.decryptOnion + private fun toPaymentPart(privateKey: PrivateKey, htlc: Either): Either { return when (val decrypted = IncomingPaymentPacket.decrypt(htlc, privateKey)) { - is Either.Left -> { // Unable to decrypt onion - val action = actionForFailureMessage(decrypted.value, htlc) - Either.Left(ProcessAddResult.Rejected(listOf(action), null)) - } - is Either.Right -> Either.Right(HtlcPart(htlc, decrypted.value)) - } - } - - /** - * Convert a incoming pay-to-open request to a payment part abstraction. - * This is very similar to the processing of a htlc, except that we only have a packet, to decrypt into a final payload. - */ - private fun toPaymentPart(privateKey: PrivateKey, payToOpenRequest: PayToOpenRequest): Either { - return when (val decrypted = IncomingPaymentPacket.decryptOnion(payToOpenRequest.paymentHash, payToOpenRequest.finalPacket, privateKey, payToOpenRequest.blinding)) { is Either.Left -> { - val action = actionForPayToOpenFailure(privateKey, decrypted.value, payToOpenRequest) + val action = when (htlc) { + is Either.Left -> actionForWillAddHtlcFailure(privateKey, decrypted.value, htlc.value) + is Either.Right -> actionForFailureMessage(decrypted.value, htlc.value) + } Either.Left(ProcessAddResult.Rejected(listOf(action), null)) } - is Either.Right -> Either.Right(PayToOpenPart(payToOpenRequest, decrypted.value)) + is Either.Right -> when (htlc) { + is Either.Left -> Either.Right(WillAddHtlcPart(htlc.value, decrypted.value)) + is Either.Right -> Either.Right(HtlcPart(htlc.value, decrypted.value)) + } } } @@ -501,7 +525,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment } val rejectedAction = when (paymentPart) { is HtlcPart -> actionForFailureMessage(failureMsg, paymentPart.htlc) - is PayToOpenPart -> actionForPayToOpenFailure(privateKey, failureMsg, paymentPart.payToOpenRequest) + is WillAddHtlcPart -> actionForWillAddHtlcFailure(privateKey, failureMsg, paymentPart.htlc) } return ProcessAddResult.Rejected(listOf(rejectedAction), incomingPayment) } @@ -514,13 +538,9 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment return WrappedChannelCommand(htlc.channelId, cmd) } - fun actionForPayToOpenFailure(privateKey: PrivateKey, failure: FailureMessage, payToOpenRequest: PayToOpenRequest): PayToOpenResponseCommand { - val reason = ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(failure) - val encryptedReason = when (failure) { - is BadOnion -> null - else -> OutgoingPaymentPacket.buildHtlcFailure(privateKey, payToOpenRequest.paymentHash, payToOpenRequest.finalPacket, reason).right - } - return PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest.chainHash, payToOpenRequest.paymentHash, PayToOpenResponse.Result.Failure(encryptedReason))) + private fun actionForWillAddHtlcFailure(privateKey: PrivateKey, failure: FailureMessage, htlc: WillAddHtlc): SendOnTheFlyFundingMessage { + val msg = OutgoingPaymentPacket.buildWillAddHtlcFailure(privateKey, htlc, failure) + return SendOnTheFlyFundingMessage(msg) } private fun minFinalCltvExpiry(paymentRequest: Bolt11Invoice, currentBlockHeight: Int): CltvExpiry { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt index b95f861d9..ee709a8a6 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt @@ -6,14 +6,23 @@ import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.utils.Either import fr.acinq.bitcoin.utils.flatMap +import fr.acinq.lightning.CltvExpiry import fr.acinq.lightning.Features +import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.crypto.RouteBlinding import fr.acinq.lightning.crypto.sphinx.Sphinx import fr.acinq.lightning.crypto.sphinx.Sphinx.hash +import fr.acinq.lightning.utils.msat import fr.acinq.lightning.wire.* object IncomingPaymentPacket { + /** Decrypt the onion packet of a received htlc. */ + fun decrypt(add: UpdateAddHtlc, privateKey: PrivateKey): Either = decrypt(Either.Right(add), privateKey) + + /** Decrypt the onion packet of a received on-the-fly funding request. */ + fun decrypt(add: WillAddHtlc, privateKey: PrivateKey): Either = decrypt(Either.Left(add), privateKey) + /** * Decrypt the onion packet of a received htlc. We expect to be the final recipient, and we validate that the HTLC * fields match the onion fields (this prevents intermediate nodes from sending an invalid amount or expiry). @@ -24,29 +33,37 @@ object IncomingPaymentPacket { * - a decrypted and valid onion final payload * - or a Bolt4 failure message that can be returned to the sender if the HTLC is invalid */ - fun decrypt(add: UpdateAddHtlc, privateKey: PrivateKey): Either { - return decryptOnion(add.paymentHash, add.onionRoutingPacket, privateKey, add.blinding).flatMap { outer -> + fun decrypt(add: Either, privateKey: PrivateKey): Either { + // The previous node may forward a smaller amount than expected to cover liquidity fees. + // But the amount used for validation should take this funding fee into account. + // We will verify later in the IncomingPaymentHandler whether the funding fee is valid or not. + val htlcAmount = add.fold({ it.amount }, { it.amountMsat + (it.fundingFee?.amount ?: 0.msat) }) + val htlcExpiry = add.fold({ it.expiry }, { it.cltvExpiry }) + val paymentHash = add.fold({ it.paymentHash }, { it.paymentHash }) + val blinding = add.fold({ it.blinding }, { it.blinding }) + val onion = add.fold({ it.finalPacket }, { it.onionRoutingPacket }) + return decryptOnion(paymentHash, onion, privateKey, blinding).flatMap { outer -> when (outer) { is PaymentOnion.FinalPayload.Standard -> when (val trampolineOnion = outer.records.get()) { - null -> validate(add, outer) + null -> validate(htlcAmount, htlcExpiry, outer) else -> { - when (val inner = decryptOnion(add.paymentHash, trampolineOnion.packet, privateKey, null)) { + when (val inner = decryptOnion(paymentHash, trampolineOnion.packet, privateKey, null)) { is Either.Left -> Either.Left(inner.value) is Either.Right -> when (val innerPayload = inner.value) { - is PaymentOnion.FinalPayload.Standard -> validate(add, outer, innerPayload) + is PaymentOnion.FinalPayload.Standard -> validate(htlcAmount, htlcExpiry, outer, innerPayload) // Blinded trampoline paths are not supported. is PaymentOnion.FinalPayload.Blinded -> Either.Left(InvalidOnionPayload(0, 0)) } } } } - is PaymentOnion.FinalPayload.Blinded -> validate(add, outer) + is PaymentOnion.FinalPayload.Blinded -> validate(htlcAmount, htlcExpiry, onion, outer) } } } - fun decryptOnion(paymentHash: ByteVector32, packet: OnionRoutingPacket, privateKey: PrivateKey, blinding: PublicKey?): Either { + private fun decryptOnion(paymentHash: ByteVector32, packet: OnionRoutingPacket, privateKey: PrivateKey, blinding: PublicKey?): Either { val onionDecryptionKey = blinding?.let { RouteBlinding.derivePrivateKey(privateKey, it) } ?: privateKey return Sphinx.peel(onionDecryptionKey, paymentHash, packet).flatMap { decrypted -> when { @@ -80,32 +97,32 @@ object IncomingPaymentPacket { .flatMap { blindedTlvs -> PaymentOnion.FinalPayload.Blinded.validate(tlvs, blindedTlvs) } } - private fun validate(add: UpdateAddHtlc, payload: PaymentOnion.FinalPayload.Standard): Either { + private fun validate(htlcAmount: MilliSatoshi, htlcExpiry: CltvExpiry, payload: PaymentOnion.FinalPayload.Standard): Either { return when { - add.amountMsat < payload.amount -> Either.Left(FinalIncorrectHtlcAmount(add.amountMsat)) - add.cltvExpiry < payload.expiry -> Either.Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) + htlcAmount < payload.amount -> Either.Left(FinalIncorrectHtlcAmount(htlcAmount)) + htlcExpiry < payload.expiry -> Either.Left(FinalIncorrectCltvExpiry(htlcExpiry)) else -> Either.Right(payload) } } - private fun validate(add: UpdateAddHtlc, payload: PaymentOnion.FinalPayload.Blinded): Either { + private fun validate(htlcAmount: MilliSatoshi, htlcExpiry: CltvExpiry, onion: OnionRoutingPacket, payload: PaymentOnion.FinalPayload.Blinded): Either { return when { - payload.recipientData.paymentConstraints?.let { add.amountMsat < it.minAmount } == true -> Either.Left(InvalidOnionBlinding(hash(add.onionRoutingPacket))) - payload.recipientData.paymentConstraints?.let { it.maxCltvExpiry < add.cltvExpiry } == true -> Either.Left(InvalidOnionBlinding(hash(add.onionRoutingPacket))) + payload.recipientData.paymentConstraints?.let { htlcAmount < it.minAmount } == true -> Either.Left(InvalidOnionBlinding(hash(onion))) + payload.recipientData.paymentConstraints?.let { it.maxCltvExpiry < htlcExpiry } == true -> Either.Left(InvalidOnionBlinding(hash(onion))) // We currently don't set the allowed_features field in our invoices. - !Features.areCompatible(Features.empty, payload.recipientData.allowedFeatures) -> Either.Left(InvalidOnionBlinding(hash(add.onionRoutingPacket))) - add.amountMsat < payload.amount -> Either.Left(InvalidOnionBlinding(hash(add.onionRoutingPacket))) - add.cltvExpiry < payload.expiry -> Either.Left(InvalidOnionBlinding(hash(add.onionRoutingPacket))) + !Features.areCompatible(Features.empty, payload.recipientData.allowedFeatures) -> Either.Left(InvalidOnionBlinding(hash(onion))) + htlcAmount < payload.amount -> Either.Left(InvalidOnionBlinding(hash(onion))) + htlcExpiry < payload.expiry -> Either.Left(InvalidOnionBlinding(hash(onion))) else -> Either.Right(payload) } } - private fun validate(add: UpdateAddHtlc, outerPayload: PaymentOnion.FinalPayload.Standard, innerPayload: PaymentOnion.FinalPayload.Standard): Either { + private fun validate(htlcAmount: MilliSatoshi, htlcExpiry: CltvExpiry, outerPayload: PaymentOnion.FinalPayload.Standard, innerPayload: PaymentOnion.FinalPayload.Standard): Either { return when { - add.amountMsat < outerPayload.amount -> Either.Left(FinalIncorrectHtlcAmount(add.amountMsat)) - add.cltvExpiry < outerPayload.expiry -> Either.Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) + htlcAmount < outerPayload.amount -> Either.Left(FinalIncorrectHtlcAmount(htlcAmount)) + htlcExpiry < outerPayload.expiry -> Either.Left(FinalIncorrectCltvExpiry(htlcExpiry)) // previous trampoline didn't forward the right expiry - outerPayload.expiry != innerPayload.expiry -> Either.Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) + outerPayload.expiry != innerPayload.expiry -> Either.Left(FinalIncorrectCltvExpiry(htlcExpiry)) // previous trampoline didn't forward the right amount outerPayload.totalAmount != innerPayload.amount -> Either.Left(FinalIncorrectHtlcAmount(outerPayload.totalAmount)) else -> { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentPacket.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentPacket.kt index 7d0a1fc0d..920ac64d1 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentPacket.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentPacket.kt @@ -17,10 +17,7 @@ import fr.acinq.lightning.router.ChannelHop import fr.acinq.lightning.router.Hop import fr.acinq.lightning.router.NodeHop import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.wire.FailureMessage -import fr.acinq.lightning.wire.OnionPaymentPayloadTlv -import fr.acinq.lightning.wire.OnionRoutingPacket -import fr.acinq.lightning.wire.PaymentOnion +import fr.acinq.lightning.wire.* object OutgoingPaymentPacket { @@ -165,4 +162,12 @@ object OutgoingPaymentPacket { } } + fun buildWillAddHtlcFailure(nodeSecret: PrivateKey, willAddHtlc: WillAddHtlc, failure: FailureMessage): OnTheFlyFundingMessage { + val reason = ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(failure) + return when (val f = buildHtlcFailure(nodeSecret, willAddHtlc.paymentHash, willAddHtlc.finalPacket, reason)) { + is Either.Right -> WillFailHtlc(willAddHtlc.id, willAddHtlc.paymentHash, f.value) + is Either.Left -> WillFailMalformedHtlc(willAddHtlc.id, willAddHtlc.paymentHash, Sphinx.hash(willAddHtlc.finalPacket), f.value.code) + } + } + } \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt index 16f5aea97..1ea499568 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt @@ -98,7 +98,7 @@ object UpdateAddHtlcSerializer : KSerializer { override fun deserialize(decoder: Decoder): UpdateAddHtlc { val surrogate = decoder.decodeSerializableValue(Surrogate.serializer()) - return UpdateAddHtlc(surrogate.channelId, surrogate.id, surrogate.amountMsat, surrogate.paymentHash, surrogate.cltvExpiry, surrogate.onionRoutingPacket, null) + return UpdateAddHtlc(surrogate.channelId, surrogate.id, surrogate.amountMsat, surrogate.paymentHash, surrogate.cltvExpiry, surrogate.onionRoutingPacket, null, null) } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt index 512da5ce2..1dc862ed1 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt @@ -98,7 +98,7 @@ object UpdateAddHtlcSerializer : KSerializer { override fun deserialize(decoder: Decoder): UpdateAddHtlc { val surrogate = decoder.decodeSerializableValue(Surrogate.serializer()) - return UpdateAddHtlc(surrogate.channelId, surrogate.id, surrogate.amountMsat, surrogate.paymentHash, surrogate.cltvExpiry, surrogate.onionRoutingPacket, null) + return UpdateAddHtlc(surrogate.channelId, surrogate.id, surrogate.amountMsat, surrogate.paymentHash, surrogate.cltvExpiry, surrogate.onionRoutingPacket, null, null) } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt index 85e80b62f..02da88516 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt @@ -239,17 +239,3 @@ sealed class ClosingSignedTlv : Tlv { } } } - -sealed class PayToOpenRequestTlv : Tlv { - /** Blinding ephemeral public key that should be used to derive shared secrets when using route blinding. */ - data class Blinding(val publicKey: PublicKey) : PayToOpenRequestTlv() { - override val tag: Long get() = Blinding.tag - - override fun write(out: Output) = LightningCodecs.writeBytes(publicKey.value, out) - - companion object : TlvValueReader { - const val tag: Long = 0 - override fun read(input: Input): Blinding = Blinding(PublicKey(LightningCodecs.bytes(input, 33))) - } - } -} diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/HtlcTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/HtlcTlv.kt index e24adc3cd..56f49b9b7 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/HtlcTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/HtlcTlv.kt @@ -1,8 +1,11 @@ package fr.acinq.lightning.wire import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.TxHash +import fr.acinq.bitcoin.TxId import fr.acinq.bitcoin.io.Input import fr.acinq.bitcoin.io.Output +import fr.acinq.lightning.utils.msat sealed class UpdateAddHtlcTlv : Tlv { /** Blinding ephemeral public key that should be used to derive shared secrets when using route blinding. */ @@ -16,4 +19,38 @@ sealed class UpdateAddHtlcTlv : Tlv { override fun read(input: Input): Blinding = Blinding(PublicKey(LightningCodecs.bytes(input, 33))) } } + + /** When on-the-fly funding is used, the liquidity fees may be taken from HTLCs relayed after funding. */ + data class FundingFeeTlv(val fee: LiquidityAds.FundingFee) : UpdateAddHtlcTlv() { + override val tag: Long get() = FundingFeeTlv.tag + + override fun write(out: Output) { + LightningCodecs.writeU64(fee.amount.toLong(), out) + LightningCodecs.writeTxHash(TxHash(fee.fundingTxId), out) + } + + companion object : TlvValueReader { + const val tag: Long = 41041 + override fun read(input: Input): FundingFeeTlv = FundingFeeTlv( + fee = LiquidityAds.FundingFee( + amount = LightningCodecs.u64(input).msat, + fundingTxId = TxId(LightningCodecs.txHash(input)), + ) + ) + } + } +} + +sealed class WillAddHtlcTlv : Tlv { + /** Blinding ephemeral public key that should be used to derive shared secrets when using route blinding. */ + data class Blinding(val publicKey: PublicKey) : WillAddHtlcTlv() { + override val tag: Long get() = Blinding.tag + + override fun write(out: Output) = LightningCodecs.writeBytes(publicKey.value, out) + + companion object : TlvValueReader { + const val tag: Long = 0 + override fun read(input: Input): Blinding = Blinding(PublicKey(LightningCodecs.bytes(input, 33))) + } + } } \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index 192da74c1..1bbfb2cb5 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -10,7 +10,6 @@ import fr.acinq.lightning.* import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.ChannelFlags import fr.acinq.lightning.channel.ChannelType -import fr.acinq.lightning.channel.Origin import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.router.Announcements import fr.acinq.lightning.utils.* @@ -80,8 +79,10 @@ interface LightningMessage { Shutdown.type -> Shutdown.read(stream) ClosingSigned.type -> ClosingSigned.read(stream) OnionMessage.type -> OnionMessage.read(stream) - PayToOpenRequest.type -> PayToOpenRequest.read(stream) - PayToOpenResponse.type -> PayToOpenResponse.read(stream) + WillAddHtlc.type -> WillAddHtlc.read(stream) + WillFailHtlc.type -> WillFailHtlc.read(stream) + WillFailMalformedHtlc.type -> WillFailMalformedHtlc.read(stream) + CancelOnTheFlyFunding.type -> CancelOnTheFlyFunding.read(stream) FCMToken.type -> FCMToken.read(stream) UnsetFCMToken.type -> UnsetFCMToken DNSAddressRequest.type -> DNSAddressRequest.read(stream) @@ -186,6 +187,8 @@ interface HasEncryptedChannelData : HasChannelId { interface ChannelMessage +interface OnTheFlyFundingMessage : LightningMessage + data class Init(val features: Features, val tlvs: TlvStream = TlvStream.empty()) : SetupMessage { val networks = tlvs.get()?.chainHashes ?: listOf() val liquidityRates = tlvs.get()?.rates?.fundingRates ?: listOf() @@ -1086,6 +1089,7 @@ data class UpdateAddHtlc( override val type: Long get() = UpdateAddHtlc.type val blinding: PublicKey? = tlvStream.get()?.publicKey + val fundingFee: LiquidityAds.FundingFee? = tlvStream.get()?.fee override fun write(out: Output) { LightningCodecs.writeBytes(channelId, out) @@ -1102,7 +1106,8 @@ data class UpdateAddHtlc( @Suppress("UNCHECKED_CAST") private val readers = mapOf( - UpdateAddHtlcTlv.Blinding.tag to UpdateAddHtlcTlv.Blinding as TlvValueReader + UpdateAddHtlcTlv.Blinding.tag to UpdateAddHtlcTlv.Blinding as TlvValueReader, + UpdateAddHtlcTlv.FundingFeeTlv.tag to UpdateAddHtlcTlv.FundingFeeTlv as TlvValueReader, ) override fun read(input: Input): UpdateAddHtlc { @@ -1123,10 +1128,14 @@ data class UpdateAddHtlc( paymentHash: ByteVector32, cltvExpiry: CltvExpiry, onionRoutingPacket: OnionRoutingPacket, - blinding: PublicKey? + blinding: PublicKey?, + fundingFee: LiquidityAds.FundingFee? ): UpdateAddHtlc { - val tlvStream = TlvStream(setOfNotNull(blinding?.let { UpdateAddHtlcTlv.Blinding(it) })) - return UpdateAddHtlc(channelId, id, amountMsat, paymentHash, cltvExpiry, onionRoutingPacket, tlvStream) + val tlvs = setOfNotNull( + blinding?.let { UpdateAddHtlcTlv.Blinding(it) }, + fundingFee?.let { UpdateAddHtlcTlv.FundingFeeTlv(it) } + ) + return UpdateAddHtlc(channelId, id, amountMsat, paymentHash, cltvExpiry, onionRoutingPacket, TlvStream(tlvs)) } } } @@ -1634,111 +1643,137 @@ data class OnionMessage( } /** - * When we don't have enough incoming liquidity to receive a payment, our peer may open a channel to us on-the-fly to carry that payment. - * This message contains details that allow us to recalculate the fee that our peer will take in exchange for the new channel. - * This allows us to combine multiple requests for the same payment and figure out the final fee that will be applied. - * - * @param chainHash chain we're on. - * @param amountMsat payment amount covered by this new channel: we will receive push_msat = amountMsat - fees. - * @param payToOpenFeeSatoshis fees that will be deducted from the amount pushed to us (this fee covers the on-chain fees our peer will pay to open the channel). - * @param paymentHash payment hash. - * @param expireAt after the proposal expires, our peer will fail the payment and won't open a channel to us. - * @param finalPacket onion packet that we would have received if there had been a channel to forward the payment to. + * This message is sent when an HTLC couldn't be relayed to our node because we don't have enough inbound liquidity. + * This allows us to treat it as an incoming payment, and request on-the-fly liquidity accordingly if we wish to receive that payment. + * If we accept the payment, we will send an [OpenDualFundedChannel] or [SpliceInit] message containing [ChannelTlv.RequestFundingTlv]. + * Our peer will then provide the requested funding liquidity and will relay the corresponding HTLC(s) afterwards. */ -data class PayToOpenRequest( +data class WillAddHtlc( override val chainHash: BlockHash, - val amountMsat: MilliSatoshi, - val payToOpenFeeSatoshis: Satoshi, + val id: ByteVector32, + val amount: MilliSatoshi, val paymentHash: ByteVector32, - val expireAt: Long, + val expiry: CltvExpiry, val finalPacket: OnionRoutingPacket, - val liquidity: Satoshi = 0.sat, - val tlvStream: TlvStream = TlvStream.empty(), -) : LightningMessage, HasChainHash { - override val type: Long get() = PayToOpenRequest.type + val tlvStream: TlvStream = TlvStream.empty() +) : OnTheFlyFundingMessage, HasChainHash { + override val type: Long get() = WillAddHtlc.type - val blinding: PublicKey? = tlvStream.get()?.publicKey + val blinding: PublicKey? = tlvStream.get()?.publicKey override fun write(out: Output) { LightningCodecs.writeBytes(chainHash.value, out) - LightningCodecs.writeU64(0, out) // backward compat for removed field fundingSatoshis - LightningCodecs.writeU64(amountMsat.toLong(), out) - LightningCodecs.writeU64(0, out) // backward compat for removed field payToOpenMinAmountMsat - LightningCodecs.writeU64(payToOpenFeeSatoshis.toLong(), out) + LightningCodecs.writeBytes(id, out) + LightningCodecs.writeU64(amount.toLong(), out) LightningCodecs.writeBytes(paymentHash, out) - LightningCodecs.writeU32(expireAt.toInt(), out) - LightningCodecs.writeU16(finalPacket.payload.size(), out) - OnionRoutingPacketSerializer(finalPacket.payload.size()).write(finalPacket, out) - LightningCodecs.writeU64(liquidity.toLong(), out) + LightningCodecs.writeU32(expiry.toLong().toInt(), out) + OnionRoutingPacketSerializer(OnionRoutingPacket.PaymentPacketLength).write(finalPacket, out) TlvStreamSerializer(false, readers).write(tlvStream, out) } - companion object : LightningMessageReader { - const val type: Long = 35021 + companion object : LightningMessageReader { + const val type: Long = 41041 @Suppress("UNCHECKED_CAST") private val readers = mapOf( - PayToOpenRequestTlv.Blinding.tag to PayToOpenRequestTlv.Blinding as TlvValueReader + WillAddHtlcTlv.Blinding.tag to WillAddHtlcTlv.Blinding as TlvValueReader, ) - override fun read(input: Input): PayToOpenRequest { - return PayToOpenRequest( - chainHash = BlockHash(LightningCodecs.bytes(input, 32)) - .also { LightningCodecs.u64(input) }, // ignoring removed field fundingSatoshis - amountMsat = MilliSatoshi(LightningCodecs.u64(input)) - .also { LightningCodecs.u64(input) }, // ignoring removed field payToOpenMinAmountMsat - payToOpenFeeSatoshis = Satoshi(LightningCodecs.u64(input)), - paymentHash = ByteVector32(LightningCodecs.bytes(input, 32)), - expireAt = LightningCodecs.u32(input).toLong(), - finalPacket = OnionRoutingPacketSerializer(LightningCodecs.u16(input)).read(input), - liquidity = Satoshi(LightningCodecs.u64(input)), - tlvStream = TlvStreamSerializer(false, readers).read(input), - ) + override fun read(input: Input): WillAddHtlc = WillAddHtlc( + chainHash = BlockHash(LightningCodecs.bytes(input, 32)), + id = LightningCodecs.bytes(input, 32).byteVector32(), + amount = LightningCodecs.u64(input).msat, + paymentHash = LightningCodecs.bytes(input, 32).byteVector32(), + expiry = CltvExpiry(LightningCodecs.u32(input).toLong()), + finalPacket = OnionRoutingPacketSerializer(OnionRoutingPacket.PaymentPacketLength).read(input), + tlvStream = TlvStreamSerializer(false, readers).read(input) + ) + + operator fun invoke( + chainHash: BlockHash, + id: ByteVector32, + amount: MilliSatoshi, + paymentHash: ByteVector32, + cltvExpiry: CltvExpiry, + onionRoutingPacket: OnionRoutingPacket, + blinding: PublicKey? + ): WillAddHtlc { + val tlvStream = TlvStream(setOfNotNull(blinding?.let { WillAddHtlcTlv.Blinding(it) })) + return WillAddHtlc(chainHash, id, amount, paymentHash, cltvExpiry, onionRoutingPacket, tlvStream) } } } -data class PayToOpenResponse(override val chainHash: BlockHash, val paymentHash: ByteVector32, val result: Result) : LightningMessage, HasChainHash { - override val type: Long get() = PayToOpenResponse.type +data class WillFailHtlc(val id: ByteVector32, val paymentHash: ByteVector32, val reason: ByteVector) : OnTheFlyFundingMessage { + override val type: Long get() = WillFailHtlc.type - sealed class Result { - // @formatter:off - data class Success(val paymentPreimage: ByteVector32) : Result() - /** reason is an onion-encrypted failure message, like those in UpdateFailHtlc */ - data class Failure(val reason: ByteVector?) : Result() - // @formatter:on + override fun write(out: Output) { + LightningCodecs.writeBytes(id, out) + LightningCodecs.writeBytes(paymentHash, out) + LightningCodecs.writeU16(reason.size(), out) + LightningCodecs.writeBytes(reason, out) + } + + companion object : LightningMessageReader { + const val type: Long = 41042 + + override fun read(input: Input): WillFailHtlc = WillFailHtlc( + id = LightningCodecs.bytes(input, 32).byteVector32(), + paymentHash = LightningCodecs.bytes(input, 32).byteVector32(), + reason = LightningCodecs.bytes(input, LightningCodecs.u16(input)).byteVector() + ) } +} + +data class WillFailMalformedHtlc(val id: ByteVector32, val paymentHash: ByteVector32, val onionHash: ByteVector32, val failureCode: Int) : OnTheFlyFundingMessage { + override val type: Long get() = WillFailMalformedHtlc.type override fun write(out: Output) { - LightningCodecs.writeBytes(chainHash.value, out) + LightningCodecs.writeBytes(id, out) LightningCodecs.writeBytes(paymentHash, out) - when (result) { - is Result.Success -> LightningCodecs.writeBytes(result.paymentPreimage, out) - is Result.Failure -> { - LightningCodecs.writeBytes(ByteVector32.Zeroes, out) // this is for backward compatibility - result.reason?.let { - LightningCodecs.writeU16(it.size(), out) - LightningCodecs.writeBytes(it, out) - } - } - } + LightningCodecs.writeBytes(onionHash, out) + LightningCodecs.writeU16(failureCode, out) } - companion object : LightningMessageReader { - const val type: Long = 35003 + companion object : LightningMessageReader { + const val type: Long = 41043 - override fun read(input: Input): PayToOpenResponse { - val chainHash = BlockHash(LightningCodecs.bytes(input, 32)) - val paymentHash = LightningCodecs.bytes(input, 32).toByteVector32() - return when (val preimage = LightningCodecs.bytes(input, 32).toByteVector32()) { - ByteVector32.Zeroes -> { - val failure = if (input.availableBytes > 0) LightningCodecs.bytes(input, LightningCodecs.u16(input)).toByteVector() else null - PayToOpenResponse(chainHash, paymentHash, Result.Failure(failure)) - } - - else -> PayToOpenResponse(chainHash, paymentHash, Result.Success(preimage)) - } - } + override fun read(input: Input): WillFailMalformedHtlc = WillFailMalformedHtlc( + id = LightningCodecs.bytes(input, 32).byteVector32(), + paymentHash = LightningCodecs.bytes(input, 32).byteVector32(), + onionHash = LightningCodecs.bytes(input, 32).byteVector32(), + failureCode = LightningCodecs.u16(input), + ) + } +} + +/** + * This message is sent in response to an [OpenDualFundedChannel] or [SpliceInit] message containing an invalid [LiquidityAds.RequestFunds]. + * The receiver must consider the funding attempt failed when receiving this message. + */ +data class CancelOnTheFlyFunding(override val channelId: ByteVector32, val paymentHashes: List, val reason: ByteVector) : OnTheFlyFundingMessage, HasChannelId { + constructor(channelId: ByteVector32, paymentHashes: List, message: String?) : this(channelId, paymentHashes, ByteVector(message?.encodeToByteArray() ?: ByteArray(0))) + + override val type: Long get() = CancelOnTheFlyFunding.type + + fun toAscii(): String = reason.toByteArray().decodeToString() + + override fun write(out: Output) { + LightningCodecs.writeBytes(channelId, out) + LightningCodecs.writeU16(paymentHashes.size, out) + paymentHashes.forEach { LightningCodecs.writeBytes(it, out) } + LightningCodecs.writeU16(reason.size(), out) + LightningCodecs.writeBytes(reason, out) + } + + companion object : LightningMessageReader { + const val type: Long = 41044 + + override fun read(input: Input): CancelOnTheFlyFunding = CancelOnTheFlyFunding( + channelId = LightningCodecs.bytes(input, 32).byteVector32(), + paymentHashes = (0 until LightningCodecs.u16(input)).map { LightningCodecs.bytes(input, 32).byteVector32() }, + reason = LightningCodecs.bytes(input, LightningCodecs.u16(input)).byteVector() + ) } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt index 75f8836ea..d80c6de6c 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt @@ -5,6 +5,7 @@ import fr.acinq.bitcoin.io.ByteArrayOutput import fr.acinq.bitcoin.io.Input import fr.acinq.bitcoin.io.Output import fr.acinq.bitcoin.utils.Either +import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.ChannelException import fr.acinq.lightning.channel.InvalidLiquidityAdsAmount @@ -29,6 +30,9 @@ object LiquidityAds { val total: Satoshi = miningFee + serviceFee } + /** Fees paid for the funding transaction that provides liquidity. */ + data class FundingFee(val amount: MilliSatoshi, val fundingTxId: TxId) + /** * Rate at which a liquidity seller sells its liquidity. * Liquidity fees are computed based on multiple components. diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt index 8efd14986..0f8ae24d4 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt @@ -36,11 +36,11 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val alice0 = InteractiveTxSession(f.nodeIdB, f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) val bob0 = InteractiveTxSession(f.nodeIdA, f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) - + // 3 swap-in inputs, 2 legacy swap-in inputs, and 2 outputs from Alice // 2 swap-in inputs, 2 legacy swap-in inputs, and 1 output from Bob - // Alice --- tx_add_input --> Bob + // Alice --- tx_add_input --> Bob val (alice1, inputA1) = sendMessage(alice0) assertEquals(0xfffffffdU, inputA1.sequence) // Alice <-- tx_add_input --- Bob @@ -93,7 +93,6 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertEquals(signedTxB.localSigs.swapInServerSigs.size, 2) assertEquals(signedTxB.localSigs.swapInServerPartialSigs.size, 3) - // Alice detects invalid signatures from Bob. val sigsInvalidTxId = signedTxB.localSigs.copy(txId = TxId(randomBytes32())) assertNull(sharedTxA.sharedTx.sign(alice7, f.keyManagerA, f.fundingParamsA, f.localParamsA, f.nodeIdB).addRemoteSigs(f.channelKeysA, f.fundingParamsA, sigsInvalidTxId)) @@ -348,6 +347,49 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertTrue(targetFeerate <= feerate && feerate <= targetFeerate * 1.25, "unexpected feerate (target=$targetFeerate actual=$feerate)") } + @Test + fun `initiator does not contribute -- on-the-fly funding`() { + // When on-the-fly funding is used, the initiator may not contribute to the funding transaction. + // It will receive HTLCs later that use the purchased inbound liquidity, and liquidity fees will be deduced from those HTLCs. + val targetFeerate = FeeratePerKw(5000.sat) + val fundingB = 150_000.sat + val utxosB = listOf(200_000.sat) + val f = createFixture(0.sat, listOf(), listOf(), fundingB, utxosB, listOf(), targetFeerate, 330.sat, 0, nonInitiatorPaysCommitFees = true) + assertEquals(f.fundingParamsA.fundingAmount, fundingB) + + val alice0 = InteractiveTxSession(f.nodeIdB, f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.nodeIdA, f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) + // Alice --- tx_add_output --> Bob + val (alice1, sharedOutput) = sendMessage(alice0) + // Alice <-- tx_add_input --- Bob + val (bob1, inputB) = receiveMessage(bob0, sharedOutput) + // Alice --- tx_complete --> Bob + val (alice2, txCompleteA1) = receiveMessage(alice1, inputB) + // Alice <-- tx_add_output --- Bob + val (bob2, outputB) = receiveMessage(bob1, txCompleteA1) + // Alice --- tx_complete --> Bob + val (alice3, txCompleteA2) = receiveMessage(alice2, outputB) + // Alice <-- tx_complete --- Bob + val (bob3, sharedTxB) = receiveFinalMessage(bob2, txCompleteA2) + assertNotNull(sharedTxB.txComplete) + val (alice4, sharedTxA) = receiveFinalMessage(alice3, sharedTxB.txComplete!!) + assertNull(sharedTxA.txComplete) + + // Alice cannot pay on-chain fees because she doesn't have inputs to contribute. + // She will pay liquidity fees instead that will be taken from the future relayed HTLCs. + assertEquals(0.msat, sharedTxA.sharedTx.localFees) + assertEquals(0.msat, sharedTxB.sharedTx.remoteFees) + + // Alice signs first since she didn't contribute. + val signedTxA = sharedTxA.sharedTx.sign(alice4, f.keyManagerA, f.fundingParamsA, f.localParamsA, f.nodeIdB) + val signedTxB = sharedTxB.sharedTx.sign(bob3, f.keyManagerB, f.fundingParamsB, f.localParamsB, f.nodeIdA).addRemoteSigs(f.channelKeysB, f.fundingParamsB, signedTxA.localSigs) + assertNotNull(signedTxB) + Transaction.correctlySpends(signedTxB.signedTx, sharedTxB.sharedTx.localInputs.map { it.previousTx }, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + // The feerate is lower than expected since Alice didn't contribute. + val feerate = Transactions.fee2rate(signedTxA.tx.fees, signedTxB.signedTx.weight()) + assertTrue(targetFeerate * 0.5 <= feerate && feerate <= targetFeerate, "unexpected feerate (target=$targetFeerate actual=$feerate)") + } + @Test fun `initiator and non-initiator splice-in`() { val targetFeerate = FeeratePerKw(1000.sat) @@ -655,6 +697,45 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertTrue(targetFeerate <= feerate && feerate <= targetFeerate * 1.25, "unexpected feerate (target=$targetFeerate actual=$feerate)") } + @Test + fun `initiator does not contribute -- on-the-fly splicing`() { + // When on-the-fly funding is used, the initiator may not contribute to the funding transaction. + // It will receive HTLCs later that use the purchased inbound liquidity, and liquidity fees will be deduced from those HTLCs. + val targetFeerate = FeeratePerKw(5000.sat) + val balanceA = 0.msat + val balanceB = 75_000_000.msat + val additionalFundingB = 50_000.sat + val utxosB = listOf(90_000.sat) + val f = createSpliceFixture(balanceA, 0.sat, listOf(), listOf(), balanceB, additionalFundingB, utxosB, listOf(), targetFeerate, 330.sat, 0, nonInitiatorPaysCommitFees = true) + assertEquals(f.fundingParamsA.fundingAmount, 125_000.sat) + + val alice0 = InteractiveTxSession(f.nodeIdB, f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, balanceA, balanceB, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.nodeIdA, f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, balanceB, balanceA, emptySet(), f.fundingContributionsB) + // Alice --- tx_add_input --> Bob + val (alice1, sharedInput) = sendMessage(alice0) + // Alice <-- tx_add_input --- Bob + val (bob1, inputB) = receiveMessage(bob0, sharedInput) + // Alice --- tx_add_output --> Bob + val (alice2, sharedOutput) = receiveMessage(alice1, inputB) + // Alice <-- tx_add_output --- Bob + val (bob2, outputB) = receiveMessage(bob1, sharedOutput) + // Alice --- tx_complete --> Bob + val (alice3, txCompleteA) = receiveMessage(alice2, outputB) + // Alice <-- tx_complete --- Bob + val (bob3, sharedTxB) = receiveFinalMessage(bob2, txCompleteA) + assertNotNull(sharedTxB.txComplete) + val (alice4, sharedTxA) = receiveFinalMessage(alice3, sharedTxB.txComplete!!) + + // Alice signs first since she didn't contribute. + val signedTxA = sharedTxA.sharedTx.sign(alice4, f.keyManagerA, f.fundingParamsA, f.localParamsA, f.nodeIdB) + val signedTxB = sharedTxB.sharedTx.sign(bob3, f.keyManagerB, f.fundingParamsB, f.localParamsB, f.nodeIdA).addRemoteSigs(f.channelKeysB, f.fundingParamsB, signedTxA.localSigs) + assertNotNull(signedTxB) + Transaction.correctlySpends(signedTxB.signedTx, previousOutputs(f.fundingParamsA, sharedTxB.sharedTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + // The feerate is lower than expected since Alice didn't contribute. + val feerate = Transactions.fee2rate(signedTxA.tx.fees, signedTxB.signedTx.weight()) + assertTrue(targetFeerate * 0.25 <= feerate && feerate <= targetFeerate, "unexpected feerate (target=$targetFeerate actual=$feerate)") + } + @Test fun `remove input - output`() { val f = createFixture(100_000.sat, listOf(150_000.sat), listOf(), 0.sat, listOf(), listOf(), FeeratePerKw(2500.sat), 330.sat, 0) @@ -662,7 +743,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val alice0 = InteractiveTxSession(f.nodeIdB, f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) val bob0 = InteractiveTxSession(f.nodeIdA, f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), FundingContributions(listOf(), listOf())) - // Alice --- tx_add_input --> Bob + // Alice --- tx_add_input --> Bob val (alice1, inputA) = sendMessage(alice0) val (_, txCompleteB) = receiveMessage(bob0, inputA) @@ -722,12 +803,6 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertNotNull(result) assertIs(result) } - run { - val previousTx = Transaction(2, listOf(), listOf(TxOut(80_000.sat, Script.pay2wpkh(pubKey)), TxOut(70_001.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 - assertNotNull(result) - assertIs(result) - } } @Test @@ -1184,12 +1259,13 @@ class InteractiveTxTestsCommon : LightningTestSuite() { legacyUtxosB: List, targetFeerate: FeeratePerKw, dustLimit: Satoshi, - lockTime: Long + lockTime: Long, + nonInitiatorPaysCommitFees: Boolean = false, ): Fixture { val channelId = randomBytes32() val fundingTxIndex = 0L - val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = true) - val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = false) + val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = !nonInitiatorPaysCommitFees) + val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = nonInitiatorPaysCommitFees) val channelKeysA = localParamsA.channelKeys(TestConstants.Alice.keyManager) val channelKeysB = localParamsB.channelKeys(TestConstants.Bob.keyManager) val swapInKeysA = TestConstants.Alice.keyManager.swapInOnChainWallet @@ -1216,11 +1292,12 @@ class InteractiveTxTestsCommon : LightningTestSuite() { fundingContributionB: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, - lockTime: Long + lockTime: Long, + nonInitiatorPaysCommitFees: Boolean = false, ): Either { val channelId = randomBytes32() - val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = true) - val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = false) + val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = !nonInitiatorPaysCommitFees) + val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = nonInitiatorPaysCommitFees) val channelKeysA = localParamsA.channelKeys(TestConstants.Alice.keyManager) val channelKeysB = localParamsB.channelKeys(TestConstants.Bob.keyManager) val swapInKeysA = TestConstants.Alice.keyManager.swapInOnChainWallet @@ -1248,12 +1325,13 @@ class InteractiveTxTestsCommon : LightningTestSuite() { outputsB: List, targetFeerate: FeeratePerKw, dustLimit: Satoshi, - lockTime: Long + lockTime: Long, + nonInitiatorPaysCommitFees: Boolean = false, ): Fixture { val channelId = randomBytes32() val fundingTxIndex = 0L - val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = true) - val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = false) + val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = !nonInitiatorPaysCommitFees) + val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = nonInitiatorPaysCommitFees) val channelKeysA = localParamsA.channelKeys(TestConstants.Alice.keyManager) val channelKeysB = localParamsB.channelKeys(TestConstants.Bob.keyManager) val swapInKeysA = TestConstants.Alice.keyManager.swapInOnChainWallet diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt index 9451f3e37..2296c53cf 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt @@ -2,8 +2,11 @@ package fr.acinq.lightning.channel.states import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either -import fr.acinq.lightning.* +import fr.acinq.lightning.CltvExpiry +import fr.acinq.lightning.CltvExpiryDelta +import fr.acinq.lightning.Feature import fr.acinq.lightning.Lightning.randomBytes32 +import fr.acinq.lightning.ShortChannelId import fr.acinq.lightning.blockchain.* import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.* @@ -1564,7 +1567,7 @@ class NormalTestsCommon : LightningTestSuite() { @Test fun `recv ChannelCommand_Close_MutualClose -- with unsupported native segwit script`() { - val (alice, _) = reachNormal() + val (alice, _) = reachNormal(aliceFeatures = TestConstants.Alice.nodeParams.features.remove(Feature.ShutdownAnySegwit)) assertNull(alice.state.localShutdown) val (alice1, actions1) = alice.process(ChannelCommand.Close.MutualClose(ByteVector("51050102030405"), null)) assertIs>(alice1) @@ -1573,10 +1576,7 @@ class NormalTestsCommon : LightningTestSuite() { @Test fun `recv ChannelCommand_Close_MutualClose -- with native segwit script`() { - val (alice, _) = reachNormal( - aliceFeatures = TestConstants.Alice.nodeParams.features.copy(TestConstants.Alice.nodeParams.features.activated + (Feature.ShutdownAnySegwit to FeatureSupport.Optional)), - bobFeatures = TestConstants.Bob.nodeParams.features.copy(TestConstants.Bob.nodeParams.features.activated + (Feature.ShutdownAnySegwit to FeatureSupport.Optional)), - ) + val (alice, _) = reachNormal() assertNull(alice.state.localShutdown) val (alice1, actions1) = alice.process(ChannelCommand.Close.MutualClose(ByteVector("51050102030405"), null)) actions1.hasOutgoingMessage() @@ -1724,7 +1724,7 @@ class NormalTestsCommon : LightningTestSuite() { @Test fun `recv Shutdown -- with unsupported native segwit script`() { - val (_, bob) = reachNormal() + val (_, bob) = reachNormal(bobFeatures = TestConstants.Bob.nodeParams.features.remove(Feature.ShutdownAnySegwit)) val (bob1, actions1) = bob.process(ChannelCommand.MessageReceived(Shutdown(bob.channelId, ByteVector("51050102030405")))) assertIs>(bob1) actions1.hasOutgoingMessage() @@ -1734,10 +1734,7 @@ class NormalTestsCommon : LightningTestSuite() { @Test fun `recv Shutdown -- with native segwit script`() { - val (_, bob) = reachNormal( - aliceFeatures = TestConstants.Alice.nodeParams.features.copy(TestConstants.Alice.nodeParams.features.activated + (Feature.ShutdownAnySegwit to FeatureSupport.Optional)), - bobFeatures = TestConstants.Bob.nodeParams.features.copy(TestConstants.Bob.nodeParams.features.activated + (Feature.ShutdownAnySegwit to FeatureSupport.Optional)), - ) + val (_, bob) = reachNormal() val (bob1, actions1) = bob.process(ChannelCommand.MessageReceived(Shutdown(bob.channelId, ByteVector("51050102030405")))) assertIs>(bob1) actions1.hasOutgoingMessage() 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 95340b309..9152669aa 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt @@ -247,7 +247,9 @@ class SpliceTestsCommon : LightningTestSuite() { val (_, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) val aliceStfu = actionsAlice1.findOutgoingMessage() val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) - assertTrue(actionsBob2.isEmpty()) + assertEquals(2, actionsBob2.size) + actionsBob2.hasOutgoingMessage() + actionsBob2.has() assertTrue(cmd.replyTo.isCompleted) assertEquals(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds, cmd.replyTo.getCompleted()) } @@ -276,6 +278,44 @@ class SpliceTestsCommon : LightningTestSuite() { } } + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `splice to purchase inbound liquidity -- not enough funds but on-the-fly funding`() { + val (alice, bob) = reachNormal(channelType = ChannelType.SupportedChannelType.AnchorOutputsZeroReserve, bobFundingAmount = 0.sat, alicePushAmount = 0.msat, bobPushAmount = 0.msat) + val fundingRate = LiquidityAds.FundingRate(0.sat, 500_000.sat, 0, 50, 0.sat) + val 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. + 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 (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()) + } + 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)) + 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) + } + } + } + @Test fun `reject splice_init`() { val cmd = createSpliceOutRequest(25_000.sat) @@ -290,6 +330,17 @@ class SpliceTestsCommon : LightningTestSuite() { actionsAlice2.hasOutgoingMessage() } + @Test + fun `reject splice_init -- cancel on-the-fly funding`() { + val cmd = createSpliceOutRequest(50_000.sat) + val (alice, bob) = reachNormal() + val (alice1, _, _) = reachQuiescent(cmd, alice, bob) + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(CancelOnTheFlyFunding(alice.channelId, listOf(randomBytes32()), "cancelling on-the-fly funding"))) + assertIs(alice2.state) + assertEquals(alice2.state.spliceStatus, SpliceStatus.None) + assertTrue(actionsAlice2.isEmpty()) + } + @Test fun `reject splice_ack`() { val cmd = createSpliceOutRequest(25_000.sat) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt b/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt index b6760d2ed..f20ab9337 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt @@ -10,6 +10,7 @@ import fr.acinq.lightning.utils.toByteVector32 class InMemoryPaymentsDb : PaymentsDb { private val incoming = mutableMapOf() private val outgoing = mutableMapOf() + private val onChainOutgoing = mutableMapOf() private val outgoingParts = mutableMapOf>() override suspend fun setLocked(txId: TxId) {} @@ -70,7 +71,7 @@ class InMemoryPaymentsDb : PaymentsDb { outgoing[outgoingPayment.id] = outgoingPayment.copy(parts = listOf()) outgoingPayment.parts.forEach { outgoingParts[it.id] = Pair(outgoingPayment.id, it) } } - is OnChainOutgoingPayment -> {} // we don't persist on-chain payments + is OnChainOutgoingPayment -> onChainOutgoing[outgoingPayment.txId] = outgoingPayment } } @@ -84,6 +85,13 @@ class InMemoryPaymentsDb : PaymentsDb { } } + override suspend fun getInboundLiquidityPurchase(fundingTxId: TxId): InboundLiquidityOutgoingPayment? { + return when (val onChainPayment = onChainOutgoing[fundingTxId]) { + is InboundLiquidityOutgoingPayment -> onChainPayment + else -> null + } + } + override suspend fun completeOutgoingPaymentOffchain(id: UUID, preimage: ByteVector32, completedAt: Long) { require(outgoing.contains(id)) { "outgoing payment with id=$id doesn't exist" } val payment = outgoing[id]!! diff --git a/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt index f895d10cf..fc805d27f 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt @@ -1,9 +1,6 @@ package fr.acinq.lightning.db -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.Chain -import fr.acinq.bitcoin.Crypto -import fr.acinq.bitcoin.TxId +import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 @@ -13,12 +10,13 @@ import fr.acinq.lightning.payment.FinalFailure import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.tests.utils.runSuspendTest import fr.acinq.lightning.utils.* +import fr.acinq.lightning.wire.LiquidityAds import kotlin.test.* class PaymentsDbTestsCommon : LightningTestSuite() { @Test - fun `receive incoming payment with 1 htlc`() = runSuspendTest { + fun `receive incoming lightning payment with 1 htlc`() = runSuspendTest { val (db, preimage, pr) = createFixture() assertNull(db.getIncomingPayment(pr.paymentHash)) @@ -29,39 +27,19 @@ class PaymentsDbTestsCommon : LightningTestSuite() { assertNotNull(pending) assertEquals(incoming, pending) - db.receivePayment( - pr.paymentHash, - listOf( - IncomingPayment.ReceivedWith.LightningPayment( - amount = 200_000.msat, - channelId = channelId, - htlcId = 1L - ) - ), 110 - ) + val receivedWith = IncomingPayment.ReceivedWith.LightningPayment(200_000.msat, channelId, 1, fundingFee = null) + db.receivePayment(pr.paymentHash, listOf(receivedWith), 110) val received = db.getIncomingPayment(pr.paymentHash) assertNotNull(received) - assertEquals( - pending.copy( - received = IncomingPayment.Received( - listOf( - IncomingPayment.ReceivedWith.LightningPayment( - amount = 200_000.msat, - channelId = channelId, - htlcId = 1L - ) - ), 110 - ) - ), received - ) + assertEquals(pending.copy(received = IncomingPayment.Received(listOf(receivedWith), 110)), received) } @Test - fun `receive incoming payment with several parts`() = runSuspendTest { + fun `receive incoming lightning payment with several parts`() = runSuspendTest { val (db, preimage, pr) = createFixture() assertNull(db.getIncomingPayment(pr.paymentHash)) - val (channelId1, channelId2, channelId3) = listOf(randomBytes32(), randomBytes32(), randomBytes32()) + val (channelId1, channelId2) = listOf(randomBytes32(), randomBytes32()) val incoming = IncomingPayment(preimage, IncomingPayment.Origin.Invoice(pr), null, 200) db.addIncomingPayment(preimage, IncomingPayment.Origin.Invoice(pr), 200) val pending = db.getIncomingPayment(pr.paymentHash) @@ -70,94 +48,80 @@ class PaymentsDbTestsCommon : LightningTestSuite() { db.receivePayment( pr.paymentHash, listOf( - IncomingPayment.ReceivedWith.LightningPayment(amount = 57_000.msat, channelId = channelId1, htlcId = 1L), - IncomingPayment.ReceivedWith.LightningPayment(amount = 43_000.msat, channelId = channelId2, htlcId = 54L), - IncomingPayment.ReceivedWith.NewChannel(amount = 99_000.msat, channelId = channelId3, serviceFee = 1_000.msat, miningFee = 0.sat, txId = TxId(randomBytes32()), confirmedAt = null, lockedAt = null) + IncomingPayment.ReceivedWith.LightningPayment(57_000.msat, channelId1, 1, fundingFee = null), + IncomingPayment.ReceivedWith.LightningPayment(43_000.msat, channelId2, 54, fundingFee = null), ), 110 ) val received = db.getIncomingPayment(pr.paymentHash) assertNotNull(received) - assertEquals(199_000.msat, received.amount) - assertEquals(1_000.msat, received.fees) - assertEquals(3, received.received!!.receivedWith.size) + assertEquals(100_000.msat, received.amount) + assertEquals(0.msat, received.fees) + assertEquals(2, received.received!!.receivedWith.size) assertEquals(57_000.msat, received.received!!.receivedWith.elementAt(0).amount) assertEquals(0.msat, received.received!!.receivedWith.elementAt(0).fees) assertEquals(channelId1, (received.received!!.receivedWith.elementAt(0) as IncomingPayment.ReceivedWith.LightningPayment).channelId) - assertEquals(54L, (received.received!!.receivedWith.elementAt(1) as IncomingPayment.ReceivedWith.LightningPayment).htlcId) - assertEquals(channelId3, (received.received!!.receivedWith.elementAt(2) as IncomingPayment.ReceivedWith.NewChannel).channelId) + assertEquals(54, (received.received!!.receivedWith.elementAt(1) as IncomingPayment.ReceivedWith.LightningPayment).htlcId) } @Test - fun `receiving several payments on the same payment hash is additive`() = runSuspendTest { + fun `receive several incoming lightning payments with the same payment hash`() = runSuspendTest { val (db, preimage, pr) = createFixture() val channelId = randomBytes32() db.addIncomingPayment(preimage, IncomingPayment.Origin.Invoice(pr), 200) - db.receivePayment( - pr.paymentHash, listOf( - IncomingPayment.ReceivedWith.LightningPayment( - amount = 200_000.msat, - channelId = channelId, - htlcId = 1L - ) - ), 110 + val receivedWith = listOf( + IncomingPayment.ReceivedWith.LightningPayment(200_000.msat, channelId, 1, fundingFee = null), + IncomingPayment.ReceivedWith.LightningPayment(100_000.msat, channelId, 2, fundingFee = null) ) + db.receivePayment(pr.paymentHash, listOf(receivedWith.first()), 110) val received1 = db.getIncomingPayment(pr.paymentHash) assertNotNull(received1) assertNotNull(received1.received) assertEquals(200_000.msat, received1.amount) - db.receivePayment( - pr.paymentHash, listOf( - IncomingPayment.ReceivedWith.LightningPayment( - amount = 100_000.msat, - channelId = channelId, - htlcId = 2L - ) - ), 150 - ) + db.receivePayment(pr.paymentHash, listOf(receivedWith.last()), 150) val received2 = db.getIncomingPayment(pr.paymentHash) assertNotNull(received2) assertNotNull(received2.received) assertEquals(300_000.msat, received2.amount) assertEquals(150, received2.received!!.receivedAt) - assertEquals( - listOf( - IncomingPayment.ReceivedWith.LightningPayment( - amount = 200_000.msat, - channelId = channelId, - htlcId = 1L - ), - IncomingPayment.ReceivedWith.LightningPayment( - amount = 100_000.msat, - channelId = channelId, - htlcId = 2L - ) - ), received2.received!!.receivedWith - ) + assertEquals(receivedWith, received2.received!!.receivedWith) } @Test - fun `received total amount accounts for the fee`() = runSuspendTest { + fun `receive lightning payment with funding fee`() = runSuspendTest { val (db, preimage, pr) = createFixture() db.addIncomingPayment(preimage, IncomingPayment.Origin.Invoice(pr), 200) - db.receivePayment( - pr.paymentHash, listOf( - IncomingPayment.ReceivedWith.NewChannel( - amount = 500_000.msat, - serviceFee = 15_000.msat, - miningFee = 0.sat, - channelId = randomBytes32(), - txId = TxId(randomBytes32()), - confirmedAt = null, - lockedAt = null - ) - ), 110 - ) - val received1 = db.getIncomingPayment(pr.paymentHash) - assertNotNull(received1?.received) - assertEquals(500_000.msat, received1!!.amount) - assertEquals(15_000.msat, received1.fees) + val receivedWith = IncomingPayment.ReceivedWith.LightningPayment(40_000_000.msat, randomBytes32(), 3, LiquidityAds.FundingFee(10_000_000.msat, TxId(randomBytes32()))) + db.receivePayment(pr.paymentHash, listOf(receivedWith), 110) + val received = db.getIncomingPayment(pr.paymentHash) + assertNotNull(received?.received) + assertEquals(40_000_000.msat, received!!.amount) + assertEquals(10_000_000.msat, received.fees) + } + + @Test + fun `receive incoming on-chain payments`() = runSuspendTest { + val (db, _, _) = createFixture() + val origin = IncomingPayment.Origin.OnChain(TxId(randomBytes32()), setOf(OutPoint(TxId(randomBytes32()), 7))) + run { + val incomingPayment = db.addIncomingPayment(randomBytes32(), origin) + val receivedWith = IncomingPayment.ReceivedWith.NewChannel(100_000_000.msat, 5_000_000.msat, 2_500.sat, randomBytes32(), origin.txId, null, null) + db.receivePayment(incomingPayment.paymentHash, listOf(receivedWith)) + val received = db.getIncomingPayment(incomingPayment.paymentHash) + assertNotNull(received?.received) + assertEquals(100_000_000.msat, received!!.amount) + assertEquals(7_500_000.msat, received.fees) + } + run { + val incomingPayment = db.addIncomingPayment(randomBytes32(), origin) + val receivedWith = IncomingPayment.ReceivedWith.SpliceIn(100_000_000.msat, 5_000_000.msat, 2_500.sat, randomBytes32(), origin.txId, null, null) + db.receivePayment(incomingPayment.paymentHash, listOf(receivedWith)) + val received = db.getIncomingPayment(incomingPayment.paymentHash) + assertNotNull(received?.received) + assertEquals(100_000_000.msat, received!!.amount) + assertEquals(7_500_000.msat, received.fees) + } } @Test 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 9897f4bb2..6898b164a 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/io/peer/PeerTest.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/io/peer/PeerTest.kt @@ -29,6 +29,7 @@ import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat import fr.acinq.lightning.wire.* +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlin.test.* @@ -253,8 +254,7 @@ class PeerTest : LightningTestSuite() { val walletBob = createWallet(nodeParams.second.keyManager, 1_000_000.sat).second bob.send(AddWalletInputsToChannel(walletBob)) - val rejected = bob.nodeParams.nodeEvents.first { it is LiquidityEvents } - assertIs(rejected) + val rejected = bob.nodeParams.nodeEvents.filterIsInstance().first() assertEquals(500_000_000.msat, rejected.amount) assertEquals(LiquidityEvents.Source.OnChainWallet, rejected.source) assertEquals(LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(maxRelativeFeeBasisPoints = 10), rejected.reason) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt index 0dd0764e1..ba3d0ff6f 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt @@ -3,15 +3,18 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* +import fr.acinq.lightning.Lightning.randomBytes import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.channel.* import fr.acinq.lightning.crypto.RouteBlinding import fr.acinq.lightning.crypto.sphinx.Sphinx.hash import fr.acinq.lightning.db.InMemoryPaymentsDb +import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.IncomingPaymentsDb -import fr.acinq.lightning.io.PayToOpenResponseCommand +import fr.acinq.lightning.io.AddLiquidityForIncomingPayment +import fr.acinq.lightning.io.SendOnTheFlyFundingMessage import fr.acinq.lightning.io.WrappedChannelCommand import fr.acinq.lightning.router.ChannelHop import fr.acinq.lightning.router.NodeHop @@ -20,7 +23,9 @@ import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.tests.utils.runSuspendTest import fr.acinq.lightning.utils.* import fr.acinq.lightning.wire.* +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlin.test.* import kotlin.time.Duration.Companion.milliseconds @@ -135,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true) @@ -143,252 +148,346 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { assertEquals(result.incomingPayment.received, result.received) assertEquals(defaultAmount, result.received.amount) - assertEquals(listOf(IncomingPayment.ReceivedWith.LightningPayment(amount = defaultAmount, channelId = channelId, htlcId = 12)), result.received.receivedWith) - + assertEquals(listOf(IncomingPayment.ReceivedWith.LightningPayment(defaultAmount, channelId, 12, null)), result.received.receivedWith) checkDbPayment(result.incomingPayment, paymentHandler.db) } @Test - fun `receive pay-to-open payment with single HTLC`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) - checkDbPayment(incomingPayment, paymentHandler.db) - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) + fun `receive multipart payment with multiple HTLCs`() = runSuspendTest { + val channelId = randomBytes32() + val (amount1, amount2) = Pair(100_000_000.msat, 50_000_000.msat) + val totalAmount = amount1 + amount2 + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) - assertIs(result) - val expected = PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest.chainHash, payToOpenRequest.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage))) - assertEquals(setOf(expected), result.actions.toSet()) + // Step 1 of 2: + // - Alice sends first multipart htlc to Bob + // - 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) + assertIs(result) + assertTrue(result.actions.isEmpty()) + } - // the pay-to-open part is not yet inserted in db - assertTrue(result.received.receivedWith.isEmpty()) - assertEquals(0.msat, result.received.amount) - assertEquals(0.msat, result.received.fees) + // Step 2 of 2: + // - Alice sends second multipart htlc to Bob + // - 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) + assertIs(result) + val (expectedActions, expectedReceivedWith) = setOf( + // @formatter:off + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0, fundingFee = null), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(5, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 5, fundingFee = null), + // @formatter:on + ).unzip() + assertEquals(expectedActions.toSet(), result.actions.toSet()) + assertEquals(totalAmount, result.received.amount) + assertEquals(expectedReceivedWith, result.received.receivedWith) + checkDbPayment(result.incomingPayment, paymentHandler.db) + } + } - // later on, a channel is created + @Test + fun `receive multipart payment after disconnection`() = runSuspendTest { val channelId = randomBytes32() - val amountOrigin = ChannelAction.Storage.StoreIncomingPayment.ViaNewChannel( - amount = payToOpenRequest.amountMsat, - serviceFee = payToOpenRequest.payToOpenFeeSatoshis.toMilliSatoshi(), - miningFee = 0.sat, - localInputs = emptySet(), - txId = TxId(randomBytes32()), - origin = Origin.OffChainPayment(incomingPayment.preimage, payToOpenRequest.amountMsat, ChannelManagementFees(miningFee = payToOpenRequest.payToOpenFeeSatoshis, serviceFee = 0.sat)) - ) - paymentHandler.process(channelId, amountOrigin) - paymentHandler.db.getIncomingPayment(payToOpenRequest.paymentHash).also { dbPayment -> - assertNotNull(dbPayment) - assertIs(dbPayment.origin) - assertNotNull(dbPayment.received) - assertEquals(1, dbPayment.received!!.receivedWith.size) - dbPayment.received!!.receivedWith.first().also { part -> - assertIs(part) - assertEquals(amountOrigin.amount, part.amount) - assertEquals(amountOrigin.serviceFee, part.serviceFee) - assertEquals(amountOrigin.miningFee, part.miningFee) - assertEquals(channelId, part.channelId) - assertNull(part.confirmedAt) - } - assertEquals(amountOrigin.amount, dbPayment.received?.amount) - assertEquals(amountOrigin.serviceFee, dbPayment.received?.fees) + val (amount1, amount2) = Pair(75_000.msat, 75_000.msat) + val totalAmount = amount1 + amount2 + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + + // 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) + assertIs(result) + assertNull(result.incomingPayment.received) + assertTrue(result.actions.isEmpty()) + add + } + + // Step 2: Bob disconnects, and cleans up pending HTLCs. + paymentHandler.purgePendingPayments() + + // Step 3: on reconnection, the HTLC from step 1 is processed again. + run { + val result = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + assertIs(result) + assertNull(result.incomingPayment.received) + assertTrue(result.actions.isEmpty()) + } + + // 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) + assertIs(result) + val (expectedActions, expectedReceivedWith) = setOf( + // @formatter:off + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0, fundingFee = null), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 1, fundingFee = null), + // @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 pay-to-open payment with two evenly-split HTLCs`() = runSuspendTest { + fun `receive will_add_htlc`() = runSuspendTest { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) checkDbPayment(incomingPayment, paymentHandler.db) - val payToOpenRequest1 = makePayToOpenRequest(incomingPayment, makeMppPayload(50_000.msat, defaultAmount, paymentSecret)) - val payToOpenRequest2 = makePayToOpenRequest(incomingPayment, makeMppPayload(50_000.msat, defaultAmount, paymentSecret)) + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() + assertIs(addLiquidity) + assertEquals(incomingPayment.preimage, addLiquidity.preimage) + assertEquals(defaultAmount, addLiquidity.paymentAmount) + assertTrue(defaultAmount < addLiquidity.requestedAmount) + assertEquals(TestConstants.fundingRates.fundingRates.first(), addLiquidity.fundingRate) + assertEquals(listOf(willAddHtlc), addLiquidity.willAddHtlcs) + // We don't update the payments DB: we're waiting to receive HTLCs after the open/splice. + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } - val result1 = paymentHandler.process(payToOpenRequest1, TestConstants.defaultBlockHeight) - assertIs(result1) - val result2 = paymentHandler.process(payToOpenRequest2, TestConstants.defaultBlockHeight) - assertIs(result2) + @Test + fun `receive two evenly-split will_add_htlc`() = runSuspendTest { + val amount = 50_000_000.msat + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(amount * 2) + checkDbPayment(incomingPayment, paymentHandler.db) - val expected = PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest1.chainHash, payToOpenRequest1.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage))) - assertEquals(setOf(expected), (result1.actions + result2.actions).toSet()) + // Step 1 of 2: + // - Alice sends first will_add_htlc to Bob + // - 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) + assertIs(result) + assertTrue(result.actions.isEmpty()) + } - // pay-to-open parts are not yet inserted in db - assertTrue(result2.received.receivedWith.isEmpty()) + // Step 2 of 2: + // - Alice sends second will_add_htlc to Bob + // - 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) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() as AddLiquidityForIncomingPayment + assertEquals(incomingPayment.preimage, addLiquidity.preimage) + assertEquals(amount * 2, addLiquidity.paymentAmount) + assertEquals(2, addLiquidity.willAddHtlcs.size) + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } } @Test - fun `receive pay-to-open payment with two unevenly-split HTLCs`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + fun `receive two unevenly-split will_add_htlc`() = runSuspendTest { + val (amount1, amount2) = Pair(50_000_000.msat, 75_000_000.msat) + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(amount1 + amount2) checkDbPayment(incomingPayment, paymentHandler.db) - val payToOpenRequest1 = makePayToOpenRequest(incomingPayment, makeMppPayload(40_000.msat, defaultAmount, paymentSecret)) - val payToOpenRequest2 = makePayToOpenRequest(incomingPayment, makeMppPayload(60_000.msat, defaultAmount, paymentSecret)) + // The sender overpays the total_amount, which is ok. + val totalAmount = amount1 + amount2 + 10_000_000.msat - val result1 = paymentHandler.process(payToOpenRequest1, TestConstants.defaultBlockHeight) - assertIs(result1) - assertEquals(emptyList(), result1.actions) - val result2 = paymentHandler.process(payToOpenRequest2, TestConstants.defaultBlockHeight) - assertIs(result2) - val payToOpenResponse = PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest1.chainHash, payToOpenRequest1.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage))) - assertEquals(listOf(payToOpenResponse), result2.actions) - - assertEquals(0.msat, result2.received.amount) - assertEquals(0.msat, result2.received.fees) + // Step 1 of 2: + // - Alice sends first will_add_htlc to Bob + // - 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) + assertIs(result) + assertTrue(result.actions.isEmpty()) + } - checkDbPayment(result2.incomingPayment, paymentHandler.db) + // Step 2 of 2: + // - Alice sends second will_add_htlc to Bob + // - 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) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() as AddLiquidityForIncomingPayment + assertEquals(incomingPayment.preimage, addLiquidity.preimage) + assertEquals(totalAmount, addLiquidity.paymentAmount) + assertEquals(2, addLiquidity.willAddHtlcs.size) + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } } @Test - fun `receive pay-to-open payment with an unknown payment hash`() = runSuspendTest { - val (paymentHandler, _, _) = createFixture(defaultAmount) - val payToOpenRequest = PayToOpenRequest( - chainHash = BlockHash(ByteVector32.Zeroes), - amountMsat = defaultAmount, - payToOpenFeeSatoshis = 100.sat, - paymentHash = ByteVector32.One, // <-- not associated to a pending invoice - expireAt = Long.MAX_VALUE, - finalPacket = OutgoingPaymentPacket.buildPacket( - paymentHash = ByteVector32.One, // <-- has to be the same as the one above otherwise encryption fails - hops = channelHops(paymentHandler.nodeParams.nodeId), - finalPayload = makeMppPayload(defaultAmount, defaultAmount, randomBytes32()), - payloadLength = OnionRoutingPacket.PaymentPacketLength - ).third.packet - ) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) + fun `receive trampoline will_add_htlc`() = runSuspendTest { + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) + val willAddHtlc = run { + // We simulate a trampoline-relay with a dummy channel hop between the liquidity provider and the wallet. + val (amount, expiry, trampolineOnion) = OutgoingPaymentPacket.buildPacket( + incomingPayment.paymentHash, + listOf(NodeHop(TestConstants.Alice.nodeParams.nodeId, TestConstants.Bob.nodeParams.nodeId, CltvExpiryDelta(144), 0.msat)), + makeMppPayload(defaultAmount, defaultAmount, paymentSecret), + null + ) + 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) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() + assertIs(addLiquidity) + assertEquals(incomingPayment.preimage, addLiquidity.preimage) + assertEquals(defaultAmount, addLiquidity.paymentAmount) + assertTrue(defaultAmount < addLiquidity.requestedAmount) + assertEquals(TestConstants.fundingRates.fundingRates.first(), addLiquidity.fundingRate) + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } + @Test + 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) assertIs(result) assertNull(result.incomingPayment) - val expected = PayToOpenResponseCommand( - PayToOpenResponse( - payToOpenRequest.chainHash, - payToOpenRequest.paymentHash, - PayToOpenResponse.Result.Failure( - OutgoingPaymentPacket.buildHtlcFailure( - paymentHandler.nodeParams.nodePrivateKey, - payToOpenRequest.paymentHash, - payToOpenRequest.finalPacket, - ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(payToOpenRequest.amountMsat, TestConstants.defaultBlockHeight.toLong())) - ).right!! - ) - ) - ) - assertEquals(setOf(expected), result.actions.toSet()) + val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(paymentHandler.nodeParams.nodePrivateKey, willAddHtlc, IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())) + assertIs(failure) + assertEquals(listOf(SendOnTheFlyFundingMessage(failure)), result.actions) } @Test - fun `receive pay-to-open payment with an incorrect payment secret`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(defaultAmount, defaultAmount, paymentSecret.reversed())) // <--- wrong secret - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - + 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) assertIs(result) assertEquals(incomingPayment, result.incomingPayment) - val expected = PayToOpenResponseCommand( - PayToOpenResponse( - payToOpenRequest.chainHash, - payToOpenRequest.paymentHash, - PayToOpenResponse.Result.Failure( - OutgoingPaymentPacket.buildHtlcFailure( - paymentHandler.nodeParams.nodePrivateKey, - payToOpenRequest.paymentHash, - payToOpenRequest.finalPacket, - ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(payToOpenRequest.amountMsat, TestConstants.defaultBlockHeight.toLong())) - ).right!! - ) - ) - ) - assertEquals(setOf(expected), result.actions.toSet()) + val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(paymentHandler.nodeParams.nodePrivateKey, willAddHtlc, IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())) + assertIs(failure) + assertEquals(listOf(SendOnTheFlyFundingMessage(failure)), result.actions) } @Test - fun `receive pay-to-open payment with a fee too high`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)).copy(payToOpenFeeSatoshis = 2_000.sat) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - + fun `receive trampoline will_add_htlc with an incorrect payment secret`() = runSuspendTest { + val (paymentHandler, incomingPayment, _) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) + val willAddHtlc = run { + // We simulate a trampoline-relay with a dummy channel hop between the liquidity provider and the wallet. + val (amount, expiry, trampolineOnion) = OutgoingPaymentPacket.buildPacket( + incomingPayment.paymentHash, + listOf(NodeHop(TestConstants.Alice.nodeParams.nodeId, TestConstants.Bob.nodeParams.nodeId, CltvExpiryDelta(144), 0.msat)), + makeMppPayload(defaultAmount, defaultAmount, randomBytes32()), + null + ) + 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) assertIs(result) assertEquals(incomingPayment, result.incomingPayment) - val expected = PayToOpenResponseCommand( - PayToOpenResponse( - payToOpenRequest.chainHash, - payToOpenRequest.paymentHash, - PayToOpenResponse.Result.Failure( - OutgoingPaymentPacket.buildHtlcFailure( - paymentHandler.nodeParams.nodePrivateKey, - payToOpenRequest.paymentHash, - payToOpenRequest.finalPacket, - ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure) - ).right!! - ) - ) - ) - assertEquals(setOf(expected), result.actions.toSet()) + val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(paymentHandler.nodeParams.nodePrivateKey, willAddHtlc, IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())) + assertIs(failure) + assertEquals(listOf(SendOnTheFlyFundingMessage(failure)), result.actions) } @Test - fun `receive pay-to-open trampoline payment with an incorrect payment secret`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) - val trampolineHops = listOf( - NodeHop(TestConstants.Alice.nodeParams.nodeId, TestConstants.Bob.nodeParams.nodeId, CltvExpiryDelta(144), 0.msat) - ) - val payToOpenRequest = PayToOpenRequest( - chainHash = BlockHash(ByteVector32.Zeroes), - amountMsat = defaultAmount, - payToOpenFeeSatoshis = 100.sat, - paymentHash = incomingPayment.paymentHash, - expireAt = Long.MAX_VALUE, - finalPacket = OutgoingPaymentPacket.buildPacket( - paymentHash = incomingPayment.paymentHash, - hops = trampolineHops, - finalPayload = makeMppPayload(defaultAmount, defaultAmount, paymentSecret.reversed()), // <-- wrong secret - payloadLength = 400 - ).third.packet + @OptIn(ExperimentalCoroutinesApi::class) + fun `receive will_add_htlc with a fee too high`() = runSuspendTest { + val fundingRates = LiquidityAds.WillFundRates( + // Note that we use a fixed liquidity fees to make testing easier. + fundingRates = listOf(LiquidityAds.FundingRate(0.sat, 250_000.sat, 0, 0, 5_000.sat)), + paymentTypes = setOf(LiquidityAds.PaymentType.FromChannelBalance, LiquidityAds.PaymentType.FromChannelBalanceForFutureHtlc, LiquidityAds.PaymentType.FromFutureHtlc), ) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - - assertIs(result) - assertEquals(incomingPayment, result.incomingPayment) - val expected = PayToOpenResponseCommand( - PayToOpenResponse( - payToOpenRequest.chainHash, - payToOpenRequest.paymentHash, - PayToOpenResponse.Result.Failure( - OutgoingPaymentPacket.buildHtlcFailure( - paymentHandler.nodeParams.nodePrivateKey, - payToOpenRequest.paymentHash, - payToOpenRequest.finalPacket, - ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(payToOpenRequest.amountMsat, TestConstants.defaultBlockHeight.toLong())) - ).right!! - ) - ) + val inboundLiquidityTarget = 100_000.sat + assertEquals(5_000.sat, fundingRates.fundingRates.first().fees(TestConstants.feeratePerKw, inboundLiquidityTarget, inboundLiquidityTarget).total) + val defaultPolicy = LiquidityPolicy.Auto(inboundLiquidityTarget, maxAbsoluteFee = 5_000.sat, maxRelativeFeeBasisPoints = 500, skipAbsoluteFeeCheck = false) + 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)), + // 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. + Triple(defaultPolicy.copy(maxAbsoluteFee = 4999.sat), 10_000_000.msat, LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(4999.sat)), + // If fee is above our liquidity policy relative fee, we reject the payment. + Triple(defaultPolicy.copy(maxRelativeFeeBasisPoints = 249), 100_000_000.msat, LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(249)), + // If we disabled automatic liquidity management, we reject the payment. + Triple(LiquidityPolicy.Disable, 10_000_000.msat, LiquidityEvents.Rejected.Reason.PolicySetToDisabled), ) - assertEquals(setOf(expected), result.actions.toSet()) + testCases.forEach { (policy, paymentAmount, failure) -> + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(paymentAmount, fundingRates) + 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) + when (failure) { + null -> { + assertIs(result) + assertEquals(incomingPayment, result.incomingPayment) + assertTrue(result.actions.filterIsInstance().isNotEmpty()) + } + else -> { + assertIs(result) + val expected = OutgoingPaymentPacket.buildWillAddHtlcFailure(paymentHandler.nodeParams.nodePrivateKey, add, TemporaryNodeFailure) + assertIs(expected) + assertEquals(listOf(SendOnTheFlyFundingMessage(expected)), result.actions) + val event = paymentHandler.nodeParams.nodeEvents.first() + assertIs(event) + assertEquals(event.reason, failure) + } + } + } } @Test - fun `receive multipart payment with multiple HTLCs via same channel`() = runSuspendTest { + fun `receive multipart payment with a mix of HTLC and will_add_htlc`() = runSuspendTest { val channelId = randomBytes32() - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) + val (amount1, amount2) = listOf(50_000_000.msat, 60_000_000.msat) val totalAmount = amount1 + amount2 val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + checkDbPayment(incomingPayment, paymentHandler.db) // Step 1 of 2: - // - Alice sends first multipart htlc to Bob - // - Bob doesn't accept the MPP set yet + // - Alice sends a normal HTLC to Bob first + // - 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) + val htlc = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) + val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) - assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) } - // Step 2 of 2: - // - Alice sends second multipart htlc to Bob - // - Bob now accepts the MPP set + // Step 2 of 3: + // - Alice sends will_add_htlc to Bob + // - Bob triggers an open/splice run { - val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() as AddLiquidityForIncomingPayment + assertEquals(incomingPayment.preimage, addLiquidity.preimage) + assertEquals(amount2.truncateToSatoshi() + LiquidityPolicy.minInboundLiquidityTarget, addLiquidity.requestedAmount) + assertEquals(totalAmount, addLiquidity.paymentAmount) + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } + + // Step 3 of 3: + // - After the splice completes, Alice sends a second HTLC to Bob + // - 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) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off - WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0), - WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 1), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, incomingPayment.preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0, fundingFee = null), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, incomingPayment.preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 1, fundingFee = null), // @formatter:on ).unzip() assertEquals(expectedActions.toSet(), result.actions.toSet()) @@ -399,33 +498,60 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } @Test - fun `receive multipart payment with multiple HTLCs via different channels`() = runSuspendTest { - val (channelId1, channelId2) = Pair(randomBytes32(), randomBytes32()) - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) + fun `receive multipart payment with a mix of HTLC and will_add_htlc -- fee too high`() = runSuspendTest { + val channelId = randomBytes32() + val (amount1, amount2) = listOf(50_000_000.msat, 50_000_000.msat) val totalAmount = amount1 + amount2 val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + checkDbPayment(incomingPayment, paymentHandler.db) // Step 1 of 2: - // - Alice sends first multipart htlc to Bob - // - Bob doesn't accept the MPP set yet + // - Alice sends a normal HTLC to Bob first + // - Bob doesn't accept the MPP set yet run { - val add = makeUpdateAddHtlc(7, channelId1, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val htlc = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) + val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } - // Step 2 of 2: - // - Alice sends second multipart htlc to Bob - // - Bob now accepts the MPP set + // Step 2 of 4: + // - Alice sends will_add_htlc to Bob + // - 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) + assertIs(result) + assertEquals(2, result.actions.size) + val willFailHtlc = result.actions.filterIsInstance().firstOrNull()?.message + assertIs(willFailHtlc).also { assertEquals(willAddHtlc.id, it.id) } + val failHtlc = ChannelCommand.Htlc.Settlement.Fail(0, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure), commit = true) + assertTrue(result.actions.contains(WrappedChannelCommand(channelId, failHtlc))) + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } + + // Step 3 of 4: + // - Alice sends the first HTLC to Bob again + // - 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) + assertIs(result) + assertTrue(result.actions.isEmpty()) + } + + // Step 4 of 4: + // - Alice sends the second HTLC to Bob + // - Bob accepts the MPP payment run { - val add = makeUpdateAddHtlc(5, channelId2, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val htlc = makeUpdateAddHtlc(2, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off - WrappedChannelCommand(channelId1, ChannelCommand.Htlc.Settlement.Fulfill(7, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId1, 7), - WrappedChannelCommand(channelId2, ChannelCommand.Htlc.Settlement.Fulfill(5, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId2, 5), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, incomingPayment.preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 1, fundingFee = null), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(2, incomingPayment.preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 2, fundingFee = null), // @formatter:on ).unzip() assertEquals(expectedActions.toSet(), result.actions.toSet()) @@ -436,183 +562,192 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } @Test - fun `receive multipart payment after disconnection`() = runSuspendTest { - // Write exactly the scenario that happened in the witnessed issue. - // Modify purgePayToOpenRequests to purge all pending HTLCs *for the given disconnected node* (to support future multi-node) + fun `receive multipart payment with a mix of HTLC and will_add_htlc -- too many parts`() = runSuspendTest { val channelId = randomBytes32() - val (amount1, amount2) = Pair(75_000.msat, 75_000.msat) - val totalAmount = amount1 + amount2 - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams.copy(maxAcceptedHtlcs = 5), InMemoryPaymentsDb(), TestConstants.fundingRates) + paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = null, maxAbsoluteFee = 10_000.sat, maxRelativeFeeBasisPoints = 1000, skipAbsoluteFeeCheck = false)) + 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).also { result -> + assertIs(result) + assertTrue(result.actions.isEmpty()) + } - // 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) + // 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) assertIs(result) - assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) - add } - // Step 2: Bob disconnects, and cleans up pending HTLCs. - paymentHandler.purgePendingPayments() + // 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) + assertIs(result) + assertEquals(6, result.actions.size) + val willFailHtlcs = result.actions.filterIsInstance().map { it.message }.filterIsInstance() + assertEquals(5, willFailHtlcs.size) + assertEquals(willAddHtlcs.map { it.id }.toSet(), willFailHtlcs.map { it.id }.toSet()) + val failHtlc = ChannelCommand.Htlc.Settlement.Fail(htlc.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure), commit = true) + assertTrue(result.actions.contains(WrappedChannelCommand(channelId, failHtlc))) + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } - // Step 3: on reconnection, the HTLC from step 1 is processed again. + @Test + fun `receive multipart payment with funding fee`() = runSuspendTest { + val channelId = randomBytes32() + val (amount1, amount2) = listOf(50_000_000.msat, 60_000_000.msat) + val totalAmount = amount1 + amount2 + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + checkDbPayment(incomingPayment, paymentHandler.db) + + // Step 1 of 2: + // - Alice sends a normal HTLC to Bob first + // - Bob doesn't accept the MPP set yet run { - val result = paymentHandler.process(add1, TestConstants.defaultBlockHeight) + val htlc = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) + val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) - assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) } - // Step 4: Alice sends second multipart htlc to Bob. + // Step 2 of 3: + // - Alice sends will_add_htlc to Bob + // - 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) + assertIs(result) + assertEquals(1, result.actions.size) + val splice = result.actions.first() as AddLiquidityForIncomingPayment + // The splice transaction is successfully signed and stored in the DB. + val purchase = LiquidityAds.Purchase.Standard( + splice.requestedAmount, + splice.fees(TestConstants.feeratePerKw), + LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(incomingPayment.paymentHash)), + ) + val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null) + paymentHandler.db.addOutgoingPayment(payment) + payment + } + + // Step 3 of 3: + // - After the splice completes, Alice sends a second HTLC to Bob with the funding fee deduced + // - Bob accepts the MPP set run { - val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + 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) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off - WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0), - WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 1), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, incomingPayment.preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0, fundingFee = null), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, incomingPayment.preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2 - purchase.fundingFee.amount, channelId, 1, purchase.fundingFee), // @formatter:on ).unzip() assertEquals(expectedActions.toSet(), result.actions.toSet()) - assertEquals(totalAmount, result.received.amount) + assertEquals(totalAmount - purchase.fundingFee.amount, result.received.amount) assertEquals(expectedReceivedWith, result.received.receivedWith) checkDbPayment(result.incomingPayment, paymentHandler.db) } } @Test - fun `receive multipart payment via pay-to-open`() = runSuspendTest { - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) - val totalAmount = amount1 + amount2 - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) - - // Step 1 of 2: - // - Alice sends first multipart htlc to Bob - // - Bob doesn't accept the MPP set yet - run { - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - assertIs(result) - assertTrue(result.actions.isEmpty()) - } - - // Step 2 of 2: - // - Alice sends second multipart htlc to Bob - // - Bob now accepts the MPP set - run { - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - assertIs(result) - - val payToOpenResponse = PayToOpenResponse(payToOpenRequest.chainHash, payToOpenRequest.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage)) - assertEquals(result.actions, listOf(PayToOpenResponseCommand(payToOpenResponse))) - - // pay-to-open parts are not yet provided - assertTrue(result.received.receivedWith.isEmpty()) - assertEquals(0.msat, result.received.fees) + fun `receive payment with funding fee -- unknown transaction`() = runSuspendTest { + val channelId = randomBytes32() + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) - checkDbPayment(result.incomingPayment, paymentHandler.db) - } + 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) + 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()) } @Test - fun `receive multipart payment with a mix of HTLC and pay-to-open`() = runSuspendTest { + fun `receive payment with funding fee -- fee too high`() = runSuspendTest { val channelId = randomBytes32() - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) - val totalAmount = amount1 + amount2 - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) + + // We have a matching transaction in our DB. + val purchase = LiquidityAds.Purchase.Standard( + defaultAmount.truncateToSatoshi() + LiquidityPolicy.minInboundLiquidityTarget, + LiquidityAds.Fees(2000.sat, 3000.sat), + LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(incomingPayment.paymentHash)), + ) + val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null) + paymentHandler.db.addOutgoingPayment(payment) - // Step 1 of 2: - // - Alice sends first multipart htlc to Bob - // - 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) - assertIs(result) - assertTrue { result.actions.isEmpty() } + // 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) + 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()) } - - // Step 2 of 2: - // - Alice sends second multipart htlc to Bob - // - Bob now accepts the MPP set run { - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) + // 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) assertIs(result) - - assertEquals(2, result.actions.size) - assertContains(result.actions, WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, incomingPayment.preimage, commit = true))) - assertContains(result.actions, PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest.chainHash, payToOpenRequest.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage)))) - - // the pay-to-open part is not yet provided - assertEquals(1, result.received.receivedWith.size) - assertContains(result.received.receivedWith, IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0)) - assertEquals(0.msat, result.received.fees) - + assertEquals(listOf(WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, incomingPayment.preimage, commit = true))), result.actions) + assertEquals(defaultAmount - payment.fundingFee.amount, result.received.amount) + assertEquals(listOf(IncomingPayment.ReceivedWith.LightningPayment(defaultAmount - payment.fundingFee.amount, channelId, 1, payment.fundingFee)), result.received.receivedWith) checkDbPayment(result.incomingPayment, paymentHandler.db) } } @Test - fun `receive multipart payment with a mix of HTLC and pay-to-open -- fee too high`() = runSuspendTest { + fun `receive payment with funding fee -- invalid payment type`() = runSuspendTest { val channelId = randomBytes32() - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) - val totalAmount = amount1 + amount2 - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) - // Step 1 of 2: - // - Alice sends first multipart htlc to Bob - // - 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) - assertIs(result) - assertTrue(result.actions.isEmpty()) - } + // We have a matching transaction in our DB, but we paid the fees from our channel balance already. + val purchase = LiquidityAds.Purchase.Standard( + defaultAmount.truncateToSatoshi() + LiquidityPolicy.minInboundLiquidityTarget, + LiquidityAds.Fees(2000.sat, 3000.sat), + LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(listOf(incomingPayment.paymentHash)), + ) + val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null) + paymentHandler.db.addOutgoingPayment(payment) - // Step 2 of 2: - // - Alice sends second multipart htlc to Bob - // - Bob has received the complete MPP set - run { - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(amount2, totalAmount, paymentSecret)).copy(payToOpenFeeSatoshis = 2_000.sat) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - assertIs(result) - val expected = setOf( - WrappedChannelCommand( - channelId, - ChannelCommand.Htlc.Settlement.Fail(0, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure), commit = true) - ), - PayToOpenResponseCommand( - PayToOpenResponse( - payToOpenRequest.chainHash, - payToOpenRequest.paymentHash, - PayToOpenResponse.Result.Failure( - OutgoingPaymentPacket.buildHtlcFailure( - paymentHandler.nodeParams.nodePrivateKey, - payToOpenRequest.paymentHash, - payToOpenRequest.finalPacket, - ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure) - ).right!! - ) - ) - ) - ) - assertEquals(expected, result.actions.toSet()) - } + val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret), fundingFee = payment.fundingFee) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + assertIs(result) + val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) + assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) } @Test - fun `receive normal single HTLC with amount-less invoice`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(invoiceAmount = null) - val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + fun `receive payment with funding fee -- invalid payment_hash`() = runSuspendTest { + val channelId = randomBytes32() + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) - assertIs(result) - val expected = WrappedChannelCommand(add.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true)) - assertEquals(setOf(expected), result.actions.toSet()) + // We have a matching transaction in our DB, but the fees must be paid with a different payment_hash. + val purchase = LiquidityAds.Purchase.Standard( + defaultAmount.truncateToSatoshi() + LiquidityPolicy.minInboundLiquidityTarget, + LiquidityAds.Fees(2000.sat, 3000.sat), + 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) + 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()) } @Test @@ -627,7 +762,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -637,7 +772,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = setOf( WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(7, incomingPayment.preimage, commit = true)), @@ -666,7 +801,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -675,7 +810,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) + val result = paymentHandler.process(add3, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = setOf( WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(3, incomingPayment.preimage, commit = true)), @@ -687,21 +822,11 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } @Test - fun `receive normal single HTLC over-payment`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(150_000.msat) - val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeSinglePartPayload(170_000.msat, paymentSecret)).copy(amountMsat = 175_000.msat) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) - assertIs(result) - val expected = WrappedChannelCommand(add.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true)) - assertEquals(setOf(expected), result.actions.toSet()) - } - - @Test - fun `receive normal single HTLC with greater expiry`() = runSuspendTest { + fun `receive multipart payment with greater expiry`() = runSuspendTest { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) - val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeSinglePartPayload(defaultAmount, paymentSecret)) + 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) + val result = paymentHandler.process(addGreaterExpiry, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = WrappedChannelCommand(add.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true)) assertEquals(setOf(expected), result.actions.toSet()) @@ -713,18 +838,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) + val result1 = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) assertTrue(result1.actions.isEmpty()) // This htlc is reprocessed (e.g. because the wallet restarted). - val result1b = paymentHandler.process(add1, TestConstants.defaultBlockHeight) + val result1b = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1b) assertTrue(result1b.actions.isEmpty()) // We receive the second multipart htlc. val add2 = makeUpdateAddHtlc(5, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount / 2, defaultAmount, paymentSecret)) - val result2 = paymentHandler.process(add2, TestConstants.defaultBlockHeight) + val result2 = paymentHandler.process(add2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) assertEquals(defaultAmount, result2.received.amount) val expected = setOf( @@ -734,7 +859,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) + val result2b = paymentHandler.process(add2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2b) assertEquals(defaultAmount, result2b.received.amount) assertEquals(listOf(WrappedChannelCommand(add2.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add2.id, incomingPayment.preimage, commit = true))), result2b.actions) @@ -746,7 +871,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) + val result1 = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) assertTrue(result1.actions.isEmpty()) @@ -756,7 +881,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) + val result2 = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) assertTrue(result2.actions.isEmpty()) @@ -766,7 +891,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) + val result3 = paymentHandler.process(add, currentBlockHeight, TestConstants.feeratePerKw) assertIs(result3) val addExpired = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, currentBlockHeight.toLong())), commit = true) assertEquals(listOf(WrappedChannelCommand(add.channelId, addExpired)), result3.actions) @@ -774,7 +899,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { @Test fun `invoice expired`() = runSuspendTest { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.fundingRates) val (incomingPayment, paymentSecret) = makeIncomingPayment( payee = paymentHandler, amount = defaultAmount, @@ -782,7 +907,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) @@ -793,7 +918,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) @@ -804,9 +929,9 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { fun `invalid onion`() = runSuspendTest { val (paymentHandler, incomingPayment, _) = createFixture(defaultAmount) val cltvExpiry = CltvExpiryDelta(144).toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) - val badOnion = OnionRoutingPacket(0, ByteVector("0x02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619"), Lightning.randomBytes(OnionRoutingPacket.PaymentPacketLength).toByteVector(), randomBytes32()) + 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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) // The current flow of error checking within the codebase would be: @@ -823,7 +948,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) @@ -841,7 +966,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { ) payloads.forEach { payload -> val add = makeUpdateAddHtlc(3, randomBytes32(), paymentHandler, incomingPayment.paymentHash, payload) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(payload.totalAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) @@ -860,7 +985,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -872,16 +997,17 @@ 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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) + val failure = IncorrectOrUnknownPaymentDetails(totalAmount, TestConstants.defaultBlockHeight.toLong()) val expected = setOf( WrappedChannelCommand( channelId, - ChannelCommand.Htlc.Settlement.Fail(1, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(totalAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) + ChannelCommand.Htlc.Settlement.Fail(1, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(failure), commit = true) ), WrappedChannelCommand( channelId, - ChannelCommand.Htlc.Settlement.Fail(2, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(totalAmount + 1.msat, TestConstants.defaultBlockHeight.toLong())), commit = true) + ChannelCommand.Htlc.Settlement.Fail(2, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(failure), commit = true) ), ) assertEquals(expected, result.actions.toSet()) @@ -899,7 +1025,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -910,7 +1036,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(totalAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) @@ -928,7 +1054,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -967,7 +1093,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -985,7 +1111,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -995,7 +1121,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = setOf( WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(3, incomingPayment.preimage, commit = true)), @@ -1019,11 +1145,11 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 1 of 2: // - Alice receives complete mpp set run { - val result1 = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight) + val result1 = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) assertTrue(result1.actions.isEmpty()) - val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight) + val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) val expected = setOf( @@ -1036,7 +1162,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) + val result = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = WrappedChannelCommand(channelId1, ChannelCommand.Htlc.Settlement.Fulfill(htlc1.id, incomingPayment.preimage, commit = true)) assertEquals(setOf(expected), result.actions.toSet()) @@ -1057,11 +1183,11 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 1 of 2: // - Alice receives complete mpp set run { - val result1 = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight) + val result1 = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) assertTrue(result1.actions.isEmpty()) - val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight) + val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) val expected = setOf( @@ -1075,7 +1201,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = WrappedChannelCommand( channelId1, ChannelCommand.Htlc.Settlement.Fail( @@ -1091,7 +1217,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val channelId3 = randomBytes32() val add = htlc2.copy(channelId = channelId3) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = WrappedChannelCommand( channelId3, ChannelCommand.Htlc.Settlement.Fail( @@ -1106,7 +1232,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { @Test fun `purge expired incoming payments`() = runSuspendTest { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.fundingRates) // create incoming payment that has expired and not been paid val expiredInvoice = paymentHandler.createInvoice( @@ -1122,7 +1248,17 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { ) paymentHandler.db.receivePayment( paidInvoice.paymentHash, - receivedWith = listOf(IncomingPayment.ReceivedWith.NewChannel(amount = 15_000_000.msat, serviceFee = 1_000_000.msat, miningFee = 0.sat, channelId = randomBytes32(), txId = TxId(randomBytes32()), confirmedAt = null, lockedAt = null)), + receivedWith = listOf( + IncomingPayment.ReceivedWith.NewChannel( + amount = 15_000_000.msat, + serviceFee = 1_000_000.msat, + miningFee = 0.sat, + channelId = randomBytes32(), + txId = TxId(randomBytes32()), + confirmedAt = null, + lockedAt = null + ) + ), receivedAt = 101 // simulate incoming payment being paid before it expired ) @@ -1145,13 +1281,13 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { @Test fun `receive blinded payment with single HTLC`() = runSuspendTest { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.fundingRates) val preimage = randomBytes32() val paymentHash = Crypto.sha256(preimage).toByteVector32() 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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fulfill(add.id, preimage, commit = true) @@ -1159,14 +1295,14 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { assertEquals(result.incomingPayment.received, result.received) assertEquals(defaultAmount, result.received.amount) - assertEquals(listOf(IncomingPayment.ReceivedWith.LightningPayment(amount = defaultAmount, channelId = add.channelId, htlcId = 8)), result.received.receivedWith) + assertEquals(listOf(IncomingPayment.ReceivedWith.LightningPayment(defaultAmount, add.channelId, 8, null)), result.received.receivedWith) checkDbPayment(result.incomingPayment, paymentHandler.db) } @Test fun `receive blinded multipart payment with multiple HTLCs`() = runSuspendTest { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.fundingRates) val channelId = randomBytes32() val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) val totalAmount = amount1 + amount2 @@ -1180,7 +1316,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) @@ -1192,12 +1328,12 @@ 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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off - WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0), - WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 1), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0, null), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 1, null), // @formatter:on ).unzip() assertEquals(expectedActions.toSet(), result.actions.toSet()) @@ -1207,13 +1343,62 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } } + @Test + fun `receive blinded will_add_htlc`() = runSuspendTest { + val (paymentHandler, _, _) = createFixture(defaultAmount) + val preimage = randomBytes32() + val paymentHash = Crypto.sha256(preimage).toByteVector32() + 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) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() + assertIs(addLiquidity) + assertEquals(preimage, addLiquidity.preimage) + assertEquals(defaultAmount, addLiquidity.paymentAmount) + // We don't update the payments DB: we're waiting to receive HTLCs after the open/splice. + assertNull(paymentHandler.db.getIncomingPayment(paymentHash)?.received) + } + + @Test + fun `receive blinded payment with funding fee`() = runSuspendTest { + val (paymentHandler, _, _) = createFixture(defaultAmount) + val channelId = randomBytes32() + val preimage = randomBytes32() + 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( + defaultAmount.truncateToSatoshi() + LiquidityPolicy.minInboundLiquidityTarget, + LiquidityAds.Fees(2000.sat, 3000.sat), + LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(listOf(preimage)), + ) + val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null) + paymentHandler.db.addOutgoingPayment(payment) + + 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) + assertIs(result) + val fulfill = ChannelCommand.Htlc.Settlement.Fulfill(add.id, preimage, commit = true) + assertEquals(setOf(WrappedChannelCommand(add.channelId, fulfill)), result.actions.toSet()) + assertEquals(result.incomingPayment.received, result.received) + assertEquals(defaultAmount - payment.fundingFee.amount, result.received.amount) + val receivedWith = IncomingPayment.ReceivedWith.LightningPayment(defaultAmount - payment.fundingFee.amount, add.channelId, 0, payment.fundingFee) + assertEquals(listOf(receivedWith), result.received.receivedWith) + checkDbPayment(result.incomingPayment, paymentHandler.db) + } + @Test fun `reject blinded payment for Bolt11 invoice`() = runSuspendTest { val (paymentHandler, incomingPayment, _) = createFixture(defaultAmount) 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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket)) @@ -1223,7 +1408,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { @Test fun `reject non-blinded payment for Bol12 invoice`() = runSuspendTest { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.fundingRates) val channelId = randomBytes32() val (amount1, amount2) = Pair(100_000_000.msat, 50_000_000.msat) val totalAmount = amount1 + amount2 @@ -1237,7 +1422,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) @@ -1248,7 +1433,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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(totalAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) @@ -1257,14 +1442,14 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { @Test fun `reject blinded payment with amount too low`() = runSuspendTest { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.fundingRates) val cltvExpiry = TestConstants.Bob.nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) val metadata = OfferPaymentMetadata.V1(randomBytes32(), 100_000_000.msat, randomBytes32(), randomKey().publicKey(), null, 1, currentTimestampMillis()) val pathId = metadata.toPathId(TestConstants.Bob.nodeParams.nodePrivateKey) 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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket)) @@ -1274,13 +1459,13 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { @Test fun `reject blinded payment with payment_hash mismatch`() = runSuspendTest { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.fundingRates) val cltvExpiry = TestConstants.Bob.nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) val metadata = OfferPaymentMetadata.V1(randomBytes32(), 100_000_000.msat, randomBytes32(), randomKey().publicKey(), null, 1, currentTimestampMillis()) 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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket)) @@ -1291,7 +1476,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { companion object { val defaultPreimage = randomBytes32() val defaultPaymentHash = Crypto.sha256(defaultPreimage).toByteVector32() - val defaultAmount = 100_000.msat + val defaultAmount = 150_000_000.msat private fun channelHops(destination: PublicKey): List { val dummyKey = PrivateKey(ByteVector32("0101010101010101010101010101010101010101010101010101010101010101")).publicKey() @@ -1316,23 +1501,31 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { return OutgoingPaymentPacket.buildCommand(UUID.randomUUID(), paymentHash, channelHops(destination), finalPayload).first.copy(commit = true) } - private fun makeUpdateAddHtlc(id: Long, channelId: ByteVector32, destination: IncomingPaymentHandler, paymentHash: ByteVector32, finalPayload: PaymentOnion.FinalPayload, blinding: PublicKey? = null): UpdateAddHtlc { + private fun makeUpdateAddHtlc( + id: Long, + channelId: ByteVector32, + destination: IncomingPaymentHandler, + paymentHash: ByteVector32, + finalPayload: PaymentOnion.FinalPayload, + blinding: PublicKey? = null, + fundingFee: LiquidityAds.FundingFee? = null + ): UpdateAddHtlc { val destinationNodeId = when (blinding) { null -> destination.nodeParams.nodeId else -> RouteBlinding.derivePrivateKey(destination.nodeParams.nodePrivateKey, blinding).publicKey() } val (_, _, packetAndSecrets) = OutgoingPaymentPacket.buildPacket(paymentHash, channelHops(destinationNodeId), finalPayload, OnionRoutingPacket.PaymentPacketLength) - return UpdateAddHtlc(channelId, id, finalPayload.amount, paymentHash, finalPayload.expiry, packetAndSecrets.packet, blinding) + val amount = finalPayload.amount - (fundingFee?.amount ?: 0.msat) + return UpdateAddHtlc(channelId, id, amount, paymentHash, finalPayload.expiry, packetAndSecrets.packet, blinding, fundingFee) } - private fun makeSinglePartPayload( - amount: MilliSatoshi, - paymentSecret: ByteVector32, - cltvExpiryDelta: CltvExpiryDelta = CltvExpiryDelta(144), - currentBlockHeight: Int = TestConstants.defaultBlockHeight - ): PaymentOnion.FinalPayload.Standard { - val expiry = cltvExpiryDelta.toCltvExpiry(currentBlockHeight.toLong()) - return PaymentOnion.FinalPayload.Standard.createSinglePartPayload(amount, expiry, paymentSecret, null) + private fun makeWillAddHtlc(destination: IncomingPaymentHandler, paymentHash: ByteVector32, finalPayload: PaymentOnion.FinalPayload, blinding: PublicKey? = null): WillAddHtlc { + val destinationNodeId = when (blinding) { + null -> destination.nodeParams.nodeId + else -> RouteBlinding.derivePrivateKey(destination.nodeParams.nodePrivateKey, blinding).publicKey() + } + val (_, _, packetAndSecrets) = OutgoingPaymentPacket.buildPacket(paymentHash, channelHops(destinationNodeId), finalPayload, OnionRoutingPacket.PaymentPacketLength) + return WillAddHtlc(destination.nodeParams.chainHash, randomBytes32(), finalPayload.amount, paymentHash, finalPayload.expiry, packetAndSecrets.packet, blinding) } private fun makeMppPayload( @@ -1390,24 +1583,6 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { return Pair(payload, route) } - const val payToOpenFeerate = 0.1 - - private fun makePayToOpenRequest(incomingPayment: IncomingPayment, finalPayload: PaymentOnion.FinalPayload): PayToOpenRequest { - return PayToOpenRequest( - chainHash = Block.RegtestGenesisBlock.hash, - amountMsat = finalPayload.amount, - payToOpenFeeSatoshis = finalPayload.amount.truncateToSatoshi() * payToOpenFeerate, // 10% - paymentHash = incomingPayment.paymentHash, - expireAt = Long.MAX_VALUE, - finalPacket = OutgoingPaymentPacket.buildPacket( - paymentHash = incomingPayment.paymentHash, - hops = channelHops(TestConstants.Bob.nodeParams.nodeId), - finalPayload = finalPayload, - payloadLength = OnionRoutingPacket.PaymentPacketLength - ).third.packet - ) - } - private suspend fun makeIncomingPayment(payee: IncomingPaymentHandler, amount: MilliSatoshi?, expirySeconds: Long? = null, timestamp: Long = currentTimestampSeconds()): Pair { val paymentRequest = payee.createInvoice(defaultPreimage, amount, Either.Left("unit test"), listOf(), expirySeconds, timestamp) assertNotNull(paymentRequest.paymentMetadata) @@ -1423,8 +1598,10 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { assertEquals(incomingPayment.received?.receivedWith, dbPayment.received?.receivedWith) } - private suspend fun createFixture(invoiceAmount: MilliSatoshi?): Triple { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + private suspend fun createFixture(invoiceAmount: MilliSatoshi?, fundingRates: LiquidityAds.WillFundRates = TestConstants.fundingRates): Triple { + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), fundingRates) + // 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)) val (incomingPayment, paymentSecret) = makeIncomingPayment(paymentHandler, invoiceAmount) return Triple(paymentHandler, incomingPayment, paymentSecret) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt index 0c7101fe9..7ac41825e 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt @@ -456,7 +456,7 @@ class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { val outgoingPaymentHandler = OutgoingPaymentHandler(TestConstants.Alice.nodeParams, defaultWalletParams, InMemoryPaymentsDb()) // The invoice comes from Bob, our direct peer (and trampoline node). val preimage = randomBytes32() - val incomingPaymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val incomingPaymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.fundingRates) val invoice = incomingPaymentHandler.createInvoice(preimage, amount = null, Either.Left("phoenix to phoenix"), listOf()) val payment = PayInvoice(UUID.randomUUID(), 300_000.msat, LightningOutgoingPayment.Details.Normal(invoice)) @@ -476,9 +476,9 @@ class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { } // Bob receives these 2 HTLCs. - val process1 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[0].first, adds[0].second, 3), TestConstants.defaultBlockHeight) + val process1 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[0].first, adds[0].second, 3), TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertTrue(process1 is IncomingPaymentHandler.ProcessAddResult.Pending) - val process2 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[1].first, adds[1].second, 5), TestConstants.defaultBlockHeight) + val process2 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[1].first, adds[1].second, 5), TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertTrue(process2 is IncomingPaymentHandler.ProcessAddResult.Accepted) val fulfills = process2.actions.filterIsInstance().mapNotNull { it.channelCommand as? ChannelCommand.Htlc.Settlement.Fulfill } assertEquals(2, fulfills.size) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt index c780d31f5..9b699dee3 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt @@ -191,7 +191,7 @@ class PaymentPacketTestsCommon : LightningTestSuite() { ) val blindedHop = ChannelHop(d, blindedRoute.blindedNodeIds.last(), channelUpdateDE) val (amountE, expiryE, onionE) = OutgoingPaymentPacket.buildPacket(paymentMetadata.paymentHash, listOf(blindedHop), finalPayload, OnionRoutingPacket.PaymentPacketLength) - val add = UpdateAddHtlc(randomBytes32(), 2, amountE, paymentMetadata.paymentHash, expiryE, onionE.packet, blindedRoute.blindingKey) + val add = UpdateAddHtlc(randomBytes32(), 2, amountE, paymentMetadata.paymentHash, expiryE, onionE.packet, blindedRoute.blindingKey, null) return Pair(add, finalPayload) } } @@ -453,7 +453,7 @@ class PaymentPacketTestsCommon : LightningTestSuite() { ) val blindedHop = ChannelHop(d, blindedRoute.blindedNodeIds.last(), channelUpdateDE) val (amountE, expiryE, onionE) = OutgoingPaymentPacket.buildPacket(addD.paymentHash, listOf(blindedHop), payloadE, OnionRoutingPacket.PaymentPacketLength) - UpdateAddHtlc(randomBytes32(), 2, amountE, addD.paymentHash, expiryE, onionE.packet, blindingE) + UpdateAddHtlc(randomBytes32(), 2, amountE, addD.paymentHash, expiryE, onionE.packet, blindingE, null) } // E can correctly decrypt the blinded payment. @@ -539,7 +539,7 @@ class PaymentPacketTestsCommon : LightningTestSuite() { ) val blindedHop = ChannelHop(d, blindedRoute.blindedNodeIds.last(), channelUpdateDE) val (amountE, expiryE, onionE) = OutgoingPaymentPacket.buildPacket(paymentMetadata.paymentHash, listOf(blindedHop), payloadE, OnionRoutingPacket.PaymentPacketLength) - val addE = UpdateAddHtlc(randomBytes32(), 2, amountE, paymentMetadata.paymentHash, expiryE, onionE.packet, blindedRoute.blindingKey) + val addE = UpdateAddHtlc(randomBytes32(), 2, amountE, paymentMetadata.paymentHash, expiryE, onionE.packet, blindedRoute.blindingKey, null) val failure = IncomingPaymentPacket.decrypt(addE, privE) assertTrue(failure.isLeft) assertEquals(failure.left, InvalidOnionBlinding(hash(addE.onionRoutingPacket))) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt b/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt index a6f5ee145..3e3b3762a 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt @@ -76,12 +76,15 @@ object TestConstants { Feature.AnchorOutputs to FeatureSupport.Mandatory, Feature.RouteBlinding to FeatureSupport.Optional, Feature.DualFunding to FeatureSupport.Mandatory, + Feature.ShutdownAnySegwit to FeatureSupport.Mandatory, + Feature.Quiescence to FeatureSupport.Mandatory, Feature.ChannelType to FeatureSupport.Mandatory, Feature.PaymentMetadata to FeatureSupport.Optional, Feature.ExperimentalTrampolinePayment to FeatureSupport.Optional, Feature.WakeUpNotificationProvider to FeatureSupport.Optional, - Feature.PayToOpenProvider to FeatureSupport.Optional, Feature.ChannelBackupProvider to FeatureSupport.Optional, + Feature.ExperimentalSplice to FeatureSupport.Optional, + Feature.OnTheFlyFunding to FeatureSupport.Optional, ), dustLimit = 1_100.sat, maxRemoteDustLimit = 1_500.sat, diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt index 0e4781c22..07acb9573 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -14,6 +14,7 @@ import fr.acinq.lightning.channel.ChannelFlags import fr.acinq.lightning.channel.ChannelType import fr.acinq.lightning.channel.Helpers import fr.acinq.lightning.message.OnionMessages +import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat @@ -598,8 +599,9 @@ class LightningCodecsTestsCommon : LightningTestSuite() { @Test fun `decode channel_update with htlc_maximum_msat`() { // this was generated by c-lightning - val encoded = - ByteVector("010258fff7d0e987e2cdd560e3bb5a046b4efe7b26c969c2f51da1dceec7bcb8ae1b634790503d5290c1a6c51d681cf8f4211d27ed33a257dcc1102862571bf1792306226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f0005a100000200005bc75919010100060000000000000001000000010000000a000000003a699d00") + val encoded = ByteVector( + "010258fff7d0e987e2cdd560e3bb5a046b4efe7b26c969c2f51da1dceec7bcb8ae1b634790503d5290c1a6c51d681cf8f4211d27ed33a257dcc1102862571bf1792306226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f0005a100000200005bc75919010100060000000000000001000000010000000a000000003a699d00" + ) val decoded = LightningMessage.decode(encoded.toByteArray()) val expected = ChannelUpdate( ByteVector64("58fff7d0e987e2cdd560e3bb5a046b4efe7b26c969c2f51da1dceec7bcb8ae1b634790503d5290c1a6c51d681cf8f4211d27ed33a257dcc1102862571bf17923"), @@ -819,20 +821,25 @@ class LightningCodecsTestsCommon : LightningTestSuite() { } @Test - fun `encode - decode pay-to-open messages`() { - val onionPacket = OnionRoutingPacket(0, ByteVector("0209be9bd1016d73fc1f611c6f8fdccd99ffb0885594e96156a268ee9afd35559c"), ByteVector("0102030405"), ByteVector32("e0a0d5be2ca6faafa03880258e4af33a0d15aa950ab738c88566a471bf3bb14f")) - val blinding = PublicKey.fromHex("033da8b63fd839472b49935127072039e65d8f99d4603f14d79cdf74b59f895721") - val preimage = ByteVector32("339770785632e71fe1f4b48b8b90d14af94a2a3a2c70af66f2156ed8a150f795") + fun `encode - decode on-the-fly funding messages`() { + val channelId = ByteVector32("c11b8fbd682b3c6ee11f9d7268e22bb5887cd4d3bf3338bfcc340583f685733c") + val paymentId = ByteVector32("3118a7954088c27b19923894ed27923c297f88ec3734f90b2b4aafcb11238503") + val blinding = PublicKey.fromHex("0296d5c32655a5eaa8be086479d7bcff967b6e9ca8319b69565747ae16ff20fad6") + val paymentHash1 = ByteVector32("80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734") + val paymentHash2 = ByteVector32("3213a810a0bfc54566d9be09da1484538b5d19229e928dfa8b692966a8df6785") + val fundingFee = LiquidityAds.FundingFee(5_000_100.msat, TxId(TxHash("24e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566"))) val testCases = listOf( // @formatter:off - PayToOpenRequest(Block.LivenetGenesisBlock.hash, 5_000.msat, 10.sat, preimage.sha256(), 100, onionPacket, 1_000_000.sat) to Hex.decode("88cd 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 0000000000000000 0000000000001388 0000000000000000 000000000000000a e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 00000064 0005 000209be9bd1016d73fc1f611c6f8fdccd99ffb0885594e96156a268ee9afd35559c0102030405e0a0d5be2ca6faafa03880258e4af33a0d15aa950ab738c88566a471bf3bb14f 00000000000f4240"), - PayToOpenRequest(Block.LivenetGenesisBlock.hash, 5_000.msat, 10.sat, preimage.sha256(), 100, onionPacket, 0.sat, TlvStream(PayToOpenRequestTlv.Blinding(blinding))) to Hex.decode("88cd 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 0000000000000000 0000000000001388 0000000000000000 000000000000000a e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 00000064 0005 000209be9bd1016d73fc1f611c6f8fdccd99ffb0885594e96156a268ee9afd35559c0102030405e0a0d5be2ca6faafa03880258e4af33a0d15aa950ab738c88566a471bf3bb14f 0000000000000000 0021033da8b63fd839472b49935127072039e65d8f99d4603f14d79cdf74b59f895721"), - PayToOpenResponse(Block.LivenetGenesisBlock.hash, preimage.sha256(), PayToOpenResponse.Result.Success(preimage)) to Hex.decode("88bb 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 339770785632e71fe1f4b48b8b90d14af94a2a3a2c70af66f2156ed8a150f795"), - PayToOpenResponse(Block.LivenetGenesisBlock.hash, preimage.sha256(), PayToOpenResponse.Result.Failure(null)) to Hex.decode("88bb 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 0000000000000000000000000000000000000000000000000000000000000000"), - PayToOpenResponse(Block.LivenetGenesisBlock.hash, preimage.sha256(), PayToOpenResponse.Result.Failure(ByteVector("deadbeef"))) to Hex.decode("88bb 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 0000000000000000000000000000000000000000000000000000000000000000 0004deadbeef"), + UpdateAddHtlc(channelId, 7, 75_000_000.msat, paymentHash1, CltvExpiry(840_000), TestConstants.emptyOnionPacket, blinding = null, fundingFee = fundingFee) to Hex.decode("0080 c11b8fbd682b3c6ee11f9d7268e22bb5887cd4d3bf3338bfcc340583f685733c 0000000000000007 00000000047868c0 80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734 000cd140 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 fda0512800000000004c4ba424e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566"), + WillAddHtlc(Block.RegtestGenesisBlock.hash, paymentId, 50_000_000.msat, paymentHash1, CltvExpiry(840_000), TestConstants.emptyOnionPacket, blinding = null) to Hex.decode("a051 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 3118a7954088c27b19923894ed27923c297f88ec3734f90b2b4aafcb11238503 0000000002faf080 80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734 000cd140 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), + WillAddHtlc(Block.RegtestGenesisBlock.hash, paymentId, 50_000_000.msat, paymentHash1, CltvExpiry(840_000), TestConstants.emptyOnionPacket, blinding) to Hex.decode("a051 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 3118a7954088c27b19923894ed27923c297f88ec3734f90b2b4aafcb11238503 0000000002faf080 80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734 000cd140 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 00210296d5c32655a5eaa8be086479d7bcff967b6e9ca8319b69565747ae16ff20fad6"), + WillFailHtlc(paymentId, paymentHash1, ByteVector("deadbeef")) to Hex.decode("a052 3118a7954088c27b19923894ed27923c297f88ec3734f90b2b4aafcb11238503 80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734 0004 deadbeef"), + WillFailMalformedHtlc(paymentId, paymentHash1, ByteVector32("9d60e5791eee0799ce7b00009f56f56c6b988f6129b6a88494cce2cf2fa8b319"), 49157) to Hex.decode("a053 3118a7954088c27b19923894ed27923c297f88ec3734f90b2b4aafcb11238503 80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734 9d60e5791eee0799ce7b00009f56f56c6b988f6129b6a88494cce2cf2fa8b319 c005"), + CancelOnTheFlyFunding(channelId, listOf(), ByteVector("deadbeef")) to Hex.decode("a054 c11b8fbd682b3c6ee11f9d7268e22bb5887cd4d3bf3338bfcc340583f685733c 0000 0004 deadbeef"), + CancelOnTheFlyFunding(channelId, listOf(paymentHash1), ByteVector("deadbeef")) to Hex.decode("a054 c11b8fbd682b3c6ee11f9d7268e22bb5887cd4d3bf3338bfcc340583f685733c 0001 80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734 0004 deadbeef"), + CancelOnTheFlyFunding(channelId, listOf(paymentHash1, paymentHash2), ByteVector("deadbeef")) to Hex.decode("a054 c11b8fbd682b3c6ee11f9d7268e22bb5887cd4d3bf3338bfcc340583f685733c 0002 80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb21067343213a810a0bfc54566d9be09da1484538b5d19229e928dfa8b692966a8df6785 0004 deadbeef"), // @formatter:on ) - testCases.forEach { val decoded = LightningMessage.decode(it.second) assertNotNull(decoded)