From 05b49a8ce2d954adc372ac95550039ffce8354f8 Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 11 Mar 2024 15:12:03 +0100 Subject: [PATCH] Replace `pay_to_open` with `maybe_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 `maybe_add_htlc` that contains all the HTLC data. If the recipient wishes to accept that payment, it reveals the preimage when sending `open_channel2` or `splice_init` and marks the on-chain part of that payment as pending. It then keeps retrying the on-chain funding operation until it completes or our peer sends a dedicated error asking us to cancel it. --- .../kotlin/fr/acinq/lightning/Features.kt | 30 +- .../kotlin/fr/acinq/lightning/NodeEvents.kt | 7 +- .../kotlin/fr/acinq/lightning/NodeParams.kt | 4 +- .../acinq/lightning/channel/ChannelAction.kt | 6 + .../fr/acinq/lightning/channel/ChannelData.kt | 6 +- .../lightning/channel/ChannelException.kt | 2 +- .../acinq/lightning/channel/InteractiveTx.kt | 19 +- .../acinq/lightning/channel/states/Normal.kt | 33 +- .../channel/states/WaitForAcceptChannel.kt | 16 +- .../lightning/channel/states/WaitForInit.kt | 9 +- .../fr/acinq/lightning/db/PaymentsDb.kt | 150 +++-- .../kotlin/fr/acinq/lightning/io/Peer.kt | 197 ++++-- .../payment/IncomingPaymentHandler.kt | 244 ++++--- .../lightning/payment/LiquidityPolicy.kt | 11 +- .../serialization/v4/Deserialization.kt | 3 +- .../serialization/v4/Serialization.kt | 2 +- .../fr/acinq/lightning/wire/ChannelTlv.kt | 13 + .../acinq/lightning/wire/LightningMessages.kt | 111 +--- .../channel/InteractiveTxTestsCommon.kt | 116 +++- .../channel/states/SpliceTestsCommon.kt | 6 +- .../acinq/lightning/db/InMemoryPaymentsDb.kt | 26 +- .../lightning/db/PaymentsDbTestsCommon.kt | 60 +- .../payment/Bolt11InvoiceTestsCommon.kt | 2 +- .../IncomingPaymentHandlerTestsCommon.kt | 611 ++++++++---------- .../OutgoingPaymentHandlerTestsCommon.kt | 6 +- .../fr/acinq/lightning/tests/TestConstants.kt | 1 - .../acinq/lightning/tests/io/peer/builders.kt | 8 +- .../wire/LightningCodecsTestsCommon.kt | 21 +- 28 files changed, 932 insertions(+), 788 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/Features.kt b/src/commonMain/kotlin/fr/acinq/lightning/Features.kt index a819b9ecb..a864b0ff0 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" @@ -249,10 +256,19 @@ sealed class Feature { override val scopes: Set get() = setOf(FeatureScope.Init) } + /** This feature bit should be activated when a node accepts on-the-fly funding using the [MaybeAddHtlc] message. */ @Serializable - object Quiescence : Feature() { - override val rfcName get() = "option_quiescence" - override val mandatory get() = 34 + object OnTheFlyFundingClient : Feature() { + override val rfcName get() = "on_the_fly_funding_client" + override val mandatory get() = 156 + override val scopes: Set get() = setOf(FeatureScope.Init) + } + + /** This feature bit should be activated when a node supports on-the-fly funding when liquidity is missing to receive a payment. */ + @Serializable + object OnTheFlyFundingProvider : Feature() { + override val rfcName get() = "on_the_fly_funding_provider" + override val mandatory get() = 158 override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) } @@ -321,6 +337,7 @@ data class Features(val activated: Map, val unknown: Se Feature.AnchorOutputs, Feature.ShutdownAnySegwit, Feature.DualFunding, + Feature.Quiescence, Feature.ChannelType, Feature.PaymentMetadata, Feature.TrampolinePayment, @@ -336,7 +353,8 @@ data class Features(val activated: Map, val unknown: Se Feature.ChannelBackupClient, Feature.ChannelBackupProvider, Feature.ExperimentalSplice, - Feature.Quiescence + Feature.OnTheFlyFundingClient, + Feature.OnTheFlyFundingProvider, ) operator fun invoke(bytes: ByteVector): Features = invoke(bytes.toByteArray()) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt index cd190fa0e..164775c86 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt @@ -9,7 +9,6 @@ import fr.acinq.lightning.channel.states.Normal import fr.acinq.lightning.channel.states.WaitForFundingCreated import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.utils.sum -import kotlinx.coroutines.CompletableDeferred sealed interface NodeEvents @@ -32,11 +31,11 @@ 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 class MissingOffChainAmountTooLow(val missingOffChainAmount: MilliSatoshi) : Reason() + data class ChannelFundingCancelled(val paymentHash: ByteVector32) : Reason() } } - - data class ApprovalRequested(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source, val replyTo: CompletableDeferred) : LiquidityEvents } /** This is useful on iOS to ask the OS for time to finish some sensitive tasks. */ diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index 24232a747..7d599c40f 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -182,16 +182,16 @@ data class NodeParams( Feature.StaticRemoteKey to FeatureSupport.Mandatory, Feature.AnchorOutputs to FeatureSupport.Optional, // can't set Mandatory because peers prefers AnchorOutputsZeroFeeHtlcTx Feature.DualFunding to FeatureSupport.Mandatory, + Feature.Quiescence to FeatureSupport.Mandatory, Feature.ShutdownAnySegwit 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.OnTheFlyFundingClient to FeatureSupport.Optional, ), dustLimit = 546.sat, maxRemoteDustLimit = 600.sat, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt index f30f448b1..53bc1619b 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt @@ -78,8 +78,14 @@ sealed class ChannelAction { abstract val origin: Origin? abstract val txId: TxId abstract val localInputs: Set + /** @param amount amount received after deducing service and mining fees. */ data class ViaNewChannel(val amount: MilliSatoshi, val serviceFee: MilliSatoshi, val miningFee: Satoshi, override val localInputs: Set, override val txId: TxId, override val origin: Origin?) : StoreIncomingPayment() + /** @param amount amount received after deducing service and mining fees. */ data class ViaSpliceIn(val amount: MilliSatoshi, val serviceFee: MilliSatoshi, val miningFee: Satoshi, override val localInputs: Set, override val txId: TxId, override val origin: Origin?) : StoreIncomingPayment() + data class Cancelled(override val origin: Origin.OffChainPayment) : StoreIncomingPayment() { + override val localInputs: Set = setOf() + override val txId: TxId = TxId(ByteVector32.Zeroes) + } } /** Payment sent through on-chain operations (channel close or splice-out) */ sealed class StoreOutgoingPayment : Storage() { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt index 1e375fd7a..087140139 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt @@ -409,10 +409,14 @@ data class TransactionFees(val miningFee: Satoshi, val serviceFee: Satoshi) { /** Reason for creating a new channel or splicing into an existing channel. */ // @formatter:off sealed class Origin { + /** Amount of the origin payment, before fees are paid. */ abstract val amount: MilliSatoshi + /** Fees applied for the channel funding transaction. */ abstract val fees: TransactionFees - data class OffChainPayment(val paymentHash: ByteVector32, override val amount: MilliSatoshi, override val fees: TransactionFees) : Origin() + data class OffChainPayment(val paymentPreimage: ByteVector32, override val amount: MilliSatoshi, override val fees: TransactionFees) : Origin() { + val paymentHash: ByteVector32 = Crypto.sha256(paymentPreimage).byteVector32() + } data class OnChainWallet(val inputs: Set, override val amount: MilliSatoshi, override val fees: TransactionFees) : Origin() } // @formatter:on diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt index 3f08d7cc2..589fe9db5 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt @@ -30,6 +30,7 @@ data class InvalidLiquidityAdsSig (override val channelId: Byte 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 InvalidLiquidityRates (override val channelId: ByteVector32) : ChannelException(channelId, "rejecting liquidity ads proposed rates") data class ChannelFundingError (override val channelId: ByteVector32) : ChannelException(channelId, "channel funding error") +data class CancelOnTheFlyFunding (override val channelId: ByteVector32) : ChannelException(channelId, "rejecting on-the-fly funding: payment timed out and should be cancelled") data class RbfAttemptAborted (override val channelId: ByteVector32) : ChannelException(channelId, "rbf attempt aborted") data class SpliceAborted (override val channelId: ByteVector32) : ChannelException(channelId, "splice aborted") data class DualFundingAborted (override val channelId: ByteVector32, val reason: String) : ChannelException(channelId, "dual funding aborted: $reason") @@ -63,7 +64,6 @@ data class InvalidHtlcSignature (override val channelId: Byte data class InvalidCloseSignature (override val channelId: ByteVector32, val txId: TxId) : ChannelException(channelId, "invalid close signature: txId=$txId") data class InvalidCloseAmountBelowDust (override val channelId: ByteVector32, val txId: TxId) : ChannelException(channelId, "invalid closing tx: some outputs are below dust: txId=$txId") data class CommitSigCountMismatch (override val channelId: ByteVector32, val expected: Int, val actual: Int) : ChannelException(channelId, "commit sig count mismatch: expected=$expected actual=$actual") -data class SwapInSigCountMismatch (override val channelId: ByteVector32, val expected: Int, val actual: Int) : ChannelException(channelId, "swap-in sig count mismatch: expected=$expected actual=$actual") data class HtlcSigCountMismatch (override val channelId: ByteVector32, val expected: Int, val actual: Int) : ChannelException(channelId, "htlc sig count mismatch: expected=$expected actual: $actual") data class ForcedLocalCommit (override val channelId: ByteVector32) : ChannelException(channelId, "forced local commit") data class UnexpectedHtlcId (override val channelId: ByteVector32, val expected: Long, val actual: Long) : ChannelException(channelId, "unexpected htlc id: expected=$expected actual=$actual") diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt index 77f4553f2..3cbe3e4b9 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt @@ -222,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 } @@ -271,27 +270,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())) @@ -936,8 +927,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())) } } @@ -1163,7 +1156,7 @@ sealed class SpliceStatus { /** Our peer has asked us to stop sending new updates and wait for our updates to be added to the local and remote commitments. */ data class ReceivedStfu(val stfu: Stfu) : QuiescenceNegotiation.NonInitiator() /** Our updates have been added to the local and remote commitments, we wait for our peer to use the now quiescent channel. */ - object NonInitiatorQuiescent : QuiescentSpliceStatus() + data object NonInitiatorQuiescent : QuiescentSpliceStatus() /** We told our peer we want to splice funds in the channel. */ data class Requested(val command: ChannelCommand.Commitment.Splice.Request, val spliceInit: SpliceInit) : QuiescentSpliceStatus() /** We both agreed to splice and are building the splice transaction. */ 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 cf9fae06f..576886fe5 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt @@ -723,6 +723,11 @@ data class Normal( val actions = buildList { add(ChannelAction.Message.Send(TxAbort(channelId, SpliceAborted(channelId).message))) addAll(endQuiescence()) + if (cmd.message.toAscii() == CancelOnTheFlyFunding(channelId).message) { + // Our peer won't accept this on-the-fly funding attempt: they didn't receive the preimage in time and failed the corresponding HTLCs. + // We should stop retrying and sending splice_init, they will keep rejecting it anyway. + spliceStatus.command.origins.firstOrNull { it is Origin.OffChainPayment }?.let { add(ChannelAction.Storage.StoreIncomingPayment.Cancelled(it as Origin.OffChainPayment)) } + } } Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) } @@ -859,10 +864,11 @@ data class Normal( action.fundingTx.signedTx?.let { add(ChannelAction.Blockchain.PublishTx(it, ChannelAction.Blockchain.PublishTx.Type.FundingTx)) } add(ChannelAction.Blockchain.SendWatch(watchConfirmed)) add(ChannelAction.Message.Send(action.localSigs)) - // If we received or sent funds as part of the splice, we will add a corresponding entry to our incoming/outgoing payments db + // If we received or sent funds as part of the splice, we will add a corresponding entry to our incoming/outgoing payments db. + // If there is an origin, we received a payment as part of this splice (swap-in or on-the-fly funding). addAll(origins.map { origin -> ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn( - amount = origin.amount, + amount = origin.amount - origin.fees.total.toMilliSatoshi(), serviceFee = origin.fees.serviceFee.toMilliSatoshi(), miningFee = origin.fees.miningFee, localInputs = action.fundingTx.sharedTx.tx.localInputs.map { it.outPoint }.toSet(), @@ -870,17 +876,7 @@ data class Normal( origin = origin ) }) - // If we added some funds ourselves it's a swap-in - if (action.fundingTx.sharedTx.tx.localInputs.isNotEmpty()) add( - ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn( - amount = action.fundingTx.sharedTx.tx.localInputs.map { i -> i.txOut.amount }.sum().toMilliSatoshi() - action.fundingTx.sharedTx.tx.localFees, - serviceFee = 0.msat, - miningFee = action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi(), - localInputs = action.fundingTx.sharedTx.tx.localInputs.map { it.outPoint }.toSet(), - txId = action.fundingTx.txId, - origin = null - ) - ) + // We never generate change outputs: if we have local outputs, they are outgoing on-chain payments. addAll(action.fundingTx.fundingParams.localOutputs.map { txOut -> ChannelAction.Storage.StoreOutgoingPayment.ViaSpliceOut( amount = txOut.amount, @@ -889,15 +885,14 @@ data class Normal( txId = action.fundingTx.txId ) }) - // If we initiated the splice but there are no new inputs on either side and no new output on our side, it's a cpfp + // If we initiated the splice but there are no new inputs on either side and no new output on our side, it's a cpfp. if (action.fundingTx.fundingParams.isInitiator && action.fundingTx.sharedTx.tx.localInputs.isEmpty() && action.fundingTx.sharedTx.tx.remoteInputs.isEmpty() && action.fundingTx.fundingParams.localOutputs.isEmpty()) { add(ChannelAction.Storage.StoreOutgoingPayment.ViaSpliceCpfp(miningFees = action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi(), txId = action.fundingTx.txId)) } - liquidityLease?.let { lease -> - // The actual mining fees contain the inputs and outputs we paid for in the interactive-tx transaction, - // and what we refunded the remote peer for some of their inputs and outputs via the lease. - val miningFees = action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi() + lease.fees.miningFee - add(ChannelAction.Storage.StoreOutgoingPayment.ViaInboundLiquidityRequest(txId = action.fundingTx.txId, miningFees = miningFees, lease = lease)) + // We may buy liquidity explicitly, outside of the context of an incoming payment where we automatically buy liquidity. + if (liquidityLease != null && origins.isEmpty()) { + val miningFees = action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi() + liquidityLease.fees.miningFee + add(ChannelAction.Storage.StoreOutgoingPayment.ViaInboundLiquidityRequest(txId = action.fundingTx.txId, miningFees = miningFees, lease = liquidityLease)) } if (staticParams.useZeroConf) { logger.info { "channel is using 0-conf, sending splice_locked right away" } 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 90bcd8d47..e198978eb 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt @@ -125,7 +125,21 @@ data class WaitForAcceptChannel( } } } - is Error -> handleRemoteError(cmd.message) + is Error -> { + val (nextState, actions) = handleRemoteError(cmd.message) + when { + cmd.message.toAscii() == CancelOnTheFlyFunding(temporaryChannelId).message -> { + // Our peer won't accept this on-the-fly funding attempt: they didn't receive the preimage in time and failed the corresponding HTLCs. + // We should stop retrying and sending open_channel, they will keep rejecting it anyway. + val actions1 = buildList { + (channelOrigin as? Origin.OffChainPayment)?.let { add(ChannelAction.Storage.StoreIncomingPayment.Cancelled(it)) } + addAll(actions) + } + Pair(nextState, actions1) + } + else -> Pair(nextState, actions) + } + } else -> unhandled(cmd) } is ChannelCommand.Close.MutualClose -> Pair(this@WaitForAcceptChannel, listOf(ChannelAction.ProcessCmdRes.NotExecuted(cmd, CommandUnavailableInThisState(temporaryChannelId, stateName)))) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForInit.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForInit.kt index cf3dac567..76d1708e3 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForInit.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForInit.kt @@ -4,10 +4,7 @@ import fr.acinq.lightning.blockchain.BITCOIN_FUNDING_DEPTHOK import fr.acinq.lightning.blockchain.BITCOIN_FUNDING_SPENT import fr.acinq.lightning.blockchain.WatchConfirmed import fr.acinq.lightning.blockchain.WatchSpent -import fr.acinq.lightning.channel.ChannelAction -import fr.acinq.lightning.channel.ChannelCommand -import fr.acinq.lightning.channel.Helpers -import fr.acinq.lightning.channel.LocalFundingStatus +import fr.acinq.lightning.channel.* import fr.acinq.lightning.utils.msat import fr.acinq.lightning.wire.ChannelTlv import fr.acinq.lightning.wire.OpenDualFundedChannel @@ -56,6 +53,10 @@ data object WaitForInit : ChannelState() { add(ChannelTlv.ChannelTypeTlv(cmd.channelType)) cmd.requestRemoteFunding?.let { add(it.requestFunds) } if (cmd.pushAmount > 0.msat) add(ChannelTlv.PushAmountTlv(cmd.pushAmount)) + when (cmd.channelOrigin) { + is Origin.OffChainPayment -> add(ChannelTlv.OnTheFlyFundingPreimage(cmd.channelOrigin.paymentPreimage)) + else -> {} + } } ) ) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt index 2777263c8..c07730b52 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt @@ -46,22 +46,33 @@ 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. + * With on-the-fly funding, there is a delay before we receive the on-chain funds. + * We may not receive them at all if our peer has cancelled the corresponding HTLCs before receiving the preimage. + * We first mark the on-chain payment as [IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending]. + * If the on-chain transaction is correctly created, we update it to [IncomingPayment.ReceivedWith.OnChainIncomingPayment.Received]. + * If it is irrevocably cancelled, we update it to [IncomingPayment.ReceivedWith.OnChainIncomingPayment.Cancelled]. * - * This method is additive: - * - receivedWith set is appended to the existing set in database. - * - receivedAt must be updated in database. + * This method is called every time a part completes, so we must: + * - update [receivedAt] in the database. + * - when [receivedWith] contains [IncomingPayment.ReceivedWith.LightningPayment] parts, they must be appended to the existing [receivedWith] from the database. + * - when [receivedWith] contains [IncomingPayment.ReceivedWith.OnChainIncomingPayment], we must remove any [IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending] + * from the database and add the new [IncomingPayment.ReceivedWith.OnChainIncomingPayment] part. * * @param receivedWith Is a set containing the payment parts holding the incoming amount. */ suspend fun receivePayment(paymentHash: ByteVector32, receivedWith: List, receivedAt: Long = currentTimestampMillis()) + /** + * List incoming payments that have incomplete on-the-fly funding (they contain an [IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending] part). + * This usually happens when we disconnect before signing the corresponding funding transaction. + * Our peer keeps track of what they owe us, so we can re-send the corresponding open_channel or splice_init message to restart the funding flow. + */ + suspend fun listPendingOnTheFlyPayments(): List> + /** List expired unpaid normal payments created within specified time range (with the most recent payments first). */ suspend fun listExpiredPayments(fromCreatedAt: Long = 0, toCreatedAt: Long = currentTimestampMillis()): List - /** Remove a pending incoming payment.*/ + /** Remove a pending (unpaid) incoming payment. */ suspend fun removeIncomingPayment(paymentHash: ByteVector32): Boolean } @@ -133,12 +144,12 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r * confirmed (if zero-conf is used), but both sides have to agree that the funds are * usable, a.k.a. "locked". */ - override val completedAt: Long? - get() = when { - received == null -> null // payment has not yet been received - received.receivedWith.any { it is ReceivedWith.OnChainIncomingPayment && it.lockedAt == null } -> null // payment has been received, but there is at least one unconfirmed on-chain part - else -> received.receivedAt - } + override val completedAt: Long? = when { + received == null -> null // payment has not yet been received + received.receivedWith.any { it is ReceivedWith.OnChainIncomingPayment.Pending } -> null // on-chain part is still pending + received.receivedWith.any { it is ReceivedWith.OnChainIncomingPayment.Received && it.lockedAt == null } -> null // payment has been received, but there is at least one unconfirmed on-chain part + else -> received.receivedAt + } /** Total fees paid to receive this payment. */ override val fees: MilliSatoshi = received?.fees ?: 0.msat @@ -156,67 +167,92 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r /** DEPRECATED: this is the legacy trusted swap-in, which we keep for backwards-compatibility (previous payments inside the DB). */ data class SwapIn(val address: String?) : Origin() - /** Trustless swap-in (dual-funding or splice-in) */ + /** Trustless swap-in (dual-funding or splice-in). */ data class OnChain(val txId: TxId, val localInputs: Set) : Origin() } data class Received(val receivedWith: List, val receivedAt: Long = currentTimestampMillis()) { - /** Total amount received after applying the fees. */ - val amount: MilliSatoshi = receivedWith.map { it.amount }.sum() - - /** Fees applied to receive this payment. */ - val fees: MilliSatoshi = receivedWith.map { it.fees }.sum() + /** Total amount received after subtracting the fees. */ + val amount: MilliSatoshi = receivedWith.map { + when (it) { + is ReceivedWith.LightningPayment -> it.amount + is ReceivedWith.OnChainIncomingPayment.Received -> it.amount + is ReceivedWith.OnChainIncomingPayment.Cancelled -> 0.msat + is ReceivedWith.OnChainIncomingPayment.Pending -> 0.msat + } + }.sum() + + /** Fees paid to receive this payment. */ + val fees: MilliSatoshi = receivedWith.map { + when (it) { + is ReceivedWith.LightningPayment -> it.fees + is ReceivedWith.OnChainIncomingPayment.Received -> it.fees + is ReceivedWith.OnChainIncomingPayment.Cancelled -> 0.msat + is ReceivedWith.OnChainIncomingPayment.Pending -> 0.msat + } + }.sum() } sealed class ReceivedWith { - /** Amount received for this part after applying the fees. This is the final amount we can use. */ + /** Amount received for this part after subtracting the fees. This is the final amount we can use. */ abstract val amount: MilliSatoshi - /** Fees applied to receive this part. Is zero for Lightning payments. */ + /** Fees paid to receive this part (zero for lightning payments). */ abstract val fees: MilliSatoshi - /** Payment was received via existing lightning channels. */ + /** Payment was received off-chain 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 } + /** Payment was received by pushing funds using an on-chain transaction. */ sealed class OnChainIncomingPayment : ReceivedWith() { - abstract val serviceFee: MilliSatoshi - abstract val miningFee: Satoshi - override val fees: MilliSatoshi get() = serviceFee + miningFee.toMilliSatoshi() - abstract val channelId: ByteVector32 - abstract val txId: TxId - abstract val confirmedAt: Long? - abstract val lockedAt: Long? - } + /** An on-chain transaction was initiated for this payment, but isn't guaranteed to complete yet. */ + data class Pending(override val amount: MilliSatoshi) : OnChainIncomingPayment() { + // We don't know the final fees yet, they depend on the feerate we use for the funding transaction. + override val fees: MilliSatoshi = 0.msat + } - /** - * Payment was received via a new channel opened to us. - * - * @param amount Our side of the balance of this channel when it's created. This is the amount pushed to us once the creation fees are applied. - * @param serviceFee Fees paid to Lightning Service Provider to open this channel. - * @param miningFee Feed paid to bitcoin miners for processing the L1 transaction. - * @param channelId The long id of the channel created to receive this payment. May be null if the channel id is not known. - */ - data class NewChannel( - override val amount: MilliSatoshi, - override val serviceFee: MilliSatoshi, - override val miningFee: Satoshi, - override val channelId: ByteVector32, - override val txId: TxId, - override val confirmedAt: Long?, - override val lockedAt: Long? - ) : OnChainIncomingPayment() - - data class SpliceIn( - override val amount: MilliSatoshi, - override val serviceFee: MilliSatoshi, - override val miningFee: Satoshi, - override val channelId: ByteVector32, - override val txId: TxId, - override val confirmedAt: Long?, - override val lockedAt: Long? - ) : OnChainIncomingPayment() + /** + * An on-chain transaction was initiated for this payment but couldn't complete, potentially resulting in a partial payment. + * This usually indicates a cheating attempt or data loss on the service provider end: users should contact support. + */ + data class Cancelled(override val amount: MilliSatoshi) : OnChainIncomingPayment() { + override val fees: MilliSatoshi = 0.msat + } + + sealed class Received : OnChainIncomingPayment() { + abstract val serviceFee: MilliSatoshi + abstract val miningFee: Satoshi + override val fees: MilliSatoshi get() = serviceFee + miningFee.toMilliSatoshi() + abstract val channelId: ByteVector32 + abstract val txId: TxId + abstract val confirmedAt: Long? + abstract val lockedAt: Long? + + /** Payment was received by pushing funds in a new channel opened to us. */ + data class NewChannel( + override val amount: MilliSatoshi, + override val serviceFee: MilliSatoshi, + override val miningFee: Satoshi, + override val channelId: ByteVector32, + override val txId: TxId, + override val confirmedAt: Long?, + override val lockedAt: Long? + ) : Received() + + /** Payment was received by pushing funds during a splice on our existing channel. */ + data class SpliceIn( + override val amount: MilliSatoshi, + override val serviceFee: MilliSatoshi, + override val miningFee: Satoshi, + override val channelId: ByteVector32, + override val txId: TxId, + override val confirmedAt: Long?, + override val lockedAt: Long? + ) : Received() + } + } } /** A payment expires if its origin is [Origin.Invoice] and its invoice has expired. [Origin.KeySend] or [Origin.SwapIn] do not expire. */ diff --git a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index ee0d36c5f..5bbd615f4 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -12,11 +12,12 @@ import fr.acinq.lightning.channel.* import fr.acinq.lightning.channel.states.* import fr.acinq.lightning.crypto.noise.* import fr.acinq.lightning.db.* -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.MDCLogger +import fr.acinq.lightning.logging.mdc +import fr.acinq.lightning.logging.withMDC import fr.acinq.lightning.payment.* import fr.acinq.lightning.serialization.Encryption.from import fr.acinq.lightning.serialization.Serialization.DeserializationResult -import fr.acinq.lightning.transactions.Scripts import fr.acinq.lightning.transactions.Transactions import fr.acinq.lightning.utils.* import fr.acinq.lightning.utils.UUID.Companion.randomUUID @@ -48,7 +49,16 @@ data class OpenOrSpliceChannel(val walletInputs: List) : PeerC val totalAmount: Satoshi = walletInputs.map { it.amount }.sum() } -data class PeerConnection(val id: Long, val output: Channel, val logger: MDCLogger) { +/** + * Initiate a channel open or a splice to allow receiving an off-chain payment. + * + * @param paymentAmount total payment amount (including amount that may have been received with HTLCs). + */ +data class OpenOrSplicePayment(val paymentAmount: MilliSatoshi, val preimage: ByteVector32) : PeerCommand() { + val paymentHash: ByteVector32 = Crypto.sha256(preimage).byteVector32() +} + +data class PeerConnection(val id: Long, val output: Channel, val delayedCommands: Channel, val logger: MDCLogger) { fun send(msg: LightningMessage) { // We can safely use trySend because we use unlimited channel buffers. // If the connection was closed, the message will automatically be dropped. @@ -70,7 +80,6 @@ data object Disconnected : PeerCommand() sealed class PaymentCommand : PeerCommand() private data object CheckPaymentsTimeout : PaymentCommand() -data class PayToOpenResponseCommand(val payToOpenResponse: PayToOpenResponse) : PeerCommand() data class SendPayment(val paymentId: UUID, val amount: MilliSatoshi, val recipient: PublicKey, val paymentRequest: PaymentRequest, val trampolineFeesOverride: List? = null) : PaymentCommand() { val paymentHash: ByteVector32 = paymentRequest.paymentHash } @@ -158,7 +167,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, leaseRate) // encapsulates logic for sending payments private val outgoingPaymentHandler = OutgoingPaymentHandler(nodeParams, walletParams, db.payments) @@ -316,7 +325,9 @@ class Peer( val session = LightningSession(enc, dec, ck) // TODO use atomic counter instead - val peerConnection = PeerConnection(connectionId, Channel(UNLIMITED), logger) + val peerConnection = PeerConnection(connectionId, Channel(UNLIMITED), Channel(UNLIMITED), logger) + // Check pending on-the-fly funding requests: we must re-send open_channel or splice_init. + db.payments.listPendingOnTheFlyPayments().forEach { (payment, pending) -> peerConnection.delayedCommands.send(OpenOrSplicePayment(pending.amount, payment.preimage)) } // Inform the peer about the new connection. input.send(Connected(peerConnection)) connectionJob = connectionLoop(socket, session, peerConnection, logger) @@ -382,6 +393,16 @@ class Peer( } } + suspend fun processDelayedCommands() { + while (isActive) { + for (cmd in peerConnection.delayedCommands) { + delay(5.seconds) + logger.info { "processing delayed command ${cmd::class.simpleName}" } + input.send(cmd) + } + } + } + suspend fun receiveLoop() { try { while (isActive) { @@ -424,6 +445,7 @@ class Peer( launch(CoroutineName("keep-alive")) { doPing() } launch(CoroutineName("check-payments-timeout")) { checkPaymentsTimeout() } + launch(CoroutineName("process-delayed-commands")) { processDelayedCommands() } launch(CoroutineName("send-loop")) { sendLoop() } val receiveJob = launch(CoroutineName("receive-loop")) { receiveLoop() } // Suspend until the coroutine is cancelled or the socket is closed. @@ -650,6 +672,12 @@ class Peer( peerConnection?.send(message) } + /** Return true if we are currently funding a channel. */ + private fun channelFundingIsInProgress(): Boolean = when (val channel = _channels.values.firstOrNull { it is Normal }) { + is Normal -> channel.spliceStatus != SpliceStatus.None + else -> _channels.values.any { it is WaitForAcceptChannel || it is WaitForFundingCreated || it is WaitForFundingSigned || it is WaitForFundingConfirmed || it is WaitForChannelReady } + } + private suspend fun processActions(channelId: ByteVector32, peerConnection: PeerConnection?, actions: List) { // we peek into the actions to see if the id of the channel is going to change, but we're not processing it yet val actualChannelId = actions.filterIsInstance().firstOrNull()?.channelId ?: channelId @@ -674,7 +702,6 @@ class Peer( null -> logger.debug { "non-final error, more partial payments are still pending: ${action.error.message}" } } } - is ChannelAction.ProcessCmdRes.AddSettledFail -> { val currentTip = currentTipFlow.filterNotNull().first() when (val result = outgoingPaymentHandler.processAddSettled(actualChannelId, action, _channels, currentTip.first)) { @@ -688,7 +715,6 @@ class Peer( null -> logger.debug { "non-final error, more partial payments are still pending: ${action.result}" } } } - is ChannelAction.ProcessCmdRes.AddSettledFulfill -> { when (val result = outgoingPaymentHandler.processAddSettled(action)) { is OutgoingPaymentHandler.Success -> _eventsFlow.emit(PaymentSent(result.request, result.payment)) @@ -696,26 +722,21 @@ class Peer( null -> logger.debug { "unknown payment" } } } - is ChannelAction.Storage.StoreState -> { logger.info { "storing state=${action.data::class.simpleName}" } db.channels.addOrUpdateChannel(action.data) } - is ChannelAction.Storage.RemoveChannel -> { logger.info { "removing channelId=${action.data.channelId} state=${action.data::class.simpleName}" } db.channels.removeChannel(action.data.channelId) } - is ChannelAction.Storage.StoreHtlcInfos -> { action.htlcs.forEach { db.channels.addHtlcInfo(actualChannelId, it.commitmentNumber, it.paymentHash, it.cltvExpiry) } } - is ChannelAction.Storage.StoreIncomingPayment -> { logger.info { "storing incoming payment $action" } incomingPaymentHandler.process(actualChannelId, action) } - is ChannelAction.Storage.StoreOutgoingPayment -> { logger.info { "storing $action" } db.payments.addOutgoingPayment( @@ -771,24 +792,19 @@ class Peer( ) _eventsFlow.emit(ChannelClosing(channelId)) } - is ChannelAction.Storage.SetLocked -> { logger.info { "setting status locked for txid=${action.txId}" } db.payments.setLocked(action.txId) } - is ChannelAction.Storage.GetHtlcInfos -> { val htlcInfos = db.channels.listHtlcInfos(actualChannelId, action.commitmentNumber).map { ChannelAction.Storage.HtlcInfo(actualChannelId, action.commitmentNumber, it.first, it.second) } input.send(WrappedChannelCommand(actualChannelId, ChannelCommand.Closing.GetHtlcInfosResponse(action.revokedCommitTxId, htlcInfos))) } - is ChannelAction.ChannelId.IdAssigned -> { logger.info { "switching channel id from ${action.temporaryChannelId} to ${action.channelId}" } _channels[action.temporaryChannelId]?.let { _channels = _channels + (action.channelId to it) - action.temporaryChannelId } } - is ChannelAction.EmitEvent -> nodeParams._nodeEvents.emit(action.event) - is ChannelAction.Disconnect -> { logger.warning { "channel disconnected due to a protocol error" } disconnect() @@ -798,11 +814,12 @@ class Peer( } } - private suspend fun processIncomingPayment(item: Either) { + private suspend fun processIncomingPayment(item: Either) { val currentBlockHeight = currentTipFlow.filterNotNull().first().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 -> { @@ -1024,24 +1041,22 @@ class Peer( _channels = _channels + (state.channelId to state1) } } - is PayToOpenRequest -> { - logger.info { "received ${msg::class.simpleName}" } - // 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 channelInitializing = _channels.isNotEmpty() - && !_channels.values.any { it is Normal } // we don't have a channel that can be spliced - && _channels.values.any { it is WaitForFundingSigned || it is WaitForFundingConfirmed || it is WaitForChannelReady } // but we will have one soon - if (channelInitializing) { - 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 MaybeAddHtlc -> { + // 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. + if (nodeParams.features.hasFeature(Feature.OnTheFlyFundingClient) && nodeParams.liquidityPolicy.value is LiquidityPolicy.Auto) { + // If a channel funding attempt is already in progress, we won't be able to immediately accept the payment. + // Once the channel funding is complete, we may have enough inbound liquidity to receive the payment without + // an on-chain operation, which is more efficient. We thus reject the payment and wait for the sender to retry. + if (channelFundingIsInProgress()) { + val rejected = LiquidityEvents.Rejected(msg.amount, 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.ChannelFundingInProgress) + logger.info { "rejecting maybe_add_htlc: reason=${rejected.reason}" } + nodeParams._nodeEvents.emit(rejected) + } else { + processIncomingPayment(Either.Left(msg)) + } } else { - processIncomingPayment(Either.Left(msg)) + logger.info { "ignoring on-the-fly funding (amount=${msg.amount}): disabled by policy" } } } is PhoenixAndroidLegacyInfo -> { @@ -1126,16 +1141,15 @@ class Peer( // Either there are no channels, or they will never be suitable for a splice-in: we open a new channel. val currentFeerates = peerFeeratesFlow.filterNotNull().first() val requestRemoteFunding = run { - val inboundLiquidityTarget = when (val policy = nodeParams.liquidityPolicy.first()) { - is LiquidityPolicy.Disable -> null - is LiquidityPolicy.Auto -> policy.inboundLiquidityTarget - } // We need our peer to contribute, because they must have enough funds to pay the commitment fees. - // If we don't have an inbound liquidity target set, we use a default amount of 100 000 sat. - LiquidityAds.RequestRemoteFunding(inboundLiquidityTarget ?: 100_000.sat, currentTipFlow.filterNotNull().first().first, leaseRate) + val inboundLiquidityTarget = when (val policy = nodeParams.liquidityPolicy.value) { + is LiquidityPolicy.Disable -> LiquidityPolicy.minInboundLiquidityTarget // we don't disable creating a channel using our own wallet inputs + is LiquidityPolicy.Auto -> policy.inboundLiquidityTarget ?: LiquidityPolicy.minInboundLiquidityTarget + } + LiquidityAds.RequestRemoteFunding(inboundLiquidityTarget, currentTipFlow.filterNotNull().first().first, leaseRate) } val (localFundingAmount, fees) = run { - val dummyFundingScript = Script.write(Scripts.multiSig2of2(Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey)).byteVector() + val dummyFundingScript = Helpers.Funding.makeFundingPubKeyScript(Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey) val localMiningFee = Transactions.weight2fee(currentFeerates.fundingFeerate, FundingContributions.computeWeightPaid(isInitiator = true, null, dummyFundingScript, cmd.walletInputs, emptyList())) // We directly pay the on-chain fees for our inputs/outputs of the transaction. val localFundingAmount = cmd.totalAmount - localMiningFee @@ -1148,7 +1162,7 @@ class Peer( logger.warning { "cannot create channel, not enough funds to pay fees (fees=${fees.total}, available=${cmd.totalAmount})" } swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) } else { - when (val rejected = nodeParams.liquidityPolicy.first().maybeReject(requestRemoteFunding.fundingAmount.toMilliSatoshi(), fees.total.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger)) { + when (val rejected = nodeParams.liquidityPolicy.value.maybeReject(requestRemoteFunding.fundingAmount.toMilliSatoshi(), fees.total.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger)) { is LiquidityEvents.Rejected -> { logger.info { "rejecting channel open: reason=${rejected.reason}" } nodeParams._nodeEvents.emit(rejected) @@ -1182,15 +1196,98 @@ class Peer( } } else { // There are existing channels but not immediately usable (e.g. creating, disconnected), we don't do anything yet. - logger.info { "ignoring request to add utxos to channel, existing channels are not ready for splice-in: ${channels.values.map { it::class.simpleName }}" } + logger.warning { "ignoring request to add utxos to channel, existing channels are not ready for splice-in: ${channels.values.map { it::class.simpleName }}" } swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) } } } } - is PayToOpenResponseCommand -> { - logger.info { "sending ${cmd.payToOpenResponse::class.simpleName}" } - peerConnection?.send(cmd.payToOpenResponse) + is OpenOrSplicePayment -> { + val channel = channels.values.firstOrNull { it is Normal } + val currentFeerates = peerFeeratesFlow.filterNotNull().first() + // We need our peer to contribute, because they must have enough funds to pay the commitment fees. + // They will fund more than what we request to also cover the maybe_add_htlc parts that they will push to us. + // We only pay fees for the additional liquidity we request, not for the maybe_add_htlc amounts. + val remoteFundingAmount = when (val policy = nodeParams.liquidityPolicy.value) { + // We already checked our liquidity policy in the IncomingPaymentHandler before accepting the payment. + // If it is now disabled, it means the user concurrently updated their policy, but we're already committed + // to accepting this payment, which passed the previous policy. + is LiquidityPolicy.Disable -> LiquidityPolicy.minInboundLiquidityTarget + is LiquidityPolicy.Auto -> policy.inboundLiquidityTarget ?: LiquidityPolicy.minInboundLiquidityTarget + } + val requestRemoteFunding = LiquidityAds.RequestRemoteFunding(remoteFundingAmount, currentTipFlow.filterNotNull().first().first, leaseRate) + when { + channelFundingIsInProgress() -> { + logger.warning { "delaying on-the-fly funding, funding is already in progress" } + peerConnection?.delayedCommands?.send(cmd) + } + channel is Normal -> { + // 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 = channel.commitments.active.first().localCommit.spec.toLocal + val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = channel.commitments.active.first(), walletInputs = listOf(), localOutputs = emptyList()) + val (targetFeerate, localMiningFee) = watcher.client.computeSpliceCpfpFeerate(channel.commitments, currentFeerates.fundingFeerate, spliceWeight = weight, logger) + val fundingFeerate = when { + localBalance <= localMiningFee * 0.75 -> { + // Our current balance is too low to pay the on-chain fees. + // We consume all of it in on-chain fees, and also target a higher feerate. + // This ensures that the resulting feerate won't be too low compared to our target. + // We must cover the shared input and the shared output, which is a lot of weight, so we add 50%. + targetFeerate * 1.5 + } + else -> targetFeerate + } + val leaseFees = leaseRate.fees(fundingFeerate, remoteFundingAmount, remoteFundingAmount) + val totalFees = TransactionFees(miningFee = localMiningFee.min(localBalance.truncateToSatoshi()) + leaseFees.miningFee, serviceFee = leaseFees.serviceFee) + logger.info { "requesting on-the-fly splice for paymentHash=${cmd.paymentHash} feerate=$fundingFeerate fee=${totalFees.total}" } + val spliceCommand = ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = null, + spliceOut = null, + requestRemoteFunding = requestRemoteFunding, + feerate = fundingFeerate, + origins = listOf(Origin.OffChainPayment(cmd.preimage, cmd.paymentAmount, totalFees)) + ) + input.send(WrappedChannelCommand(channel.channelId, spliceCommand)) + } + channels.values.all { it is ShuttingDown || it is Negotiating || it is Closing || it is Closed || it is Aborted } -> { + // 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 must cover the shared output, which doesn't add too much weight, so we add 25%. + val fundingFeerate = currentFeerates.fundingFeerate * 1.25 + val leaseFees = leaseRate.fees(fundingFeerate, remoteFundingAmount, remoteFundingAmount) + // We don't pay any local on-chain fees, our fee is only for the liquidity lease. + val totalFees = TransactionFees(miningFee = leaseFees.miningFee, serviceFee = leaseFees.serviceFee) + logger.info { "requesting on-the-fly channel for paymentHash=${cmd.paymentHash} feerate=$fundingFeerate fee=${leaseFees.total}" } + 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 = requestRemoteFunding, + 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) + } + else -> { + // There is an existing channel but not immediately usable (e.g. disconnected), we don't do anything yet. + logger.warning { "delaying on-the-fly funding, existing channels are not ready for splice-in: ${channels.values.map { it::class.simpleName }}" } + peerConnection?.delayedCommands?.send(cmd) + } + } } is SendPayment -> { val currentTip = currentTipFlow.filterNotNull().first() diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index fd83e8166..92c374938 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -7,12 +7,16 @@ import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 +import fr.acinq.lightning.LiquidityEvents +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.NodeParams +import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.ChannelAction import fr.acinq.lightning.channel.ChannelCommand import fr.acinq.lightning.channel.Origin import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.IncomingPaymentsDb -import fr.acinq.lightning.io.PayToOpenResponseCommand +import fr.acinq.lightning.io.OpenOrSplicePayment import fr.acinq.lightning.io.PeerCommand import fr.acinq.lightning.io.WrappedChannelCommand import fr.acinq.lightning.logging.MDCLogger @@ -34,14 +38,14 @@ 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 OnTheFlyFundingPart(val htlc: MaybeAddHtlc, 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 fun toString(): String = "pay-to-open(amount=${payToOpenRequest.amountMsat})" + override val paymentHash: ByteVector32 = htlc.paymentHash + override fun toString(): String = "maybe-htlc(amount=${htlc.amount})" } -class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPaymentsDb) { +class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPaymentsDb, val leaseRate: LiquidityAds.LeaseRate) { sealed class ProcessAddResult { abstract val actions: List @@ -107,13 +111,13 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment /** * 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. + * - for an off-chain origin, the payment already exists and we only add a received-with. + * - for a swap-in origin, a new incoming payment must be created with a random payment hash. */ suspend fun process(channelId: ByteVector32, action: ChannelAction.Storage.StoreIncomingPayment) { val receivedWith = when (action) { is ChannelAction.Storage.StoreIncomingPayment.ViaNewChannel -> - IncomingPayment.ReceivedWith.NewChannel( + IncomingPayment.ReceivedWith.OnChainIncomingPayment.Received.NewChannel( amount = action.amount, serviceFee = action.serviceFee, miningFee = action.miningFee, @@ -123,7 +127,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment lockedAt = null, ) is ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn -> - IncomingPayment.ReceivedWith.SpliceIn( + IncomingPayment.ReceivedWith.OnChainIncomingPayment.Received.SpliceIn( amount = action.amount, serviceFee = action.serviceFee, miningFee = action.miningFee, @@ -132,26 +136,23 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment confirmedAt = null, lockedAt = null, ) + is ChannelAction.Storage.StoreIncomingPayment.Cancelled -> { + logger.warning { "channelId:$channelId on-the-fly funding cancelled by our peer, payment may be partially received" } + val event = LiquidityEvents.Rejected(action.origin.amount, action.origin.fees.total.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.ChannelFundingCancelled(action.origin.paymentHash)) + nodeParams._nodeEvents.emit(event) + IncomingPayment.ReceivedWith.OnChainIncomingPayment.Cancelled(action.origin.amount) + } } 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) - ) + is Origin.OffChainPayment -> { + // There already is a corresponding invoice in the db. + db.receivePayment(origin.paymentHash, listOf(receivedWith)) nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(origin.paymentHash, listOf(receivedWith))) } else -> { - // 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 - origin = IncomingPayment.Origin.OnChain(action.txId, action.localInputs) - ) - db.receivePayment( - paymentHash = incomingPayment.paymentHash, - receivedWith = listOf(receivedWith) - ) + // This is a swap, there was no pre-existing invoice, we need to create a fake one. + val incomingPayment = db.addIncomingPayment(preimage = randomBytes32(), origin = IncomingPayment.Origin.OnChain(action.txId, action.localInputs)) + db.receivePayment(incomingPayment.paymentHash, listOf(receivedWith)) nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(incomingPayment.paymentHash, listOf(receivedWith))) } } @@ -161,11 +162,11 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment * 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). + * @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 { + suspend fun process(htlc: UpdateAddHtlc, currentBlockHeight: Int, currentFeerate: FeeratePerKw): 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, @@ -173,27 +174,27 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment // 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) + is Either.Right -> processPaymentPart(res.value, currentBlockHeight, currentFeerate) } } /** - * Process an incoming pay-to-open request. + * Process an incoming on-the-fly funding 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)) { + suspend fun process(htlc: MaybeAddHtlc, currentBlockHeight: Int, currentFeerate: FeeratePerKw): ProcessAddResult { + return when (val res = toPaymentPart(privateKey, htlc, logger)) { 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} funding=${paymentPart.payToOpenRequest.fundingSatoshis} fees=${paymentPart.payToOpenRequest.payToOpenFeeSatoshis}" } + is HtlcPart -> logger.info { "processing htlc part amount=${paymentPart.htlc.amountMsat} expiry=${paymentPart.htlc.cltvExpiry}" } + is OnTheFlyFundingPart -> logger.info { "processing on-the-fly funding part amount=${paymentPart.htlc.amount} expiry=${paymentPart.htlc.expiry}" } } return when (val validationResult = validatePaymentPart(paymentPart, currentBlockHeight)) { is Either.Left -> validationResult.value @@ -222,10 +223,9 @@ 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) - ProcessAddResult.Rejected(listOf(action), incomingPayment) + is OnTheFlyFundingPart -> { + logger.info { "ignoring on-the-fly funding part for an invoice that has already been paid" } + ProcessAddResult.Rejected(listOf(), incomingPayment) } } } else { @@ -235,13 +235,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment // 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 - } - } + val actions = payment.parts.filterIsInstance().map { actionForFailureMessage(IncorrectOrUnknownPaymentDetails(it.totalAmount, currentBlockHeight.toLong()), it.htlc) } pending.remove(paymentPart.paymentHash) return ProcessAddResult.Rejected(actions, incomingPayment) } @@ -251,57 +245,71 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment return 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 onTheFlyFundingParts = payment.parts.filterIsInstance() + val onTheFlyAmount = onTheFlyFundingParts.map { it.amount }.sum() + val rejected = when { + onTheFlyFundingParts.isNotEmpty() -> { + val policy = nodeParams.liquidityPolicy.value + val fees = when (policy) { + is LiquidityPolicy.Disable -> 0.msat + is LiquidityPolicy.Auto -> { + val requestedAmount = policy.inboundLiquidityTarget ?: LiquidityPolicy.minInboundLiquidityTarget + leaseRate.fees(currentFeerate, requestedAmount, requestedAmount).total.toMilliSatoshi() } } - pending.remove(paymentPart.paymentHash) - return ProcessAddResult.Rejected(actions, incomingPayment) + when { + // We shouldn't initiate an on-the-fly funding if the remaining amount is too low to pay the fees. + onTheFlyAmount < fees * 2 -> LiquidityEvents.Rejected(payment.amountReceived, fees, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow(onTheFlyAmount)) + // We consider the total amount received (not only the on-the-fly funding parts) to evaluate our relative fee policy. + // A side effect is that if a large payment is only missing a small amount to be complete, we may still create a funding transaction for it. + // This makes sense, as the user likely wants to receive this large payment, and will obtain inbound liquidity for future payments. + else -> policy.maybeReject(payment.amountReceived, fees, LiquidityEvents.Source.OffChainPayment, logger) + } } + else -> null } - - when (val paymentMetadata = paymentPart.finalPayload.paymentMetadata) { - null -> logger.info { "payment received (${payment.amountReceived}) without payment metadata" } - else -> logger.info { "payment received (${payment.amountReceived}) with payment metadata ($paymentMetadata)" } - } - 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)) + when (rejected) { + is LiquidityEvents.Rejected -> { + logger.info { "rejecting on-the-fly funding: reason=${rejected.reason}" } + nodeParams._nodeEvents.emit(rejected) + pending.remove(paymentPart.paymentHash) + val actions = htlcParts.map { actionForFailureMessage(TemporaryNodeFailure, it.htlc) } + ProcessAddResult.Rejected(actions, incomingPayment) } - // We avoid sending duplicate pay-to-open responses, since the preimage is the same for every part. - if (payToOpenParts.isNotEmpty()) { - val response = PayToOpenResponse(nodeParams.chainHash, incomingPayment.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage)) - add(PayToOpenResponseCommand(response)) + else -> { + when (val paymentMetadata = paymentPart.finalPayload.paymentMetadata) { + null -> logger.info { "payment received (${payment.amountReceived}) without payment metadata" } + else -> logger.info { "payment received (${payment.amountReceived}) with payment metadata ($paymentMetadata)" } + } + // When the payment involves on-the-fly funding, we reveal the preimage to our peer and trust them to fund a channel accordingly. + // We consider the payment received, but we can only fill the DB with the htlc parts. + // The on-the-fly funding part will be updated once the corresponding channel or splice completes. + val receivedWith = buildList { + htlcParts.forEach { part -> add(IncomingPayment.ReceivedWith.LightningPayment(part.amount, part.htlc.channelId, part.htlc.id)) } + if (onTheFlyFundingParts.isNotEmpty()) add(IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending(onTheFlyAmount)) + } + val actions = buildList { + // If an on-the-fly funding is required, we first ask our peer to initiate the funding process. + // This ensures they get the preimage as soon as possible and can record how much they owe us in case we disconnect. + if (onTheFlyFundingParts.isNotEmpty()) { + add(OpenOrSplicePayment(onTheFlyAmount, incomingPayment.preimage)) + } + htlcParts.forEach { part -> + val cmd = ChannelCommand.Htlc.Settlement.Fulfill(part.htlc.id, incomingPayment.preimage, true) + add(WrappedChannelCommand(part.htlc.channelId, cmd)) + } + } + // We can remove that payment from our in-memory state and store it in our DB. + pending.remove(paymentPart.paymentHash) + val received = IncomingPayment.Received(receivedWith = receivedWith) + db.receivePayment(paymentPart.paymentHash, received.receivedWith) + nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(paymentPart.paymentHash, received.receivedWith)) + // Now that the payment is stored in our DB, we fulfill it. + // If we disconnect before completing those actions, we will read from the DB and retry when reconnecting. + ProcessAddResult.Accepted(actions, incomingPayment.copy(received = received), received) } } - - pending.remove(paymentPart.paymentHash) - val received = IncomingPayment.Received(receivedWith = receivedWith) - db.receivePayment(paymentPart.paymentHash, received.receivedWith) - nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(paymentPart.paymentHash, received.receivedWith)) - return ProcessAddResult.Accepted(actions, incomingPayment.copy(received = received), received) } } } @@ -315,17 +323,17 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment return when { incomingPayment == null -> { logger.warning { "payment for which we don't have a preimage" } - Either.Left(rejectPaymentPart(privateKey, paymentPart, null, currentBlockHeight)) + Either.Left(rejectPaymentPart(paymentPart, null, currentBlockHeight)) } // Payments are rejected for expired invoices UNLESS invoice has already been paid // We must accept payments for already paid invoices, because it could be the channel replaying HTLCs that we already fulfilled incomingPayment.isExpired() && incomingPayment.received == null -> { logger.warning { "the invoice is expired" } - Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight)) + Either.Left(rejectPaymentPart(paymentPart, incomingPayment, currentBlockHeight)) } incomingPayment.origin !is IncomingPayment.Origin.Invoice -> { logger.warning { "unsupported payment type: ${incomingPayment.origin::class}" } - Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight)) + Either.Left(rejectPaymentPart(paymentPart, incomingPayment, currentBlockHeight)) } incomingPayment.origin.paymentRequest.paymentSecret != paymentPart.finalPayload.paymentSecret -> { // BOLT 04: @@ -338,7 +346,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment // // NB: We always include a paymentSecret, and mark the feature as mandatory. logger.warning { "payment with invalid paymentSecret (${paymentPart.finalPayload.paymentSecret})" } - Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight)) + Either.Left(rejectPaymentPart(paymentPart, incomingPayment, currentBlockHeight)) } incomingPayment.origin.paymentRequest.amount != null && paymentPart.totalAmount < incomingPayment.origin.paymentRequest.amount -> { // BOLT 04: @@ -346,7 +354,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment // - MUST fail the HTLC. // - MUST return an incorrect_or_unknown_payment_details error. logger.warning { "invalid amount (underpayment): ${paymentPart.totalAmount}, expected: ${incomingPayment.origin.paymentRequest.amount}" } - Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight)) + Either.Left(rejectPaymentPart(paymentPart, incomingPayment, currentBlockHeight)) } incomingPayment.origin.paymentRequest.amount != null && paymentPart.totalAmount > incomingPayment.origin.paymentRequest.amount * 2 -> { // BOLT 04: @@ -357,11 +365,11 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment // Note: this allows the origin node to reduce information leakage by altering // the amount while not allowing for accidental gross overpayment. logger.warning { "invalid amount (overpayment): ${paymentPart.totalAmount}, expected: ${incomingPayment.origin.paymentRequest.amount}" } - Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight)) + Either.Left(rejectPaymentPart(paymentPart, incomingPayment, currentBlockHeight)) } paymentPart is HtlcPart && paymentPart.htlc.cltvExpiry < minFinalCltvExpiry(incomingPayment.origin.paymentRequest, currentBlockHeight) -> { logger.warning { "payment with expiry too small: ${paymentPart.htlc.cltvExpiry}, min is ${minFinalCltvExpiry(incomingPayment.origin.paymentRequest, currentBlockHeight)}" } - Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight)) + Either.Left(rejectPaymentPart(paymentPart, incomingPayment, currentBlockHeight)) } else -> Either.Right(incomingPayment) } @@ -370,7 +378,6 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment fun checkPaymentsTimeout(currentTimestampSeconds: Long): List { val actions = mutableListOf() val keysToRemove = mutableSetOf() - // BOLT 04: // - MUST fail all HTLCs in the HTLC set after some reasonable timeout. // - SHOULD wait for at least 60 seconds after the initial HTLC. @@ -382,12 +389,11 @@ 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 OnTheFlyFundingPart -> {} // we don't need to notify our peer, they will automatically fail HTLCs after a delay } } } } - pending.minusAssign(keysToRemove) return actions } @@ -408,11 +414,11 @@ 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 requests will be forgotten by our peer, so we need to do the same otherwise we may accept outdated ones. * 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() } @@ -431,27 +437,26 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment } /** - * Convert a incoming pay-to-open request to a payment part abstraction. + * Convert a incoming on-the-fly funding 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)) { + private fun toPaymentPart(privateKey: PrivateKey, htlc: MaybeAddHtlc, logger: MDCLogger): Either { + return when (val decrypted = IncomingPaymentPacket.decryptOnion(htlc.paymentHash, htlc.finalPacket, privateKey)) { is Either.Left -> { - val failureMsg = decrypted.value - val action = actionForPayToOpenFailure(privateKey, failureMsg, payToOpenRequest) - Either.Left(ProcessAddResult.Rejected(listOf(action), null)) + // We simply ignore invalid maybe_add_htlc messages. + logger.warning { "could not decrypt maybe_add_htlc: ${decrypted.value.message}" } + Either.Left(ProcessAddResult.Rejected(listOf(), null)) } - is Either.Right -> Either.Right(PayToOpenPart(payToOpenRequest, decrypted.value)) + is Either.Right -> Either.Right(OnTheFlyFundingPart(htlc, decrypted.value)) } } - private fun rejectPaymentPart(privateKey: PrivateKey, paymentPart: PaymentPart, incomingPayment: IncomingPayment?, currentBlockHeight: Int): ProcessAddResult.Rejected { + private fun rejectPaymentPart(paymentPart: PaymentPart, incomingPayment: IncomingPayment?, currentBlockHeight: Int): ProcessAddResult.Rejected { val failureMsg = IncorrectOrUnknownPaymentDetails(paymentPart.totalAmount, currentBlockHeight.toLong()) - val rejectedAction = when (paymentPart) { - is HtlcPart -> actionForFailureMessage(failureMsg, paymentPart.htlc) - is PayToOpenPart -> actionForPayToOpenFailure(privateKey, failureMsg, paymentPart.payToOpenRequest) + return when (paymentPart) { + is HtlcPart -> ProcessAddResult.Rejected(listOf(actionForFailureMessage(failureMsg, paymentPart.htlc)), incomingPayment) + is OnTheFlyFundingPart -> ProcessAddResult.Rejected(listOf(), incomingPayment) } - return ProcessAddResult.Rejected(listOf(rejectedAction), incomingPayment) } private fun actionForFailureMessage(msg: FailureMessage, htlc: UpdateAddHtlc, commit: Boolean = true): WrappedChannelCommand { @@ -462,15 +467,6 @@ 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 (val result = OutgoingPaymentPacket.buildHtlcFailure(privateKey, payToOpenRequest.paymentHash, payToOpenRequest.finalPacket, reason)) { - is Either.Right -> result.value - is Either.Left -> null - } - return PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest.chainHash, payToOpenRequest.paymentHash, PayToOpenResponse.Result.Failure(encryptedReason))) - } - private fun minFinalCltvExpiry(paymentRequest: Bolt11Invoice, currentBlockHeight: Int): CltvExpiry { val minFinalCltvExpiryDelta = paymentRequest.minFinalExpiryDelta ?: Bolt11Invoice.DEFAULT_MIN_FINAL_EXPIRY_DELTA return minFinalCltvExpiryDelta.toCltvExpiry(currentBlockHeight.toLong()) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt index b49a9fcaa..c9ed8ac95 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt @@ -5,10 +5,11 @@ import fr.acinq.lightning.LiquidityEvents import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.toMilliSatoshi sealed class LiquidityPolicy { - /** Never initiates swap-ins, never accept pay-to-open */ + /** Never initiates swap-ins, never accept on-the-fly funding requests. */ data object Disable : LiquidityPolicy() /** @@ -38,4 +39,12 @@ sealed class LiquidityPolicy { }?.let { reason -> LiquidityEvents.Rejected(amount, fee, source, reason) } } + companion object { + /** + * We usually need our peer to contribute to channel funding, because they must have enough funds to pay the commitment fees. + * When we don't have an inbound liquidity target set, we use the following default amount. + */ + val minInboundLiquidityTarget: Satoshi = 100_000.sat + } + } \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt index 13346fdff..9a61bce9d 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt @@ -425,6 +425,7 @@ object Deserialization { private fun Input.readChannelOrigin(): Origin = when (val discriminator = read()) { 0x01 -> { + // Note that we've replaced this field by the payment preimage: old entries will be incorrect, but it's not critical. val paymentHash = readByteVector32() val serviceFee = readNumber().msat val miningFee = readNumber().sat @@ -439,7 +440,7 @@ object Deserialization { Origin.OnChainWallet(setOf(), amount, TransactionFees(miningFee, serviceFee.truncateToSatoshi())) } 0x03 -> Origin.OffChainPayment( - paymentHash = readByteVector32(), + paymentPreimage = readByteVector32(), amount = readNumber().msat, fees = TransactionFees(miningFee = readNumber().sat, serviceFee = readNumber().sat), ) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt index ce081974f..2fa338703 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt @@ -464,7 +464,7 @@ object Serialization { private fun Output.writeChannelOrigin(o: Origin) = when (o) { is Origin.OffChainPayment -> { write(0x03) - writeByteVector32(o.paymentHash) + writeByteVector32(o.paymentPreimage) writeNumber(o.amount.toLong()) writeNumber(o.fees.miningFee.toLong()) writeNumber(o.fees.serviceFee.toLong()) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt index 0ca65c36f..91cdf3ffd 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt @@ -127,6 +127,19 @@ sealed class ChannelTlv : Tlv { override fun read(input: Input): PushAmountTlv = PushAmountTlv(LightningCodecs.tu64(input).msat) } } + + /** Preimage of a pending payment that requires on-the-fly funding. The payment amount will be pushed using [PushAmountTlv]. */ + data class OnTheFlyFundingPreimage(val preimage: ByteVector32) : ChannelTlv() { + override val tag: Long get() = OnTheFlyFundingPreimage.tag + + override fun write(out: Output) = LightningCodecs.writeBytes(preimage, out) + + companion object : TlvValueReader { + const val tag: Long = 0x4700000a + + override fun read(input: Input): OnTheFlyFundingPreimage = OnTheFlyFundingPreimage(LightningCodecs.bytes(input, 32).byteVector32()) + } + } } sealed class ChannelReadyTlv : Tlv { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index 542f94a36..2cb2a04f4 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.* @@ -78,8 +77,7 @@ 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) + MaybeAddHtlc.type -> MaybeAddHtlc.read(stream) FCMToken.type -> FCMToken.read(stream) UnsetFCMToken.type -> UnsetFCMToken PhoenixAndroidLegacyInfo.type -> PhoenixAndroidLegacyInfo.read(stream) @@ -671,6 +669,7 @@ data class OpenDualFundedChannel( val channelType: ChannelType? get() = tlvStream.get()?.channelType val pushAmount: MilliSatoshi get() = tlvStream.get()?.amount ?: 0.msat val requestFunds: ChannelTlv.RequestFunds? get() = tlvStream.get() + val preimage: ByteVector32? = tlvStream.get()?.preimage override val type: Long get() = OpenDualFundedChannel.type @@ -709,6 +708,7 @@ data class OpenDualFundedChannel( ChannelTlv.RequireConfirmedInputsTlv.tag to ChannelTlv.RequireConfirmedInputsTlv as TlvValueReader, ChannelTlv.RequestFunds.tag to ChannelTlv.RequestFunds as TlvValueReader, ChannelTlv.PushAmountTlv.tag to ChannelTlv.PushAmountTlv.Companion as TlvValueReader, + ChannelTlv.OnTheFlyFundingPreimage.tag to ChannelTlv.OnTheFlyFundingPreimage.Companion as TlvValueReader, ) override fun read(input: Input): OpenDualFundedChannel { @@ -950,6 +950,7 @@ data class SpliceInit( val requireConfirmedInputs: Boolean = tlvStream.get()?.let { true } ?: false val requestFunds: ChannelTlv.RequestFunds? get() = tlvStream.get() val pushAmount: MilliSatoshi = tlvStream.get()?.amount ?: 0.msat + val preimage: ByteVector32? = tlvStream.get()?.preimage constructor(channelId: ByteVector32, fundingContribution: Satoshi, pushAmount: MilliSatoshi, feerate: FeeratePerKw, lockTime: Long, fundingPubkey: PublicKey, requestFunds: ChannelTlv.RequestFunds?) : this( channelId, @@ -977,6 +978,7 @@ data class SpliceInit( ChannelTlv.RequireConfirmedInputsTlv.tag to ChannelTlv.RequireConfirmedInputsTlv as TlvValueReader, ChannelTlv.RequestFunds.tag to ChannelTlv.RequestFunds as TlvValueReader, ChannelTlv.PushAmountTlv.tag to ChannelTlv.PushAmountTlv.Companion as TlvValueReader, + ChannelTlv.OnTheFlyFundingPreimage.tag to ChannelTlv.OnTheFlyFundingPreimage.Companion as TlvValueReader, ) override fun read(input: Input): SpliceInit = SpliceInit( @@ -1594,102 +1596,39 @@ 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 fundingSatoshis total capacity of the channel our peer will open to us (some of the funds may be on their side). - * @param amountMsat payment amount covered by this new channel: we will receive push_msat = amountMsat - fees. - * @param payToOpenMinAmountMsat minimum amount for a pay-to-open to be attempted, this should be compared to the total amount in the case of an MPP payment. - * @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.OnTheFlyFundingPreimage] and [ChannelTlv.RequestFunds]. + * Our peer will then use the payment preimage to settle the HTLCs they received and use [ChannelTlv.PushAmountTlv] to forward us the payment amount. */ -data class PayToOpenRequest( +data class MaybeAddHtlc( override val chainHash: BlockHash, - val fundingSatoshis: Satoshi, - val amountMsat: MilliSatoshi, - val payToOpenMinAmountMsat: MilliSatoshi, - val payToOpenFeeSatoshis: Satoshi, + val amount: MilliSatoshi, val paymentHash: ByteVector32, - val expireAt: Long, + val expiry: CltvExpiry, val finalPacket: OnionRoutingPacket -) : LightningMessage, HasChainHash { - override val type: Long get() = PayToOpenRequest.type +) : ChannelMessage, HasChainHash { + override val type: Long get() = MaybeAddHtlc.type override fun write(out: Output) { LightningCodecs.writeBytes(chainHash.value, out) - LightningCodecs.writeU64(fundingSatoshis.toLong(), out) - LightningCodecs.writeU64(amountMsat.toLong(), out) - LightningCodecs.writeU64(payToOpenMinAmountMsat.toLong(), out) - LightningCodecs.writeU64(payToOpenFeeSatoshis.toLong(), out) + LightningCodecs.writeU64(amount.toLong(), out) LightningCodecs.writeBytes(paymentHash, out) - LightningCodecs.writeU32(expireAt.toInt(), out) + LightningCodecs.writeU32(expiry.toLong().toInt(), out) LightningCodecs.writeU16(finalPacket.payload.size(), out) OnionRoutingPacketSerializer(finalPacket.payload.size()).write(finalPacket, out) } - companion object : LightningMessageReader { - const val type: Long = 35021 + companion object : LightningMessageReader { + const val type: Long = 35027 - override fun read(input: Input): PayToOpenRequest { - return PayToOpenRequest( - chainHash = BlockHash(LightningCodecs.bytes(input, 32)), - fundingSatoshis = Satoshi(LightningCodecs.u64(input)), - amountMsat = MilliSatoshi(LightningCodecs.u64(input)), - payToOpenMinAmountMsat = MilliSatoshi(LightningCodecs.u64(input)), - payToOpenFeeSatoshis = Satoshi(LightningCodecs.u64(input)), - paymentHash = ByteVector32(LightningCodecs.bytes(input, 32)), - expireAt = LightningCodecs.u32(input).toLong(), - finalPacket = OnionRoutingPacketSerializer(LightningCodecs.u16(input)).read(input) - ) - } - } -} - -data class PayToOpenResponse(override val chainHash: BlockHash, val paymentHash: ByteVector32, val result: Result) : LightningMessage, HasChainHash { - override val type: Long get() = PayToOpenResponse.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(chainHash.value, 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) - } - } - } - } - - companion object : LightningMessageReader { - const val type: Long = 35003 - - 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): MaybeAddHtlc = MaybeAddHtlc( + chainHash = BlockHash(LightningCodecs.bytes(input, 32)), + amount = LightningCodecs.u64(input).msat, + paymentHash = LightningCodecs.bytes(input, 32).byteVector32(), + expiry = CltvExpiry(LightningCodecs.u32(input).toLong()), + finalPacket = OnionRoutingPacketSerializer(LightningCodecs.u16(input)).read(input), + ) } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt index 8efd14986..c979b19c4 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 funds using push_amount, and liquidity fees will be deduced from that amount. + 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 push_amount. + 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 funds using push_amount, and liquidity fees will be deduced from that amount. + 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/SpliceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt index d3cbf7571..836eb7666 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt @@ -664,7 +664,7 @@ class SpliceTestsCommon : LightningTestSuite() { val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(txSigsBob)) assertIs>(alice5) assertEquals(alice5.state.commitments.active.size, 2) - assertEquals(actionsAlice5.size, 8) + assertEquals(actionsAlice5.size, 7) assertEquals(actionsAlice5.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.FundingTx).txid, spliceTxId) assertEquals(htlcs.bobToAlice.map { it.second }.toSet(), actionsAlice5.filterIsInstance().map { it.add }.toSet()) actionsAlice5.hasWatchConfirmed(spliceTxId) @@ -716,7 +716,7 @@ class SpliceTestsCommon : LightningTestSuite() { val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(txSigsBob)) assertIs>(alice5) assertEquals(alice5.state.commitments.active.size, 2) - assertEquals(actionsAlice5.size, 9) + assertEquals(actionsAlice5.size, 8) assertEquals(actionsAlice5.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.FundingTx).txid, spliceTxId) actionsAlice5.hasWatchConfirmed(spliceTxId) actionsAlice5.has() @@ -1490,7 +1490,7 @@ class SpliceTestsCommon : LightningTestSuite() { assertNotNull(actionsAlice2.filterIsInstance().find { it.add == htlc }) } when { - aliceSpliceStatus.session.fundingParams.localContribution > 0.sat -> actionsAlice2.has() + aliceSpliceStatus.origins.isNotEmpty() -> actionsAlice2.has() aliceSpliceStatus.session.fundingParams.localContribution < 0.sat && aliceSpliceStatus.session.fundingParams.localOutputs.isNotEmpty() -> actionsAlice2.has() aliceSpliceStatus.session.fundingParams.localContribution < 0.sat && aliceSpliceStatus.session.fundingParams.localOutputs.isEmpty() -> actionsAlice2.has() else -> {} diff --git a/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt b/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt index f833b27d7..7f1e848ad 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt @@ -30,13 +30,25 @@ class InMemoryPaymentsDb : PaymentsDb { override suspend fun receivePayment(paymentHash: ByteVector32, receivedWith: List, receivedAt: Long) { when (val payment = incoming[paymentHash]) { null -> Unit // no-op - else -> incoming[paymentHash] = run { - payment.copy( - received = IncomingPayment.Received( - receivedWith = (payment.received?.receivedWith ?: emptySet()) + receivedWith, - receivedAt = receivedAt - ) - ) + else -> { + val currentReceivedWith = payment.received?.receivedWith ?: emptySet() + val nextReceivedWith = when { + // When receiving an on-chain part, we must remove the corresponding "pending" placeholder. + receivedWith.any { it is IncomingPayment.ReceivedWith.OnChainIncomingPayment } -> currentReceivedWith.filter { it !is IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending } + receivedWith + else -> currentReceivedWith + receivedWith + } + incoming[paymentHash] = payment.copy(received = IncomingPayment.Received(nextReceivedWith, receivedAt)) + } + } + } + + override suspend fun listPendingOnTheFlyPayments(): List> { + return buildList { + incoming.values.forEach { payment -> + when (val pending = payment.received?.receivedWith?.firstOrNull { it is IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending }) { + is IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending -> add(Pair(payment, pending)) + else -> {} + } } } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt index 86c3c3011..fb69dbd7d 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt @@ -1,6 +1,9 @@ package fr.acinq.lightning.db -import fr.acinq.bitcoin.* +import fr.acinq.bitcoin.Block +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.TxId import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 @@ -8,7 +11,6 @@ import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.channel.TooManyAcceptedHtlcs import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.FinalFailure -import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.tests.utils.runSuspendTest import fr.acinq.lightning.utils.* @@ -57,7 +59,7 @@ class PaymentsDbTestsCommon : LightningTestSuite() { } @Test - fun `receive incoming payment with several parts`() = runSuspendTest { + fun `receive incoming payment with on-chain part`() = runSuspendTest { val (db, preimage, pr) = createFixture() assertNull(db.getIncomingPayment(pr.paymentHash)) @@ -68,23 +70,39 @@ class PaymentsDbTestsCommon : LightningTestSuite() { assertNotNull(pending) assertEquals(incoming, pending) + // The on-chain part is initially pending, until the corresponding on-chain transaction is created. db.receivePayment( - pr.paymentHash, listOf( + 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) - ), 110 + IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending(50_000.msat), + ) ) - 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(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) + run { + val received = db.getIncomingPayment(pr.paymentHash) + assertNotNull(received) + assertEquals(100_000.msat, received.amount) + assertEquals(0.msat, received.fees) + assertNull(received.completedAt) + } + + // The on-chain part completes. + val onChainPart = IncomingPayment.ReceivedWith.OnChainIncomingPayment.Received.NewChannel(45_000.msat, serviceFee = 2_000.msat, miningFee = 3.sat, channelId3, TxId(randomBytes32()), confirmedAt = null, lockedAt = null) + db.receivePayment(pr.paymentHash, listOf(onChainPart)) + run { + val received = db.getIncomingPayment(pr.paymentHash) + assertNotNull(received) + assertEquals(145_000.msat, received.amount) + assertEquals(5_000.msat, received.fees) + assertEquals(3, received.received!!.receivedWith.size) + assertFalse(received.received!!.receivedWith.any { it is IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending }) + assertEquals(57_000.msat, received.received!!.receivedWith[0].amount) + assertEquals(0.msat, received.received!!.receivedWith[0].fees) + assertEquals(channelId1, (received.received!!.receivedWith[0] as IncomingPayment.ReceivedWith.LightningPayment).channelId) + assertEquals(54, (received.received!!.receivedWith[1] as IncomingPayment.ReceivedWith.LightningPayment).htlcId) + assertEquals(channelId3, (received.received!!.receivedWith[2] as IncomingPayment.ReceivedWith.OnChainIncomingPayment.Received.NewChannel).channelId) + } } @Test @@ -143,7 +161,7 @@ class PaymentsDbTestsCommon : LightningTestSuite() { db.addIncomingPayment(preimage, IncomingPayment.Origin.Invoice(pr), 200) db.receivePayment( pr.paymentHash, listOf( - IncomingPayment.ReceivedWith.NewChannel( + IncomingPayment.ReceivedWith.OnChainIncomingPayment.Received.NewChannel( amount = 500_000.msat, serviceFee = 15_000.msat, miningFee = 0.sat, @@ -154,10 +172,10 @@ class PaymentsDbTestsCommon : LightningTestSuite() { ) ), 110 ) - val received1 = db.getIncomingPayment(pr.paymentHash) - assertNotNull(received1?.received) - assertEquals(500_000.msat, received1!!.amount) - assertEquals(15_000.msat, received1.fees) + val received = db.getIncomingPayment(pr.paymentHash) + assertNotNull(received?.received) + assertEquals(500_000.msat, received!!.amount) + assertEquals(15_000.msat, received.fees) } @Test diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt11InvoiceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt11InvoiceTestsCommon.kt index f2f697813..6680ef63e 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt11InvoiceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt11InvoiceTestsCommon.kt @@ -454,7 +454,7 @@ class Bolt11InvoiceTestsCommon : LightningTestSuite() { // This doesn't satisfy the feature dependency graph, but since those aren't invoice features, we should ignore it. val features = Features( mapOf(Feature.VariableLengthOnion to FeatureSupport.Mandatory, Feature.PaymentSecret to FeatureSupport.Mandatory, Feature.AnchorOutputs to FeatureSupport.Mandatory), - setOf(UnknownFeature(121), UnknownFeature(156)) + setOf(UnknownFeature(121), UnknownFeature(256)) ) val pr = Bolt11Invoice.read(createInvoiceUnsafe(features = features).write()).get() assertEquals(pr.features, features) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt index fbbf01458..f02f683dd 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt @@ -3,7 +3,7 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.CltvExpiryDelta -import fr.acinq.lightning.Lightning +import fr.acinq.lightning.Lightning.randomBytes import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.ShortChannelId @@ -12,7 +12,7 @@ import fr.acinq.lightning.crypto.sphinx.Sphinx import fr.acinq.lightning.db.InMemoryPaymentsDb import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.IncomingPaymentsDb -import fr.acinq.lightning.io.PayToOpenResponseCommand +import fr.acinq.lightning.io.OpenOrSplicePayment import fr.acinq.lightning.io.WrappedChannelCommand import fr.acinq.lightning.router.ChannelHop import fr.acinq.lightning.router.NodeHop @@ -131,283 +131,241 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } @Test - fun `receive multipart payment with single HTLC`() = runSuspendTest { + fun `receive payment with single maybe_add_htlc`() = runSuspendTest { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) 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 add = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) - val expected = ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true) - assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) - - 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) - - checkDbPayment(result.incomingPayment, paymentHandler.db) - } + val expected = OpenOrSplicePayment(defaultAmount, incomingPayment.preimage) + assertEquals(listOf(expected), result.actions) - @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) - - assertIs(result) - val expected = PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest.chainHash, payToOpenRequest.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage))) - assertEquals(setOf(expected), result.actions.toSet()) - - // the pay-to-open part is not yet inserted in db - assertTrue(result.received.receivedWith.isEmpty()) + // The on-the-fly funding part is pending in the db. + assertTrue(result.received.receivedWith.any { it is IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending }) assertEquals(0.msat, result.received.amount) assertEquals(0.msat, result.received.fees) - // later on, a channel is created + // Later on, a channel is created which completes the payment. val channelId = randomBytes32() - val amountOrigin = ChannelAction.Storage.StoreIncomingPayment.ViaNewChannel( - amount = payToOpenRequest.amountMsat, - serviceFee = payToOpenRequest.payToOpenFeeSatoshis.toMilliSatoshi(), - miningFee = 0.sat, + val action = ChannelAction.Storage.StoreIncomingPayment.ViaNewChannel( + amount = defaultAmount - 3_000_000.msat, + serviceFee = 1_000__000.msat, + miningFee = 2_000.sat, localInputs = emptySet(), txId = TxId(randomBytes32()), - origin = Origin.OffChainPayment(payToOpenRequest.paymentHash, payToOpenRequest.amountMsat, TransactionFees(miningFee = payToOpenRequest.payToOpenFeeSatoshis, serviceFee = 0.sat)) + origin = Origin.OffChainPayment(incomingPayment.preimage, defaultAmount, TransactionFees(miningFee = 2_000.sat, serviceFee = 1_000.sat)) ) - paymentHandler.process(channelId, amountOrigin) - paymentHandler.db.getIncomingPayment(payToOpenRequest.paymentHash).also { dbPayment -> + paymentHandler.process(channelId, action) + paymentHandler.db.getIncomingPayment(incomingPayment.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) + assertIs(part) + assertEquals(action.amount, part.amount) + assertEquals(action.serviceFee, part.serviceFee) + assertEquals(action.miningFee, part.miningFee) assertEquals(channelId, part.channelId) assertNull(part.confirmedAt) } - assertEquals(amountOrigin.amount, dbPayment.received?.amount) - assertEquals(amountOrigin.serviceFee, dbPayment.received?.fees) + assertEquals(action.amount, dbPayment.received?.amount) + assertEquals(action.serviceFee + action.miningFee.toMilliSatoshi(), dbPayment.received?.fees) } - } @Test - fun `receive pay-to-open payment with two evenly-split HTLCs`() = runSuspendTest { + fun `receive payment with two evenly-split maybe_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 add1 = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(75_000_000.msat, defaultAmount, paymentSecret)) + val add2 = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(75_000_000.msat, defaultAmount, paymentSecret)) - val result1 = paymentHandler.process(payToOpenRequest1, TestConstants.defaultBlockHeight) + val result1 = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) - val result2 = paymentHandler.process(payToOpenRequest2, TestConstants.defaultBlockHeight) + assertTrue(result1.actions.isEmpty()) + val result2 = paymentHandler.process(add2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) + val expected = OpenOrSplicePayment(defaultAmount, incomingPayment.preimage) + assertEquals(listOf(expected), result2.actions) - val expected = PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest1.chainHash, payToOpenRequest1.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage))) - assertEquals(setOf(expected), (result1.actions + result2.actions).toSet()) - - // pay-to-open parts are not yet inserted in db - assertTrue(result2.received.receivedWith.isEmpty()) + // The on-the-fly funding part is pending in the db. + assertEquals(1, result2.received.receivedWith.size) + assertIs(result2.received.receivedWith.first()) + assertEquals(0.msat, result2.received.amount) + assertEquals(0.msat, result2.received.fees) + checkDbPayment(result2.incomingPayment, paymentHandler.db) } @Test - fun `receive pay-to-open payment with two unevenly-split HTLCs`() = runSuspendTest { + fun `receive payment with two unevenly-split maybe_add_htlc`() = runSuspendTest { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) checkDbPayment(incomingPayment, paymentHandler.db) - val payToOpenRequest1 = makePayToOpenRequest(incomingPayment, makeMppPayload(40_000.msat, defaultAmount, paymentSecret)) - val payToOpenRequest2 = makePayToOpenRequest(incomingPayment, makeMppPayload(60_000.msat, defaultAmount, paymentSecret)) + val add1 = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(50_000_000.msat, defaultAmount, paymentSecret)) + val add2 = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(100_000_000.msat, defaultAmount, paymentSecret)) - val result1 = paymentHandler.process(payToOpenRequest1, TestConstants.defaultBlockHeight) + val result1 = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) - assertEquals(emptyList(), result1.actions) - val result2 = paymentHandler.process(payToOpenRequest2, TestConstants.defaultBlockHeight) + assertTrue(result1.actions.isEmpty()) + val result2 = paymentHandler.process(add2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) - val payToOpenResponse = PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest1.chainHash, payToOpenRequest1.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage))) - assertEquals(listOf(payToOpenResponse), result2.actions) + val expected = OpenOrSplicePayment(defaultAmount, incomingPayment.preimage) + assertEquals(listOf(expected), result2.actions) + // The on-the-fly funding part is pending in the db. + assertEquals(1, result2.received.receivedWith.size) + assertIs(result2.received.receivedWith.first()) assertEquals(0.msat, result2.received.amount) assertEquals(0.msat, result2.received.fees) - checkDbPayment(result2.incomingPayment, paymentHandler.db) - } - @Test - fun `receive pay-to-open payment with an unknown payment hash`() = runSuspendTest { - val (paymentHandler, _, _) = createFixture(defaultAmount) - val payToOpenRequest = PayToOpenRequest( - chainHash = BlockHash(ByteVector32.Zeroes), - fundingSatoshis = 100_000.sat, - amountMsat = defaultAmount, - payToOpenMinAmountMsat = 1_000_000.msat, - 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 + // Later on, a splice is created which completes the payment. + val channelId = randomBytes32() + val action = ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn( + amount = defaultAmount - 5_000_000.msat, + serviceFee = 0.msat, + miningFee = 5_000.sat, + localInputs = emptySet(), + txId = TxId(randomBytes32()), + origin = Origin.OffChainPayment(incomingPayment.preimage, defaultAmount, TransactionFees(miningFee = 5_000.sat, serviceFee = 0.sat)) ) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) + paymentHandler.process(channelId, action) + paymentHandler.db.getIncomingPayment(incomingPayment.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(action.amount, part.amount) + assertEquals(action.serviceFee, part.serviceFee) + assertEquals(action.miningFee, part.miningFee) + assertEquals(channelId, part.channelId) + assertNull(part.confirmedAt) + } + assertEquals(action.amount, dbPayment.received?.amount) + assertEquals(5_000_000.msat, dbPayment.received?.fees) + } + } + @Test + fun `receive maybe_add_htlc with an unknown payment hash`() = runSuspendTest { + val (paymentHandler, incomingPayment, _) = createFixture(defaultAmount) + val add = makeMaybeAddHtlc(paymentHandler, randomBytes32(), makeSinglePartPayload(defaultAmount, randomBytes32())) + val result = paymentHandler.process(add, 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()) + assertTrue(result.actions.isEmpty()) + checkDbPayment(incomingPayment, paymentHandler.db) } @Test - fun `receive pay-to-open payment with an incorrect payment secret`() = runSuspendTest { + fun `receive maybe_add_htlc 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) - + val add = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret.reversed())) // <--- wrong secret + val result = paymentHandler.process(add, 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!! - ) - ) + assertTrue(result.actions.isEmpty()) + checkDbPayment(incomingPayment, paymentHandler.db) + } + + @Test + fun `receive maybe_add_htlc with fee too high`() = runSuspendTest { + val inboundLiquidityTarget = 100_000.sat + val expectedFee = 3500.sat + assertEquals(expectedFee, TestConstants.leaseRate.fees(TestConstants.feeratePerKw, inboundLiquidityTarget, inboundLiquidityTarget).total) + val defaultPolicy = LiquidityPolicy.Auto(inboundLiquidityTarget, maxAbsoluteFee = 3500.sat, maxRelativeFeeBasisPoints = 10_000, skipAbsoluteFeeCheck = false) + val testCases = listOf( + // If payment amount is at least twice the fees, we accept the payment. + Triple(defaultPolicy, 7_000_000.msat, true), + // If fee is above our liquidity policy maximum fee, we reject the payment. + Triple(defaultPolicy.copy(maxAbsoluteFee = 3499.sat), 7_000_000.msat, false), + // If we disabled automatic liquidity management, we reject the payment. + Triple(LiquidityPolicy.Disable, 7_000_000.msat, false), + // If payment is too close to the fee, we reject the payment. + Triple(defaultPolicy, 6_999_999.msat, false), ) - assertEquals(setOf(expected), result.actions.toSet()) + testCases.forEach { (policy, paymentAmount, success) -> + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(paymentAmount) + paymentHandler.nodeParams.liquidityPolicy.emit(policy) + val add = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(paymentAmount, paymentAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + if (success) { + assertIs(result) + } else { + assertIs(result) + assertTrue(result.actions.isEmpty()) + } + } } @Test - fun `receive pay-to-open payment with a fee too high`() = runSuspendTest { + fun `receive trampoline payment with maybe_add_htlc`() = 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) - - 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!! - ) - ) + checkDbPayment(incomingPayment, paymentHandler.db) + val trampolineHops = listOf( + NodeHop(TestConstants.Alice.nodeParams.nodeId, TestConstants.Bob.nodeParams.nodeId, CltvExpiryDelta(144), 0.msat) ) - assertEquals(setOf(expected), result.actions.toSet()) + val finalPayload = makeMppPayload(defaultAmount, defaultAmount, paymentSecret) + val (_, _, packetAndSecrets) = OutgoingPaymentPacket.buildPacket(incomingPayment.paymentHash, trampolineHops, finalPayload, payloadLength = null) + assertTrue(packetAndSecrets.packet.payload.size() < 500) + // When our peer is used as trampoline node, they directly send the trampoline onion in maybe_add_htlc instead of wrapping it in a payment onion. + val add = MaybeAddHtlc(Chain.Regtest.chainHash, finalPayload.amount, incomingPayment.paymentHash, finalPayload.expiry, packetAndSecrets.packet) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + + assertIs(result) + val expected = OpenOrSplicePayment(defaultAmount, incomingPayment.preimage) + assertEquals(listOf(expected), result.actions) + + // The on-the-fly funding part is pending in the db. + assertTrue(result.received.receivedWith.any { it is IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending }) + assertEquals(0.msat, result.received.amount) + assertEquals(0.msat, result.received.fees) } @Test - fun `receive pay-to-open trampoline payment with an incorrect payment secret`() = runSuspendTest { + fun `receive maybe_add_htlc trampoline payment with an incorrect payment secret`() = runSuspendTest { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) val trampolineHops = listOf( NodeHop(TestConstants.Alice.nodeParams.nodeId, TestConstants.Bob.nodeParams.nodeId, CltvExpiryDelta(144), 0.msat) ) - val payToOpenRequest = PayToOpenRequest( - chainHash = BlockHash(ByteVector32.Zeroes), - fundingSatoshis = 100_000.sat, - amountMsat = defaultAmount, - payToOpenMinAmountMsat = 1_000_000.msat, - 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 - ) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - + val finalPayload = makeMppPayload(defaultAmount, defaultAmount, paymentSecret.reversed()) // <-- wrong secret + val (_, _, packetAndSecrets) = OutgoingPaymentPacket.buildPacket(incomingPayment.paymentHash, trampolineHops, finalPayload, payloadLength = null) + assertTrue(packetAndSecrets.packet.payload.size() < 500) + // When our peer is used as trampoline node, they directly send the trampoline onion in maybe_add_htlc instead of wrapping it in a payment onion. + val add = MaybeAddHtlc(Chain.Regtest.chainHash, finalPayload.amount, incomingPayment.paymentHash, finalPayload.expiry, packetAndSecrets.packet) + val result = paymentHandler.process(add, 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()) + assertTrue(result.actions.isEmpty()) } @Test - fun `receive multipart payment with multiple HTLCs via same channel`() = runSuspendTest { + fun `receive multipart payment with single HTLC`() = runSuspendTest { + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) val channelId = randomBytes32() - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) - val totalAmount = amount1 + amount2 - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + val add = makeUpdateAddHtlc(12, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) - // 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) - assertNull(result.incomingPayment.received) - assertTrue(result.actions.isEmpty()) - } + assertIs(result) + val expected = ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, 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 add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) - 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), - // @formatter:on - ).unzip() - assertEquals(expectedActions.toSet(), result.actions.toSet()) - assertEquals(totalAmount, result.received.amount) - assertEquals(expectedReceivedWith, result.received.receivedWith) - checkDbPayment(result.incomingPayment, paymentHandler.db) - } + 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) + + checkDbPayment(result.incomingPayment, paymentHandler.db) } @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 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) @@ -415,9 +373,10 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Alice sends first multipart htlc to Bob // - 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 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()) } @@ -425,13 +384,13 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Alice sends second multipart htlc to Bob // - Bob now accepts the MPP set run { - val add = makeUpdateAddHtlc(5, channelId2, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + 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(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(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), // @formatter:on ).unzip() assertEquals(expectedActions.toSet(), result.actions.toSet()) @@ -443,8 +402,6 @@ 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) val channelId = randomBytes32() val (amount1, amount2) = Pair(75_000.msat, 75_000.msat) val totalAmount = amount1 + amount2 @@ -453,7 +410,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 1: Alice sends first multipart htlc to Bob. val add1 = run { val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) @@ -465,7 +422,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 3: on reconnection, the HTLC from step 1 is processed again. run { - val result = paymentHandler.process(add1, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) @@ -474,7 +431,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 4: Alice sends second multipart htlc to Bob. run { val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off @@ -490,44 +447,9 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } @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) - - checkDbPayment(result.incomingPayment, paymentHandler.db) - } - } - - @Test - fun `receive multipart payment with a mix of HTLC and pay-to-open`() = runSuspendTest { + fun `receive multipart payment with a mix of HTLC and maybe_add_htlc`() = runSuspendTest { val channelId = randomBytes32() - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) + val (amount1, amount2) = Pair(100_000_000.msat, 50_000_000.msat) val totalAmount = amount1 + amount2 val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) @@ -536,36 +458,58 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob doesn't accept the MPP set yet run { val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) - assertTrue { result.actions.isEmpty() } + assertTrue(result.actions.isEmpty()) } // Step 2 of 2: - // - Alice sends second multipart htlc to Bob + // - Alice sends second multipart htlc to Bob using maybe_add_htlc // - Bob now accepts the MPP set run { - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) + val add = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + 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(result.actions.first(), OpenOrSplicePayment(amount2, incomingPayment.preimage)) + assertEquals(result.actions.last(), WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, incomingPayment.preimage, commit = true))) + + // The on-the-fly funding part is pending in the db, we only mark the HTLC amount as received. + assertEquals(2, result.received.receivedWith.size) + assertEquals(result.received.receivedWith.first(), IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0)) + assertEquals(result.received.receivedWith.last(), IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending(amount2)) + assertEquals(amount1, result.received.amount) assertEquals(0.msat, result.received.fees) - checkDbPayment(result.incomingPayment, paymentHandler.db) + // The on-the-fly funding part completes with a splice. + val action = ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn( + amount = amount2 - 2_000_000.msat, + serviceFee = 0.msat, + miningFee = 2_000.sat, + localInputs = emptySet(), + txId = TxId(randomBytes32()), + origin = Origin.OffChainPayment(incomingPayment.preimage, amount2, TransactionFees(miningFee = 2_000.sat, serviceFee = 0.sat)) + ) + paymentHandler.process(channelId, action) + paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash).also { dbPayment -> + assertNotNull(dbPayment) + assertIs(dbPayment.origin) + val receivedWith = dbPayment.received?.receivedWith + assertNotNull(receivedWith) + assertEquals(2, receivedWith.size) + assertEquals(receivedWith.first(), IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0)) + assertEquals(receivedWith.last(), IncomingPayment.ReceivedWith.OnChainIncomingPayment.Received.SpliceIn(action.amount, action.serviceFee, action.miningFee, channelId, action.txId, confirmedAt = null, lockedAt = null)) + assertEquals(148_000_000.msat, dbPayment.received?.amount) + assertEquals(2_000_000.msat, dbPayment.received?.fees) + } } } @Test - fun `receive multipart payment with a mix of HTLC and pay-to-open -- fee too high`() = runSuspendTest { + fun `receive multipart payment with a mix of HTLC and maybe_add_htlc -- fee too high`() = runSuspendTest { val channelId = randomBytes32() - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) + val (amount1, amount2) = Pair(100_000_000.msat, 50_000_000.msat) val totalAmount = amount1 + amount2 val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) @@ -574,39 +518,21 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob doesn't accept the MPP set yet run { val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } // Step 2 of 2: - // - Alice sends second multipart htlc to Bob + // - Alice sends second multipart htlc to Bob using maybe_add_htlc // - 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) + paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = null, maxAbsoluteFee = 500.sat, maxRelativeFeeBasisPoints = 100, skipAbsoluteFeeCheck = false)) + val add = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) 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 fail = ChannelCommand.Htlc.Settlement.Fail(0, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure), commit = true) + assertEquals(listOf(WrappedChannelCommand(channelId, fail)), result.actions) } } @@ -614,7 +540,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { 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) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = WrappedChannelCommand(add.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true)) @@ -633,7 +559,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()) } @@ -643,7 +569,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)), @@ -672,7 +598,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()) } @@ -681,7 +607,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)), @@ -696,7 +622,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { 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) + val result = paymentHandler.process(add, 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()) @@ -707,7 +633,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeSinglePartPayload(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()) @@ -719,18 +645,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( @@ -740,7 +666,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) @@ -752,7 +678,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()) @@ -762,7 +688,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()) @@ -772,7 +698,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) @@ -780,7 +706,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { @Test fun `invoice expired`() = runSuspendTest { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRate) val (incomingPayment, paymentSecret) = makeIncomingPayment( payee = paymentHandler, amount = defaultAmount, @@ -788,7 +714,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) @@ -799,7 +725,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) @@ -810,9 +736,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: @@ -829,7 +755,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) @@ -847,7 +773,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()) @@ -866,7 +792,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()) } @@ -878,7 +804,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val payload = makeMppPayload(amount2, totalAmount + MilliSatoshi(1), paymentSecret) val add = makeUpdateAddHtlc(2, channelId, paymentHandler, incomingPayment.paymentHash, payload) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = setOf( WrappedChannelCommand( @@ -905,7 +831,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()) } @@ -916,7 +842,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()) @@ -934,7 +860,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()) } @@ -973,7 +899,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()) } @@ -991,7 +917,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()) } @@ -1001,7 +927,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)), @@ -1025,11 +951,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( @@ -1042,7 +968,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()) @@ -1063,11 +989,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( @@ -1081,7 +1007,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( @@ -1097,7 +1023,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( @@ -1112,7 +1038,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.leaseRate) // create incoming payment that has expired and not been paid val expiredInvoice = paymentHandler.createInvoice( @@ -1128,7 +1054,7 @@ 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.OnChainIncomingPayment.Received.NewChannel(15_000_000.msat, 1_000_000.msat, 0.sat, randomBytes32(), TxId(randomBytes32()), confirmedAt = null, lockedAt = null)), receivedAt = 101 // simulate incoming payment being paid before it expired ) @@ -1152,7 +1078,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() @@ -1182,6 +1108,11 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { return UpdateAddHtlc(channelId, id, finalPayload.amount, paymentHash, finalPayload.expiry, packetAndSecrets.packet) } + private fun makeMaybeAddHtlc(destination: IncomingPaymentHandler, paymentHash: ByteVector32, finalPayload: PaymentOnion.FinalPayload): MaybeAddHtlc { + val (_, _, packetAndSecrets) = OutgoingPaymentPacket.buildPacket(paymentHash, channelHops(destination.nodeParams.nodeId), finalPayload, OnionRoutingPacket.PaymentPacketLength) + return MaybeAddHtlc(Chain.Regtest.chainHash, finalPayload.amount, paymentHash, finalPayload.expiry, packetAndSecrets.packet) + } + private fun makeSinglePartPayload( amount: MilliSatoshi, paymentSecret: ByteVector32, @@ -1203,26 +1134,6 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { return PaymentOnion.FinalPayload.createMultiPartPayload(amount, totalAmount, expiry, paymentSecret, null) } - const val payToOpenFeerate = 0.1 - - private fun makePayToOpenRequest(incomingPayment: IncomingPayment, finalPayload: PaymentOnion.FinalPayload): PayToOpenRequest { - return PayToOpenRequest( - chainHash = Block.RegtestGenesisBlock.hash, - fundingSatoshis = 100_000.sat, - amountMsat = finalPayload.amount, - payToOpenMinAmountMsat = 10_000.msat, - 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) @@ -1239,7 +1150,9 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } private suspend fun createFixture(invoiceAmount: MilliSatoshi?): Triple { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRate) + // 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 868530494..bbb0d7dd6 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt @@ -454,7 +454,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.leaseRate) val invoice = incomingPaymentHandler.createInvoice(preimage, amount = null, Either.Left("phoenix to phoenix"), listOf()) val payment = SendPayment(UUID.randomUUID(), 300_000.msat, invoice.nodeId, invoice) @@ -473,9 +473,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/tests/TestConstants.kt b/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt index db8a221f8..78d77fbb9 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt @@ -78,7 +78,6 @@ object TestConstants { 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, ), dustLimit = 1_100.sat, diff --git a/src/commonTest/kotlin/fr/acinq/lightning/tests/io/peer/builders.kt b/src/commonTest/kotlin/fr/acinq/lightning/tests/io/peer/builders.kt index 29e06ccf5..0e4bade22 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/tests/io/peer/builders.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/tests/io/peer/builders.kt @@ -60,8 +60,8 @@ suspend fun connect( automateMessaging: Boolean = true ): PeerTuple { val logger = MDCLogger(testLoggerFactory.newLogger("PeerConnection")) - val aliceConnection = PeerConnection(connectionId, Channel(Channel.UNLIMITED), logger) - val bobConnection = PeerConnection(connectionId, Channel(Channel.UNLIMITED), logger) + val aliceConnection = PeerConnection(connectionId, Channel(Channel.UNLIMITED), Channel(Channel.UNLIMITED), logger) + val bobConnection = PeerConnection(connectionId, Channel(Channel.UNLIMITED), Channel(Channel.UNLIMITED), logger) alice.send(Connected(aliceConnection)) bob.send(Connected(bobConnection)) @@ -142,7 +142,7 @@ suspend fun CoroutineScope.newPeer( val peer = buildPeer(this, nodeParams, walletParams, db) val logger = MDCLogger(nodeParams.loggerFactory.newLogger("PeerConnection")) - val connection = PeerConnection(0, Channel(Channel.UNLIMITED), logger) + val connection = PeerConnection(0, Channel(Channel.UNLIMITED), Channel(Channel.UNLIMITED), logger) peer.send(Connected(connection)) remotedNodeChannelState?.let { state -> @@ -197,7 +197,7 @@ suspend fun buildPeer( fastFeerate = FeeratePerKw(FeeratePerByte(50.sat)) ) val logger = MDCLogger(nodeParams.loggerFactory.newLogger("PeerConnection")) - val connection = PeerConnection(0, Channel(Channel.UNLIMITED), logger) + val connection = PeerConnection(0, Channel(Channel.UNLIMITED), Channel(Channel.UNLIMITED), logger) peer.send(Connected(connection)) return peer diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt index 8cc9475af..84bb54f5f 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -770,19 +770,22 @@ class LightningCodecsTestsCommon : LightningTestSuite() { } @Test - fun `encode - decode pay-to-open messages`() { + fun `encode - decode maybe_add_htlc`() { + // @formatter:off + val paymentOnion = OnionRoutingPacket(0, ByteVector("03462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b"), ByteVector("9149ce01cce1709194109ab594037113e897ab6120025c770527dd8537997e2528082b984fe078a5667978a573abeaf7977d9b8b6ee4f124d3352f7eea52cc66c0e76b8f6d7a25d4501a04ae190b17baff8e6378b36f165815f714559dfef275278eba897f5f229be70fc8a1980cf859d1c25fe90c77f006419770e19d29ba80be8f613d039dd05600734e0d1e218af441fe30877e717a26b7b37c2c071d62bf6d61dd17f7abfb81546d2c722c9a6dc581aa97fb6f3b513e5fbaf0d669fbf0714b2b016a0a8e356d55f267fa144f7501792f2a59269c5a22e555a914e2eb71eba5af519564f246cf58983ea3fa2674e3ab7d9969d8dffbb2bda2b2752657417937d46601eb8ebf1837221d4bdf55a4d6a97ecffde5a09bd409717fa19e440e55d775890aed89f72e65af515757e94a9b501e6bad048af55e1583adb2960a84f60fb5efd0352e77a34045fc6b221498f62810bd8294d995d9f513696f8633f29faaa9668d0c6fa0d0dd7fa13e2c185572485762bd2810dc12187f521fbefa9c320762ac1e107f7988d81c6ee201ab68a95d45d578027e271b6526154317877037dca17134ccd955a22a8481b8e1996d896fc4bf006154ed18ef279d4f255e3f1233d037aea2560011069a0ae56d6bfdd8327054ded12d85d120b8982bff970986db333baae7c95f85677726a8f74cc8bd1e5aca3d433c113048305ecce8e35caf0485a53df00284b52b42291a9ffe686b96442135b3107d8856bc652d674ee9a148e79b02c9972d3ca8c2b02609f3b358c4a67c540ba6769c4d83169bceda640b1d18b74d12b6df605b417dacf6f82d79d43bb40920898f818dc8344c036ae9c8bbf9ef52ea1ccf225c8825a4d8503df190b999e15a4be34c9d7bbf60d3b93bb7d6559f4a5916f5e40c3defeeca9337ccd1280e46d6727c5c91c2d898b685543d4ca7cfee23981323c43260b6387e7febb0fffb200a8c974ef36b3253d0fb9fe0c1c6017f2dbbdc169f3f061d9781521e8118164aeec31c3e59c199016f1025c295d8f7bdeb627d357105a2708c4c3a856b9e83ff37ed69f59f2d2e464ed1db5882925ebe2493a7ddb707e1a308fa445172a24b3ea60732f75f5c69b41fc11467ee93f37c9a6f7285ba42f716e2a0e30909056ea3e4f7985d14ca9ab280cc184ce98e2a0722d0447aa1a2eedc5e53ddfa53731df7eced406b10627b0bebd768a30bde0d470c0f1d10adc070f8d3029cacceec74e4833f4dc8c52c3f41733f5f896fceb425d0737e717a63bfb033df46286d99594dd01e2bd0a942ab792874177b32842f4833bc0340ddb74852e9cd6f29f1d997a4a4bf05dd5d12011f95e6ce18928e3a9b83b24d15f989bdf43370bcc657c3ac6601eaf5e951efdbd7ee69b1623dc5039b2dfc640692378ef032f17bc36cc00293ad90b7e18f5feb8f287a7061ed9713929aed9b14b8d566199fc7822b1c38daa16b6d83077b10af0e2b6e531ccc34ea248ea593128c9ff17defcee6618c29cd2d93cfed99b90319104b1fdcfea91e98b41d792782840fb7b25280d8565b0bcd874e79b1b323139e7fc88eb5f80f690ce30fcd81111076adb31de6aeced879b538c0b5f2b74c027cc582a540133952cb021424510312f13e15d403f700f3e15b41d677c5a1e7c4e692c5880cb4522c48e993381996a29615d2956781509cd74aec6a3c73b8536d1817e473dad4cbb1787e046606b692a44e5d21ef6b5219658b002f674367e90a2b610924e9ac543362257d4567728f2e61f61231cb5d7816e100bb6f6bd9a42329b728b18d7a696711650c16fd476e2f471f38af0f6b00d45c6e"), ByteVector32("1fa492cc7962814953ab6ad1ce3d3f3dc950e64d18a8fdce6aabc14321576f06")) + val trampolineOnion = OnionRoutingPacket(0, ByteVector("02531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337"), ByteVector("1860c0749bfd613056cfc5718beecc25a2f255fc7abbea3cd75ff820e9d30807d19b30f33626452fa54bb2d822e918558ed3e6714deb3f9a2a10895e7553c6f088c9a852043530dbc9abcc486030894364b205f5de60171b451ff462664ebce23b672579bf2a444ebfe0a81875c26d2fa16d426795b9b02ccbc4bdf909c583f0c2ebe9136510645917153ecb05181ca0c1b207824578ee841804a148f4c3df7306"), ByteVector32("dcea52d94222907c9187bc31c0880fc084f0d88716e195c0abe7672d15217623")) + val paymentHash = ByteVector32.fromValidHex("a04f0e1256ac502c6c878f473a98fbf4e2adbf1921eb076c3f40e99ec3956d8a") val testCases = listOf( - PayToOpenRequest(BlockHash(randomBytes32()), 10_000.sat, 5_000.msat, 100.msat, 10.sat, randomBytes32(), 100, OnionRoutingPacket(0, randomKey().publicKey().value, ByteVector("0102030405"), randomBytes32())), - PayToOpenResponse(BlockHash(randomBytes32()), randomBytes32(), PayToOpenResponse.Result.Success(randomBytes32())), - PayToOpenResponse(BlockHash(randomBytes32()), randomBytes32(), PayToOpenResponse.Result.Failure(null)), - PayToOpenResponse(BlockHash(randomBytes32()), randomBytes32(), PayToOpenResponse.Result.Failure(ByteVector("deadbeef"))), + Pair(MaybeAddHtlc(Chain.Mainnet.chainHash, 500.msat, paymentHash, CltvExpiry(1105), paymentOnion), Hex.decode("88d3 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 00000000000001f4 a04f0e1256ac502c6c878f473a98fbf4e2adbf1921eb076c3f40e99ec3956d8a 00000451 0514 0003462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b9149ce01cce1709194109ab594037113e897ab6120025c770527dd8537997e2528082b984fe078a5667978a573abeaf7977d9b8b6ee4f124d3352f7eea52cc66c0e76b8f6d7a25d4501a04ae190b17baff8e6378b36f165815f714559dfef275278eba897f5f229be70fc8a1980cf859d1c25fe90c77f006419770e19d29ba80be8f613d039dd05600734e0d1e218af441fe30877e717a26b7b37c2c071d62bf6d61dd17f7abfb81546d2c722c9a6dc581aa97fb6f3b513e5fbaf0d669fbf0714b2b016a0a8e356d55f267fa144f7501792f2a59269c5a22e555a914e2eb71eba5af519564f246cf58983ea3fa2674e3ab7d9969d8dffbb2bda2b2752657417937d46601eb8ebf1837221d4bdf55a4d6a97ecffde5a09bd409717fa19e440e55d775890aed89f72e65af515757e94a9b501e6bad048af55e1583adb2960a84f60fb5efd0352e77a34045fc6b221498f62810bd8294d995d9f513696f8633f29faaa9668d0c6fa0d0dd7fa13e2c185572485762bd2810dc12187f521fbefa9c320762ac1e107f7988d81c6ee201ab68a95d45d578027e271b6526154317877037dca17134ccd955a22a8481b8e1996d896fc4bf006154ed18ef279d4f255e3f1233d037aea2560011069a0ae56d6bfdd8327054ded12d85d120b8982bff970986db333baae7c95f85677726a8f74cc8bd1e5aca3d433c113048305ecce8e35caf0485a53df00284b52b42291a9ffe686b96442135b3107d8856bc652d674ee9a148e79b02c9972d3ca8c2b02609f3b358c4a67c540ba6769c4d83169bceda640b1d18b74d12b6df605b417dacf6f82d79d43bb40920898f818dc8344c036ae9c8bbf9ef52ea1ccf225c8825a4d8503df190b999e15a4be34c9d7bbf60d3b93bb7d6559f4a5916f5e40c3defeeca9337ccd1280e46d6727c5c91c2d898b685543d4ca7cfee23981323c43260b6387e7febb0fffb200a8c974ef36b3253d0fb9fe0c1c6017f2dbbdc169f3f061d9781521e8118164aeec31c3e59c199016f1025c295d8f7bdeb627d357105a2708c4c3a856b9e83ff37ed69f59f2d2e464ed1db5882925ebe2493a7ddb707e1a308fa445172a24b3ea60732f75f5c69b41fc11467ee93f37c9a6f7285ba42f716e2a0e30909056ea3e4f7985d14ca9ab280cc184ce98e2a0722d0447aa1a2eedc5e53ddfa53731df7eced406b10627b0bebd768a30bde0d470c0f1d10adc070f8d3029cacceec74e4833f4dc8c52c3f41733f5f896fceb425d0737e717a63bfb033df46286d99594dd01e2bd0a942ab792874177b32842f4833bc0340ddb74852e9cd6f29f1d997a4a4bf05dd5d12011f95e6ce18928e3a9b83b24d15f989bdf43370bcc657c3ac6601eaf5e951efdbd7ee69b1623dc5039b2dfc640692378ef032f17bc36cc00293ad90b7e18f5feb8f287a7061ed9713929aed9b14b8d566199fc7822b1c38daa16b6d83077b10af0e2b6e531ccc34ea248ea593128c9ff17defcee6618c29cd2d93cfed99b90319104b1fdcfea91e98b41d792782840fb7b25280d8565b0bcd874e79b1b323139e7fc88eb5f80f690ce30fcd81111076adb31de6aeced879b538c0b5f2b74c027cc582a540133952cb021424510312f13e15d403f700f3e15b41d677c5a1e7c4e692c5880cb4522c48e993381996a29615d2956781509cd74aec6a3c73b8536d1817e473dad4cbb1787e046606b692a44e5d21ef6b5219658b002f674367e90a2b610924e9ac543362257d4567728f2e61f61231cb5d7816e100bb6f6bd9a42329b728b18d7a696711650c16fd476e2f471f38af0f6b00d45c6e1fa492cc7962814953ab6ad1ce3d3f3dc950e64d18a8fdce6aabc14321576f06")), + Pair(MaybeAddHtlc(Chain.Mainnet.chainHash, 500_000_000.msat, paymentHash, CltvExpiry(1729), trampolineOnion), Hex.decode("88d3 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 000000001dcd6500 a04f0e1256ac502c6c878f473a98fbf4e2adbf1921eb076c3f40e99ec3956d8a 000006c1 00a1 0002531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe3371860c0749bfd613056cfc5718beecc25a2f255fc7abbea3cd75ff820e9d30807d19b30f33626452fa54bb2d822e918558ed3e6714deb3f9a2a10895e7553c6f088c9a852043530dbc9abcc486030894364b205f5de60171b451ff462664ebce23b672579bf2a444ebfe0a81875c26d2fa16d426795b9b02ccbc4bdf909c583f0c2ebe9136510645917153ecb05181ca0c1b207824578ee841804a148f4c3df7306dcea52d94222907c9187bc31c0880fc084f0d88716e195c0abe7672d15217623")), ) - + // @formatter:on testCases.forEach { - val encoded = LightningMessage.encode(it) - val decoded = LightningMessage.decode(encoded) + val decoded = LightningMessage.decode(it.second) assertNotNull(decoded) - assertEquals(it, decoded) + assertEquals(it.first, decoded) + val encoded = LightningMessage.encode(decoded) + assertArrayEquals(it.second, encoded) } }