From 149b8c30247c89306e2f498439b6b18a6b3d4ffb Mon Sep 17 00:00:00 2001 From: Bastien Teinturier <31281497+t-bast@users.noreply.github.com> Date: Wed, 25 Sep 2024 03:13:06 +0200 Subject: [PATCH] On-the-fly channel funding based on splicing and liquidity ads (#649) * Remove `please_open_channel` It is usually the wallet that decides that it needs a channel, but we want the LSP to pay the commit fees to allow the wallet user to empty its wallet over lightning. We previously used a `please_open_channel` message that was sent by the wallet to the LSP, but it doesn't work well with liquidity ads. We remove that message and instead send `open_channel` from the wallet but with a custom channel flag that tells the LSP that they should be paying the commit fees. This only works if the LSP adds funds on their side of the channel, so we couple that with liquidity ads to request funds from the LSP. We also add a `recommended_feerates` message from the LSP which lets the wallet know the on-chain feerates that the LSP will accept for on-chain funding operations, since those feerates are set in the `open_channel` message that is now sent by the wallet. * Add liquidity ads to the channel opening flow We previously only used liquidity ads with splicing: we now support it during the initial channel opening flow as well. This lets us add more unit tests, including tests for the case where the node receiving the `open_channel` message is responsible for paying the commitment fees. We also update liquidity ads to use the latest version of the spec from https://github.com/lightning/bolts/pull/1153. This introduces more ways of paying the liquidity fees, to support on-the-fly funding without existing channel balance (not implemented in this commit). Note that we need some backwards-compatibility with the previous liquidity ads types in our state serialization code: when we're in the middle of signing a splice transaction, we may have a legacy liquidity lease in our splice status. We ignore it when finalizing the splice: the only consequence is that we won't store an entry in our DB for that lease, but the channel will otherwise work correctly. * Replace `pay_to_open` with `will_add_htlc` We replace the previous pay-to-open protocol with a new protocol that only relies on liquidity ads for paying fees. We simply transmit HTLCs that cannot be relayed on existing channels with a new message called `will_add_htlc` that contains all the HTLC data. The recipient can verify that the HTLC that would match this promise is valid, and if it wishes to accept that payment, it can trigger a channel open or a splice to purchase the required inbound liquidity. Once that transaction completes, the sender will relay HTLCs matching the proposed `will_add_htlc`, which completes the payment. If the fees for the inbound liquidity purchase couldn't be paid from the previous channel balance, they can be taken from the HTLCs relayed after the funding transaction. When that happens, one side needs to trust that the other will comply. Each side can independently configure the options they're comfortable with, depending on whether they trust their peer or not. * Add `channelCreationFee` to liquidity ads Creating a new channel has an additional cost compared to adding liquidity to an existing channel: the channel will be closed in the future, which will require paying on-chain fees. Node operators can include a `channel-creation-fee-satoshis` in their liquidity ads to cover some of that future cost. * Clarify received amount before or after fees We clarify some of our event types that previously had an `amount` field to detail whether this amount includes fees or not. This impacts: - SwapInEvents.Accepted - StoreIncomingPayment.ViaNewChannel - StoreIncomingPayment.ViaSpliceIn - Origin.OnChainWallet - Origin.OffChainPayment There was an inconsistency in the `ViaSpliceIn` event, where in some cases we used the received amount, and in others the amount with fees. * Remove `minInboundLiquidityTarget` We previously forced wallets to purchase additional inbound liquidity every time an on-chain transaction was created. We now allow wallets to disable automatic liquidity purchases: the LSP will need to add enough funds on their side to cover the commitment fees, which the wallet won't be paying for. But we still make a dummy purchase of 1 sat to ensure that the liquidity ads flow is used and the wallet refunds the mining fees paid by the LSP. * Read remote funding rates from their `init` message Instead of using a hard-coded value from `WalletParams`, we read the liquidity funding rates from our peer's `init` message. --- .../kotlin/fr/acinq/lightning/Features.kt | 23 +- .../kotlin/fr/acinq/lightning/NodeEvents.kt | 25 +- .../kotlin/fr/acinq/lightning/NodeParams.kt | 9 +- .../blockchain/electrum/SwapInManager.kt | 18 +- .../acinq/lightning/channel/ChannelAction.kt | 13 +- .../acinq/lightning/channel/ChannelCommand.kt | 12 +- .../fr/acinq/lightning/channel/ChannelData.kt | 42 +- .../lightning/channel/ChannelException.kt | 4 +- .../fr/acinq/lightning/channel/Commitments.kt | 34 +- .../fr/acinq/lightning/channel/Helpers.kt | 12 +- .../acinq/lightning/channel/InteractiveTx.kt | 66 +- .../acinq/lightning/channel/states/Channel.kt | 4 +- .../states/LegacyWaitForFundingLocked.kt | 1 - .../lightning/channel/states/Negotiating.kt | 6 +- .../acinq/lightning/channel/states/Normal.kt | 107 +- .../lightning/channel/states/ShuttingDown.kt | 4 +- .../acinq/lightning/channel/states/Syncing.kt | 2 +- .../channel/states/WaitForAcceptChannel.kt | 101 +- .../channel/states/WaitForChannelReady.kt | 1 - .../channel/states/WaitForFundingConfirmed.kt | 8 +- .../channel/states/WaitForFundingCreated.kt | 6 +- .../channel/states/WaitForFundingSigned.kt | 26 +- .../lightning/channel/states/WaitForInit.kt | 12 +- .../channel/states/WaitForOpenChannel.kt | 21 +- .../fr/acinq/lightning/db/PaymentsDb.kt | 27 +- .../kotlin/fr/acinq/lightning/io/Peer.kt | 475 +++++--- .../acinq/lightning/json/JsonSerializers.kt | 22 +- .../payment/IncomingPaymentHandler.kt | 347 +++--- .../payment/IncomingPaymentPacket.kt | 57 +- .../lightning/payment/LiquidityPolicy.kt | 25 +- .../payment/OutgoingPaymentPacket.kt | 13 +- .../serialization/v2/ChannelState.kt | 9 +- .../serialization/v3/ChannelState.kt | 9 +- .../serialization/v4/Deserialization.kt | 206 ++-- .../serialization/v4/Serialization.kt | 110 +- .../lightning/transactions/Transactions.kt | 14 +- .../fr/acinq/lightning/wire/ChannelTlv.kt | 174 +-- .../kotlin/fr/acinq/lightning/wire/HtlcTlv.kt | 37 + .../kotlin/fr/acinq/lightning/wire/InitTlv.kt | 19 +- .../acinq/lightning/wire/LightningMessages.kt | 373 +++--- .../fr/acinq/lightning/wire/LiquidityAds.kt | 331 +++-- .../lightning/wire/RecommendedFeeratesTlv.kt | 48 + .../electrum/SwapInManagerTestsCommon.kt | 40 +- .../channel/CommitmentsTestsCommon.kt | 19 +- .../channel/InteractiveTxTestsCommon.kt | 116 +- .../fr/acinq/lightning/channel/TestsHelper.kt | 16 +- .../channel/states/ClosingTestsCommon.kt | 31 + .../channel/states/NegotiatingTestsCommon.kt | 36 + .../channel/states/NormalTestsCommon.kt | 56 +- .../channel/states/QuiescenceTestsCommon.kt | 3 +- .../channel/states/ShutdownTestsCommon.kt | 19 + .../channel/states/SpliceTestsCommon.kt | 123 +- .../states/WaitForAcceptChannelTestsCommon.kt | 53 +- .../states/WaitForChannelReadyTestsCommon.kt | 5 +- .../WaitForFundingConfirmedTestsCommon.kt | 2 + .../WaitForFundingCreatedTestsCommon.kt | 3 +- .../states/WaitForFundingSignedTestsCommon.kt | 170 ++- .../crypto/LocalKeyManagerTestsCommon.kt | 8 +- .../acinq/lightning/db/InMemoryPaymentsDb.kt | 10 +- .../lightning/db/PaymentsDbTestsCommon.kt | 142 +-- .../fr/acinq/lightning/io/peer/PeerTest.kt | 133 +-- .../IncomingPaymentHandlerTestsCommon.kt | 1061 ++++++++++------- .../payment/LiquidityPolicyTestsCommon.kt | 16 +- .../OutgoingPaymentHandlerTestsCommon.kt | 11 +- .../payment/PaymentPacketTestsCommon.kt | 6 +- .../StateSerializationTestsCommon.kt | 37 +- .../fr/acinq/lightning/tests/TestConstants.kt | 23 +- .../acinq/lightning/tests/io/peer/builders.kt | 15 +- .../transactions/AnchorOutputsTestsCommon.kt | 4 +- .../transactions/TransactionsTestsCommon.kt | 10 +- .../wire/LightningCodecsTestsCommon.kt | 213 ++-- .../lightning/wire/LiquidityAdsTestsCommon.kt | 57 + .../lightning/wire/OpenTlvTestsCommon.kt | 31 - .../nonreg/v2/Closing_0ba41d17/data.json | 8 +- .../nonreg/v2/Closing_0ed6ff68/data.json | 8 +- .../nonreg/v2/Closing_0efffae3/data.json | 8 +- .../nonreg/v2/Closing_2fd2a3fa/data.json | 8 +- .../nonreg/v2/Closing_3bb07fb6/data.json | 8 +- .../nonreg/v2/Closing_8f1a524e/data.json | 8 +- .../nonreg/v2/Closing_ef682e2e/data.json | 8 +- .../nonreg/v2/Negotiating_c8d15808/data.json | 8 +- .../nonreg/v2/Negotiating_d9b4cd96/data.json | 8 +- .../nonreg/v2/Negotiating_ee10091c/data.json | 8 +- .../nonreg/v2/Negotiating_f52b19b8/data.json | 8 +- .../nonreg/v2/Normal_748a735b/data.json | 11 +- .../nonreg/v2/Normal_e2253ddd/data.json | 11 +- .../nonreg/v2/Normal_ff248f8d/data.json | 11 +- .../nonreg/v2/Normal_ff4a71b6/data.json | 11 +- .../nonreg/v2/Normal_ffd9f5db/data.json | 11 +- .../nonreg/v2/ShuttingDown_c321b947/data.json | 8 +- .../nonreg/v2/ShuttingDown_f89ecd50/data.json | 8 +- .../data.json | 8 +- .../data.json | 8 +- .../data.json | 8 +- .../WaitForFundingLocked_f3437082/data.json | 8 +- .../data.json | 8 +- .../data.json | 8 +- .../nonreg/v3/Closing_029bf8f3/data.json | 8 +- .../nonreg/v3/Closing_0ba41d17/data.json | 8 +- .../nonreg/v3/Closing_0ed6ff68/data.json | 8 +- .../nonreg/v3/Closing_0efffae3/data.json | 8 +- .../nonreg/v3/Closing_ebbd24bc/data.json | 8 +- .../nonreg/v3/Closing_f137669f/data.json | 8 +- .../nonreg/v3/Negotiating_da44c6e2/data.json | 8 +- .../nonreg/v3/Negotiating_dabbed55/data.json | 8 +- .../nonreg/v3/Negotiating_fadb50c1/data.json | 8 +- .../nonreg/v3/Normal_fd10d3cc/data.json | 11 +- .../nonreg/v3/Normal_fe897b64/data.json | 11 +- .../nonreg/v3/Normal_ff248f8d/data.json | 11 +- .../nonreg/v3/Normal_ff4a71b6/data.json | 11 +- .../nonreg/v3/ShuttingDown_ef41a1a5/data.json | 8 +- .../nonreg/v3/ShuttingDown_ef7081a1/data.json | 8 +- .../data.json | 8 +- .../data.json | 8 +- .../WaitForFundingLocked_f3437082/data.json | 8 +- .../data.json | 8 +- .../data.json | 8 +- 117 files changed, 3505 insertions(+), 2196 deletions(-) create mode 100644 src/commonMain/kotlin/fr/acinq/lightning/wire/RecommendedFeeratesTlv.kt create mode 100644 src/commonTest/kotlin/fr/acinq/lightning/wire/LiquidityAdsTestsCommon.kt diff --git a/src/commonMain/kotlin/fr/acinq/lightning/Features.kt b/src/commonMain/kotlin/fr/acinq/lightning/Features.kt index 86c8235e4..90c98d16a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/Features.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/Features.kt @@ -126,6 +126,13 @@ sealed class Feature { override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) } + @Serializable + object Quiescence : Feature() { + override val rfcName get() = "option_quiescence" + override val mandatory get() = 34 + override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) + } + @Serializable object ChannelType : Feature() { override val rfcName get() = "option_channel_type" @@ -185,7 +192,7 @@ sealed class Feature { override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) } - /** This feature bit should be activated when a node accepts on-the-fly channel creation. */ + /** DEPRECATED: this feature bit was used for the legacy pay-to-open protocol. */ @Serializable object PayToOpenClient : Feature() { override val rfcName get() = "pay_to_open_client" @@ -193,7 +200,7 @@ sealed class Feature { override val scopes: Set get() = setOf(FeatureScope.Init) } - /** This feature bit should be activated when a node supports opening channels on-the-fly when liquidity is missing to receive a payment. */ + /** DEPRECATED: this feature bit was used for the legacy pay-to-open protocol. */ @Serializable object PayToOpenProvider : Feature() { override val rfcName get() = "pay_to_open_provider" @@ -250,9 +257,9 @@ sealed class Feature { } @Serializable - object Quiescence : Feature() { - override val rfcName get() = "option_quiescence" - override val mandatory get() = 34 + object OnTheFlyFunding : Feature() { + override val rfcName get() = "on_the_fly_funding" + override val mandatory get() = 560 override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) } @@ -322,6 +329,7 @@ data class Features(val activated: Map, val unknown: Se Feature.RouteBlinding, Feature.ShutdownAnySegwit, Feature.DualFunding, + Feature.Quiescence, Feature.ChannelType, Feature.PaymentMetadata, Feature.TrampolinePayment, @@ -337,7 +345,7 @@ data class Features(val activated: Map, val unknown: Se Feature.ChannelBackupClient, Feature.ChannelBackupProvider, Feature.ExperimentalSplice, - Feature.Quiescence + Feature.OnTheFlyFunding ) operator fun invoke(bytes: ByteVector): Features = invoke(bytes.toByteArray()) @@ -369,7 +377,8 @@ data class Features(val activated: Map, val unknown: Se Feature.BasicMultiPartPayment to listOf(Feature.PaymentSecret), Feature.AnchorOutputs to listOf(Feature.StaticRemoteKey), Feature.TrampolinePayment to listOf(Feature.PaymentSecret), - Feature.ExperimentalTrampolinePayment to listOf(Feature.PaymentSecret) + Feature.ExperimentalTrampolinePayment to listOf(Feature.PaymentSecret), + Feature.OnTheFlyFunding to listOf(Feature.ExperimentalSplice) ) class FeatureException(message: String) : IllegalArgumentException(message) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt index 225bc9c91..098b225ed 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt @@ -2,7 +2,10 @@ package fr.acinq.lightning import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.OutPoint import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.blockchain.electrum.WalletState +import fr.acinq.lightning.channel.ChannelManagementFees import fr.acinq.lightning.channel.InteractiveTxParams import fr.acinq.lightning.channel.SharedFundingInput import fr.acinq.lightning.channel.states.ChannelStateWithCommitments @@ -11,16 +14,18 @@ import fr.acinq.lightning.channel.states.WaitForFundingCreated import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.utils.sum import fr.acinq.lightning.wire.Init -import fr.acinq.lightning.wire.PleaseOpenChannel -import kotlinx.coroutines.CompletableDeferred sealed interface NodeEvents data class PeerConnected(val remoteNodeId: PublicKey, val theirInit: Init) : NodeEvents sealed interface SwapInEvents : NodeEvents { - data class Requested(val req: PleaseOpenChannel) : SwapInEvents - data class Accepted(val requestId: ByteVector32, val serviceFee: MilliSatoshi, val miningFee: Satoshi) : SwapInEvents + data class Requested(val walletInputs: List) : SwapInEvents { + val totalAmount: Satoshi = walletInputs.map { it.amount }.sum() + } + data class Accepted(val inputs: Set, val amountBeforeFees: Satoshi, val fees: ChannelManagementFees) : SwapInEvents { + val receivedAmount: Satoshi = amountBeforeFees - fees.total + } } sealed interface ChannelEvents : NodeEvents { @@ -30,6 +35,7 @@ sealed interface ChannelEvents : NodeEvents { } sealed interface LiquidityEvents : NodeEvents { + /** Amount of liquidity purchased, before fees are paid. */ val amount: MilliSatoshi val fee: MilliSatoshi val source: Source @@ -42,11 +48,13 @@ sealed interface LiquidityEvents : NodeEvents { data class OverAbsoluteFee(val maxAbsoluteFee: Satoshi) : TooExpensive() data class OverRelativeFee(val maxRelativeFeeBasisPoints: Int) : TooExpensive() } - data object ChannelInitializing : Reason() + data object ChannelFundingInProgress : Reason() + data object NoMatchingFundingRate : Reason() + data class MissingOffChainAmountTooLow(val missingOffChainAmount: MilliSatoshi) : Reason() + data class TooManyParts(val parts: Int) : Reason() } } - - data class ApprovalRequested(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source, val replyTo: CompletableDeferred) : LiquidityEvents + data class Accepted(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source) : LiquidityEvents } /** This is useful on iOS to ask the OS for time to finish some sensitive tasks. */ @@ -59,7 +67,6 @@ sealed interface SensitiveTaskEvents : NodeEvents { } data class TaskStarted(val id: TaskIdentifier) : SensitiveTaskEvents data class TaskEnded(val id: TaskIdentifier) : SensitiveTaskEvents - } /** This will be emitted in a corner case where the user restores a wallet on an older version of the app, which is unable to read the channel data. */ @@ -67,7 +74,7 @@ data object UpgradeRequired : NodeEvents sealed interface PaymentEvents : NodeEvents { data class PaymentReceived(val paymentHash: ByteVector32, val receivedWith: List) : PaymentEvents { - val amount: MilliSatoshi = receivedWith.map { it.amount }.sum() + val amount: MilliSatoshi = receivedWith.map { it.amountReceived }.sum() val fees: MilliSatoshi = receivedWith.map { it.fees }.sum() } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index 6abd258e2..9c158f1b2 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -12,6 +12,7 @@ import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.toMilliSatoshi +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.lightning.wire.OfferTypes import io.ktor.utils.io.charsets.* import io.ktor.utils.io.core.* @@ -172,6 +173,8 @@ data class NodeParams( require(!features.hasFeature(Feature.ZeroConfChannels)) { "${Feature.ZeroConfChannels.rfcName} has been deprecated: use the zeroConfPeers whitelist instead" } require(!features.hasFeature(Feature.TrustedSwapInClient)) { "${Feature.TrustedSwapInClient.rfcName} has been deprecated" } require(!features.hasFeature(Feature.TrustedSwapInProvider)) { "${Feature.TrustedSwapInProvider.rfcName} has been deprecated" } + require(!features.hasFeature(Feature.PayToOpenClient)) { "${Feature.PayToOpenClient.rfcName} has been deprecated" } + require(!features.hasFeature(Feature.PayToOpenProvider)) { "${Feature.PayToOpenProvider.rfcName} has been deprecated" } Features.validateFeatureGraph(features) } @@ -193,15 +196,15 @@ data class NodeParams( Feature.RouteBlinding to FeatureSupport.Optional, Feature.DualFunding to FeatureSupport.Mandatory, Feature.ShutdownAnySegwit to FeatureSupport.Mandatory, + Feature.Quiescence to FeatureSupport.Mandatory, Feature.ChannelType to FeatureSupport.Mandatory, Feature.PaymentMetadata to FeatureSupport.Optional, Feature.ExperimentalTrampolinePayment to FeatureSupport.Optional, Feature.ZeroReserveChannels to FeatureSupport.Optional, Feature.WakeUpNotificationClient to FeatureSupport.Optional, - Feature.PayToOpenClient to FeatureSupport.Optional, Feature.ChannelBackupClient to FeatureSupport.Optional, Feature.ExperimentalSplice to FeatureSupport.Optional, - Feature.Quiescence to FeatureSupport.Mandatory + Feature.OnTheFlyFunding to FeatureSupport.Optional, ), dustLimit = 546.sat, maxRemoteDustLimit = 600.sat, @@ -229,7 +232,7 @@ data class NodeParams( maxPaymentAttempts = 5, zeroConfPeers = emptySet(), paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(75), CltvExpiryDelta(200)), - liquidityPolicy = MutableStateFlow(LiquidityPolicy.Auto(maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false)), + liquidityPolicy = MutableStateFlow(LiquidityPolicy.Auto(inboundLiquidityTarget = null, maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false)), minFinalCltvExpiryDelta = Bolt11Invoice.DEFAULT_MIN_FINAL_EXPIRY_DELTA, maxFinalCltvExpiryDelta = CltvExpiryDelta(360), bolt12invoiceExpiry = 60.seconds, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManager.kt b/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManager.kt index 6f9737c23..8d3581dcd 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManager.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManager.kt @@ -2,21 +2,18 @@ package fr.acinq.lightning.blockchain.electrum import fr.acinq.bitcoin.OutPoint import fr.acinq.bitcoin.Transaction -import fr.acinq.bitcoin.TxId -import fr.acinq.lightning.Lightning import fr.acinq.lightning.SwapInParams import fr.acinq.lightning.channel.FundingContributions.Companion.stripInputWitnesses import fr.acinq.lightning.channel.LocalFundingStatus import fr.acinq.lightning.channel.RbfStatus -import fr.acinq.lightning.channel.SignedSharedTransaction import fr.acinq.lightning.channel.SpliceStatus import fr.acinq.lightning.channel.states.* -import fr.acinq.lightning.io.RequestChannelOpen +import fr.acinq.lightning.io.AddWalletInputsToChannel import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.utils.sat internal sealed class SwapInCommand { - data class TrySwapIn(val currentBlockHeight: Int, val wallet: WalletState, val swapInParams: SwapInParams, val trustedTxs: Set) : SwapInCommand() + data class TrySwapIn(val currentBlockHeight: Int, val wallet: WalletState, val swapInParams: SwapInParams) : SwapInCommand() data class UnlockWalletInputs(val inputs: Set) : SwapInCommand() } @@ -33,20 +30,15 @@ internal sealed class SwapInCommand { class SwapInManager(private var reservedUtxos: Set, private val logger: MDCLogger) { constructor(bootChannels: List, logger: MDCLogger) : this(reservedWalletInputs(bootChannels), logger) - internal fun process(cmd: SwapInCommand): RequestChannelOpen? = when (cmd) { + internal fun process(cmd: SwapInCommand): AddWalletInputsToChannel? = when (cmd) { is SwapInCommand.TrySwapIn -> { val availableWallet = cmd.wallet.withoutReservedUtxos(reservedUtxos).withConfirmations(cmd.currentBlockHeight, cmd.swapInParams) logger.info { "swap-in wallet balance: deeplyConfirmed=${availableWallet.deeplyConfirmed.balance}, weaklyConfirmed=${availableWallet.weaklyConfirmed.balance}, unconfirmed=${availableWallet.unconfirmed.balance}" } - val utxos = buildSet { - // some utxos may be used for swap-in even if they are not confirmed, for example when migrating from the legacy phoenix android app - addAll(availableWallet.unconfirmed.filter { cmd.trustedTxs.contains(it.outPoint.txid) }) - addAll(availableWallet.weaklyConfirmed.filter { cmd.trustedTxs.contains(it.outPoint.txid) }) - addAll(availableWallet.deeplyConfirmed.filter { Transaction.write(it.previousTx.stripInputWitnesses()).size < 65_000 }) - }.toList() + val utxos = availableWallet.deeplyConfirmed.filter { Transaction.write(it.previousTx.stripInputWitnesses()).size < 65_000 } if (utxos.balance > 0.sat) { logger.info { "swap-in wallet: requesting channel using ${utxos.size} utxos with balance=${utxos.balance}" } reservedUtxos = reservedUtxos.union(utxos.map { it.outPoint }) - RequestChannelOpen(Lightning.randomBytes32(), utxos) + AddWalletInputsToChannel(utxos) } else { null } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt index 6b2d5aa08..e7bb9be6f 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt @@ -1,14 +1,15 @@ package fr.acinq.lightning.channel import fr.acinq.bitcoin.* -import fr.acinq.lightning.ChannelEvents import fr.acinq.lightning.CltvExpiry import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.NodeEvents import fr.acinq.lightning.blockchain.Watch import fr.acinq.lightning.channel.states.PersistedChannelState import fr.acinq.lightning.db.ChannelClosingType import fr.acinq.lightning.transactions.Transactions import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.toMilliSatoshi import fr.acinq.lightning.wire.* /** Channel Actions (outputs produced by the state machine). */ @@ -78,8 +79,8 @@ sealed class ChannelAction { abstract val origin: Origin? abstract val txId: TxId abstract val localInputs: Set - 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() - data class ViaSpliceIn(val amount: MilliSatoshi, val serviceFee: MilliSatoshi, val miningFee: Satoshi, override val localInputs: Set, override val txId: TxId, override val origin: Origin.PayToOpenOrigin?) : StoreIncomingPayment() + data class ViaNewChannel(val amountReceived: MilliSatoshi, val serviceFee: MilliSatoshi, val miningFee: Satoshi, override val localInputs: Set, override val txId: TxId, override val origin: Origin?) : StoreIncomingPayment() + data class ViaSpliceIn(val amountReceived: MilliSatoshi, val serviceFee: MilliSatoshi, val miningFee: Satoshi, override val localInputs: Set, override val txId: TxId, override val origin: Origin?) : StoreIncomingPayment() } /** Payment sent through on-chain operations (channel close or splice-out) */ sealed class StoreOutgoingPayment : Storage() { @@ -87,7 +88,7 @@ sealed class ChannelAction { abstract val txId: TxId data class ViaSpliceOut(val amount: Satoshi, override val miningFees: Satoshi, val address: String, override val txId: TxId) : StoreOutgoingPayment() data class ViaSpliceCpfp(override val miningFees: Satoshi, override val txId: TxId) : StoreOutgoingPayment() - data class ViaInboundLiquidityRequest(override val txId: TxId, override val miningFees: Satoshi, val lease: LiquidityAds.Lease) : StoreOutgoingPayment() + data class ViaInboundLiquidityRequest(override val txId: TxId, override val miningFees: Satoshi, val purchase: LiquidityAds.Purchase) : StoreOutgoingPayment() data class ViaClose(val amount: Satoshi, override val miningFees: Satoshi, val address: String, override val txId: TxId, val isSentToDefaultAddress: Boolean, val closingType: ChannelClosingType) : StoreOutgoingPayment() } data class SetLocked(val txId: TxId) : Storage() @@ -128,8 +129,8 @@ sealed class ChannelAction { } } - data class EmitEvent(val event: ChannelEvents) : ChannelAction() + data class EmitEvent(val event: NodeEvents) : ChannelAction() - object Disconnect : ChannelAction() + data object Disconnect : ChannelAction() // @formatter:on } \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt index 9ec5ebf68..25e2a90e3 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt @@ -32,10 +32,11 @@ sealed class ChannelCommand { val fundingTxFeerate: FeeratePerKw, val localParams: LocalParams, val remoteInit: InitMessage, - val channelFlags: Byte, + val channelFlags: ChannelFlags, val channelConfig: ChannelConfig, val channelType: ChannelType.SupportedChannelType, - val channelOrigin: Origin? = null + val requestRemoteFunding: LiquidityAds.RequestFunding?, + val channelOrigin: Origin?, ) : Init() { fun temporaryChannelId(keyManager: KeyManager): ByteVector32 = keyManager.channelKeys(localParams.fundingKeyPath).temporaryChannelId } @@ -47,7 +48,8 @@ sealed class ChannelCommand { val walletInputs: List, val localParams: LocalParams, val channelConfig: ChannelConfig, - val remoteInit: InitMessage + val remoteInit: InitMessage, + val fundingRates: LiquidityAds.WillFundRates? ) : Init() data class Restore(val state: PersistedChannelState) : Init() @@ -85,7 +87,7 @@ sealed class ChannelCommand { data class UpdateFee(val feerate: FeeratePerKw, val commit: Boolean = false) : Commitment(), ForbiddenDuringSplice, ForbiddenDuringQuiescence data object CheckHtlcTimeout : Commitment() sealed class Splice : Commitment() { - data class Request(val replyTo: CompletableDeferred, val spliceIn: SpliceIn?, val spliceOut: SpliceOut?, val requestRemoteFunding: LiquidityAds.RequestRemoteFunding?, val feerate: FeeratePerKw, val origins: List = emptyList()) : Splice() { + data class Request(val replyTo: CompletableDeferred, val spliceIn: SpliceIn?, val spliceOut: SpliceOut?, val requestRemoteFunding: LiquidityAds.RequestFunding?, val feerate: FeeratePerKw, val origins: List) : Splice() { val pushAmount: MilliSatoshi = spliceIn?.pushAmount ?: 0.msat val spliceOutputs: List = spliceOut?.let { listOf(TxOut(it.amount, it.scriptPubKey)) } ?: emptyList() @@ -104,7 +106,7 @@ sealed class ChannelCommand { val fundingTxId: TxId, val capacity: Satoshi, val balance: MilliSatoshi, - val liquidityLease: LiquidityAds.Lease?, + val liquidityPurchase: LiquidityAds.Purchase?, ) : Response() sealed class Failure : Response() { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt index 6bea9342b..62f3eabd9 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt @@ -12,6 +12,7 @@ import fr.acinq.lightning.crypto.KeyManager import fr.acinq.lightning.logging.LoggingContext import fr.acinq.lightning.transactions.Scripts import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.* +import fr.acinq.lightning.utils.toMilliSatoshi import fr.acinq.lightning.wire.ClosingSigned /** @@ -350,23 +351,29 @@ data class LocalParams( val htlcMinimum: MilliSatoshi, val toSelfDelay: CltvExpiryDelta, val maxAcceptedHtlcs: Int, - val isInitiator: Boolean, + val isChannelOpener: Boolean, + val paysCommitTxFees: Boolean, val defaultFinalScriptPubKey: ByteVector, val features: Features ) { - constructor(nodeParams: NodeParams, isInitiator: Boolean) : this( + constructor(nodeParams: NodeParams, isChannelOpener: Boolean, payCommitTxFees: Boolean) : this( nodeId = nodeParams.nodeId, - fundingKeyPath = nodeParams.keyManager.newFundingKeyPath(isInitiator), // we make sure that initiator and non-initiator key path end differently + fundingKeyPath = nodeParams.keyManager.newFundingKeyPath(isChannelOpener), // we make sure that initiator and non-initiator key path end differently dustLimit = nodeParams.dustLimit, maxHtlcValueInFlightMsat = nodeParams.maxHtlcValueInFlightMsat, htlcMinimum = nodeParams.htlcMinimum, toSelfDelay = nodeParams.toRemoteDelayBlocks, // we choose their delay maxAcceptedHtlcs = nodeParams.maxAcceptedHtlcs, - isInitiator = isInitiator, + isChannelOpener = isChannelOpener, + paysCommitTxFees = payCommitTxFees, defaultFinalScriptPubKey = nodeParams.keyManager.finalOnChainWallet.pubkeyScript(addressIndex = 0), // the default closing address is the same for all channels features = nodeParams.features.initFeatures() ) + // The node responsible for the commit tx fees is also the node paying the mutual close fees. + // The other node's balance may be empty, which wouldn't allow them to pay the closing fees. + val paysClosingFees: Boolean = paysCommitTxFees + fun channelKeys(keyManager: KeyManager) = keyManager.channelKeys(fundingKeyPath) } @@ -384,10 +391,11 @@ data class RemoteParams( val features: Features ) -object ChannelFlags { - const val AnnounceChannel = 0x01.toByte() - const val Empty = 0x00.toByte() -} +/** + * The [nonInitiatorPaysCommitFees] parameter can be set to true when the sender wants the receiver to pay the commitment transaction fees. + * This is not part of the BOLTs and won't be needed anymore once commitment transactions don't pay any on-chain fees. + */ +data class ChannelFlags(val announceChannel: Boolean, val nonInitiatorPaysCommitFees: Boolean) data class ClosingTxProposed(val unsignedTx: ClosingTx, val localClosingSigned: ClosingSigned) @@ -399,13 +407,19 @@ data class ChannelManagementFees(val miningFee: Satoshi, val serviceFee: Satoshi val total: Satoshi = miningFee + serviceFee } -/** Reason for creating a new channel or a splice. */ +/** Reason for creating a new channel or splicing into an existing channel. */ // @formatter:off sealed class Origin { - abstract val amount: MilliSatoshi - abstract val serviceFee: MilliSatoshi - abstract val miningFee: Satoshi - data class PayToOpenOrigin(val paymentHash: ByteVector32, override val serviceFee: MilliSatoshi, override val miningFee: Satoshi, override val amount: MilliSatoshi) : Origin() - data class PleaseOpenChannelOrigin(val requestId: ByteVector32, override val serviceFee: MilliSatoshi, override val miningFee: Satoshi, override val amount: MilliSatoshi) : Origin() + /** Amount of the origin payment, before fees are paid. */ + abstract val amountBeforeFees: MilliSatoshi + /** Fees applied for the channel funding transaction. */ + abstract val fees: ChannelManagementFees + + fun amountReceived(): MilliSatoshi = amountBeforeFees - fees.total.toMilliSatoshi() + + data class OnChainWallet(val inputs: Set, override val amountBeforeFees: MilliSatoshi, override val fees: ChannelManagementFees) : Origin() + data class OffChainPayment(val paymentPreimage: ByteVector32, override val amountBeforeFees: MilliSatoshi, override val fees: ChannelManagementFees) : Origin() { + val paymentHash: ByteVector32 = Crypto.sha256(paymentPreimage).byteVector32() + } } // @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 d6f3f1724..0c28e038c 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt @@ -28,7 +28,9 @@ data class ToSelfDelayTooHigh (override val channelId: Byte data class MissingLiquidityAds (override val channelId: ByteVector32) : ChannelException(channelId, "liquidity ads field is missing") data class InvalidLiquidityAdsSig (override val channelId: ByteVector32) : ChannelException(channelId, "liquidity ads signature is invalid") data class InvalidLiquidityAdsAmount (override val channelId: ByteVector32, val proposed: Satoshi, val min: Satoshi) : ChannelException(channelId, "liquidity ads funding amount is too low (expected at least $min, got $proposed)") -data class InvalidLiquidityRates (override val channelId: ByteVector32) : ChannelException(channelId, "rejecting liquidity ads proposed rates") +data class InvalidLiquidityAdsRate (override val channelId: ByteVector32) : ChannelException(channelId, "liquidity ads funding rate does not match the rate we selected") +data class UnexpectedLiquidityAdsFundingFee (override val channelId: ByteVector32, val fundingTxId: TxId) : ChannelException(channelId, "unexpected liquidity ads funding fee for txId=$fundingTxId (transaction not found)") +data class InvalidLiquidityAdsFundingFee (override val channelId: ByteVector32, val fundingTxId: TxId, val paymentHash: ByteVector32, val expected: Satoshi, val proposed: MilliSatoshi) : ChannelException(channelId, "invalid liquidity ads funding fee for txId=$fundingTxId and paymentHash=$paymentHash (expected $expected, got $proposed)") data class ChannelFundingError (override val channelId: ByteVector32) : ChannelException(channelId, "channel funding error") data class RbfAttemptAborted (override val channelId: ByteVector32) : ChannelException(channelId, "rbf attempt aborted") data class SpliceAborted (override val channelId: ByteVector32) : ChannelException(channelId, "splice aborted") diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt index c1b728c9b..a094f456c 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt @@ -40,7 +40,7 @@ data class ChannelParams( val channelConfig: ChannelConfig, val channelFeatures: ChannelFeatures, val localParams: LocalParams, val remoteParams: RemoteParams, - val channelFlags: Byte + val channelFlags: ChannelFlags ) { init { require(channelConfig.hasOption(ChannelConfigOption.FundingPubKeyBasedChannelKeyPath)) { "FundingPubKeyBasedChannelKeyPath option must be enabled" } @@ -252,7 +252,7 @@ data class Commitment( val remoteCommit1 = nextRemoteCommit?.commit ?: remoteCommit val reduced = CommitmentSpec.reduce(remoteCommit1.spec, changes.remoteChanges.acked, changes.localChanges.proposed) val balanceNoFees = (reduced.toRemote - localChannelReserve(params).toMilliSatoshi()).coerceAtLeast(0.msat) - return if (params.localParams.isInitiator) { + return if (params.localParams.paysCommitTxFees) { // The initiator always pays the on-chain fees, so we must subtract that from the amount we can send. val commitFees = commitTxFeeMsat(params.remoteParams.dustLimit, reduced) // the initiator needs to keep a "initiator fee buffer" (see explanation above) @@ -278,7 +278,7 @@ data class Commitment( fun availableBalanceForReceive(params: ChannelParams, changes: CommitmentChanges): MilliSatoshi { val reduced = CommitmentSpec.reduce(localCommit.spec, changes.localChanges.acked, changes.remoteChanges.proposed) val balanceNoFees = (reduced.toRemote - remoteChannelReserve(params).toMilliSatoshi()).coerceAtLeast(0.msat) - return if (params.localParams.isInitiator) { + return if (params.localParams.paysCommitTxFees) { // The non-initiator doesn't pay on-chain fees so we don't take those into account when receiving. balanceNoFees } else { @@ -357,7 +357,7 @@ data class Commitment( val initiatorFeeBuffer = commitTxFeeMsat(params.remoteParams.dustLimit, reduced.copy(feerate = reduced.feerate * 2)) + htlcOutputFee(reduced.feerate * 2) // NB: increasing the feerate can actually remove htlcs from the commit tx (if they fall below the trim threshold) // which may result in a lower commit tx fee; this is why we take the max of the two. - val missingForSender = reduced.toRemote - localChannelReserve(params).toMilliSatoshi() - (if (params.localParams.isInitiator) fees.toMilliSatoshi().coerceAtLeast(initiatorFeeBuffer) else 0.msat) + val missingForSender = reduced.toRemote - localChannelReserve(params).toMilliSatoshi() - (if (params.localParams.paysCommitTxFees) fees.toMilliSatoshi().coerceAtLeast(initiatorFeeBuffer) else 0.msat) // According to BOLT 2, we should also subtract the channel reserve from the calculation below. // But this creates issues with splicing in the following scenario: // - Alice opened a channel to Bob, and her balance is slightly above the reserve @@ -366,12 +366,12 @@ data class Commitment( // - The liquidity is mostly on Bob's side, but since he's unable to send HTLCs the channel is stuck // We instead only check that the channel initiator is able to pay the fees for the commit tx. // We are sending an outgoing HTLC, so once it's fulfilled it will increase their balance which is good for the channel reserve. - val missingForReceiver = reduced.toLocal - (if (params.localParams.isInitiator) 0.msat else fees.toMilliSatoshi()) + val missingForReceiver = reduced.toLocal - (if (params.localParams.paysCommitTxFees) 0.msat else fees.toMilliSatoshi()) if (missingForSender < 0.msat) { - val actualFees = if (params.localParams.isInitiator) fees else 0.sat + val actualFees = if (params.localParams.paysCommitTxFees) fees else 0.sat return Either.Left(InsufficientFunds(params.channelId, amount, -missingForSender.truncateToSatoshi(), localChannelReserve(params), actualFees)) } else if (missingForReceiver < 0.msat) { - if (params.localParams.isInitiator) { + if (params.localParams.paysCommitTxFees) { // receiver is not the initiator; it is ok if it can't maintain its channel_reserve for now, as long as its balance is increasing, which is the case if it is receiving a payment } else { return Either.Left(RemoteCannotAffordFeesForNewHtlc(params.channelId, amount = amount, missing = -missingForReceiver.truncateToSatoshi(), fees = fees)) @@ -406,14 +406,14 @@ data class Commitment( val fees = commitTxFee(params.localParams.dustLimit, reduced) // NB: we don't enforce the initiatorFeeReserve (see sendAdd) because it would confuse a remote initiator that doesn't have this mitigation in place // We could enforce it once we're confident a large portion of the network implements it. - val missingForSender = reduced.toRemote - remoteChannelReserve(params).toMilliSatoshi() - (if (params.localParams.isInitiator) 0.sat else fees).toMilliSatoshi() + val missingForSender = reduced.toRemote - remoteChannelReserve(params).toMilliSatoshi() - (if (params.localParams.paysCommitTxFees) 0.sat else fees).toMilliSatoshi() // We diverge from Bolt 2 and don't subtract the channel reserve: see `canSendAdd` for details. - val missingForReceiver = reduced.toLocal - (if (params.localParams.isInitiator) fees else 0.sat).toMilliSatoshi() + val missingForReceiver = reduced.toLocal - (if (params.localParams.paysCommitTxFees) fees else 0.sat).toMilliSatoshi() if (missingForSender < 0.sat) { - val actualFees = if (params.localParams.isInitiator) 0.sat else fees + val actualFees = if (params.localParams.paysCommitTxFees) 0.sat else fees return Either.Left(InsufficientFunds(params.channelId, amount, -missingForSender.truncateToSatoshi(), remoteChannelReserve(params), actualFees)) } else if (missingForReceiver < 0.sat) { - if (params.localParams.isInitiator) { + if (params.localParams.paysCommitTxFees) { return Either.Left(CannotAffordFees(params.channelId, missing = -missingForReceiver.truncateToSatoshi(), reserve = localChannelReserve(params), fees = fees)) } else { // receiver is not the initiator; it is ok if it can't maintain its channel_reserve for now, as long as its balance is increasing, which is the case if it is receiving a payment @@ -737,7 +737,7 @@ data class Commitments( } fun sendFee(cmd: ChannelCommand.Commitment.UpdateFee): Either> { - if (!params.localParams.isInitiator) return Either.Left(NonInitiatorCannotSendUpdateFee(channelId)) + if (!params.localParams.paysCommitTxFees) return Either.Left(NonInitiatorCannotSendUpdateFee(channelId)) // let's compute the current commitment *as seen by them* with this change taken into account val fee = UpdateFee(channelId, cmd.feerate) // update_fee replace each other, so we can remove previous ones @@ -747,7 +747,7 @@ data class Commitments( } fun receiveFee(fee: UpdateFee, feerateTolerance: FeerateTolerance): Either { - if (params.localParams.isInitiator) return Either.Left(NonInitiatorCannotSendUpdateFee(channelId)) + if (params.localParams.paysCommitTxFees) return Either.Left(NonInitiatorCannotSendUpdateFee(channelId)) if (fee.feeratePerKw < FeeratePerKw.MinimumFeeratePerKw) return Either.Left(FeerateTooSmall(channelId, remoteFeeratePerKw = fee.feeratePerKw)) if (Helpers.isFeeDiffTooHigh(FeeratePerKw.CommitmentFeerate, fee.feeratePerKw, feerateTolerance)) return Either.Left(FeerateTooDifferent(channelId, FeeratePerKw.CommitmentFeerate, fee.feeratePerKw)) val changes1 = changes.copy(remoteChanges = changes.remoteChanges.copy(proposed = changes.remoteChanges.proposed.filterNot { it is UpdateFee } + fee)) @@ -1031,7 +1031,7 @@ data class Commitments( val outputs = makeCommitTxOutputs( channelKeys.fundingPubKey(fundingTxIndex), remoteFundingPubKey, - localParams.isInitiator, + localParams.paysCommitTxFees, localParams.dustLimit, localRevocationPubkey, remoteParams.toSelfDelay, @@ -1041,7 +1041,7 @@ data class Commitments( remoteHtlcPubkey, spec ) - val commitTx = Transactions.makeCommitTx(commitmentInput, commitTxNumber, localPaymentBasepoint, remoteParams.paymentBasepoint, localParams.isInitiator, outputs) + val commitTx = Transactions.makeCommitTx(commitmentInput, commitTxNumber, localPaymentBasepoint, remoteParams.paymentBasepoint, localParams.isChannelOpener, outputs) val htlcTxs = Transactions.makeHtlcTxs(commitTx.tx, localParams.dustLimit, localRevocationPubkey, remoteParams.toSelfDelay, localDelayedPaymentPubkey, spec.feerate, outputs) return Pair(commitTx, htlcTxs) } @@ -1065,7 +1065,7 @@ data class Commitments( val outputs = makeCommitTxOutputs( remoteFundingPubKey, channelKeys.fundingPubKey(fundingTxIndex), - !localParams.isInitiator, + !localParams.paysCommitTxFees, remoteParams.dustLimit, remoteRevocationPubkey, localParams.toSelfDelay, @@ -1076,7 +1076,7 @@ data class Commitments( spec ) // NB: we are creating the remote commit tx, so local/remote parameters are inverted. - val commitTx = Transactions.makeCommitTx(commitmentInput, commitTxNumber, remoteParams.paymentBasepoint, localPaymentPubkey, !localParams.isInitiator, outputs) + val commitTx = Transactions.makeCommitTx(commitmentInput, commitTxNumber, remoteParams.paymentBasepoint, localPaymentPubkey, !localParams.isChannelOpener, outputs) val htlcTxs = Transactions.makeHtlcTxs(commitTx.tx, remoteParams.dustLimit, remoteRevocationPubkey, localParams.toSelfDelay, remoteDelayedPaymentPubkey, spec.feerate, outputs) return Pair(commitTx, htlcTxs) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt index 585991d54..7609cee77 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt @@ -258,8 +258,8 @@ object Helpers { val localSpec = CommitmentSpec(localHtlcs, commitTxFeerate, toLocal = toLocal, toRemote = toRemote) val remoteSpec = CommitmentSpec(localHtlcs.map{ it.opposite() }.toSet(), commitTxFeerate, toLocal = toRemote, toRemote = toLocal) - if (!localParams.isInitiator) { - // They initiated the channel open, therefore they pay the fee: we need to make sure they can afford it! + if (!localParams.paysCommitTxFees) { + // They are responsible for paying the commitment transaction fee: we need to make sure they can afford it! // Note that the reserve may not be always be met: we could be using dual funding with a large funding amount on // our side and a small funding amount on their side. But we shouldn't care as long as they can pay the fees for // the commitment transaction. @@ -324,7 +324,7 @@ object Helpers { private fun firstClosingFee(commitment: FullCommitment, localScriptPubkey: ByteArray, remoteScriptPubkey: ByteArray, requestedFeerate: ClosingFeerates): ClosingFees { // this is just to estimate the weight which depends on the size of the pubkey scripts - val dummyClosingTx = Transactions.makeClosingTx(commitment.commitInput, localScriptPubkey, remoteScriptPubkey, commitment.params.localParams.isInitiator, Satoshi(0), Satoshi(0), commitment.localCommit.spec) + val dummyClosingTx = Transactions.makeClosingTx(commitment.commitInput, localScriptPubkey, remoteScriptPubkey, commitment.params.localParams.paysClosingFees, 0.sat, 0.sat, commitment.localCommit.spec) val closingWeight = Transaction.weight(Transactions.addSigs(dummyClosingTx, dummyPublicKey, commitment.remoteFundingPubkey, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig).tx) return requestedFeerate.computeFees(closingWeight) } @@ -356,7 +356,7 @@ object Helpers { require(isValidFinalScriptPubkey(localScriptPubkey, allowAnySegwit)) { "invalid localScriptPubkey" } require(isValidFinalScriptPubkey(remoteScriptPubkey, allowAnySegwit)) { "invalid remoteScriptPubkey" } val dustLimit = commitment.params.localParams.dustLimit.max(commitment.params.remoteParams.dustLimit) - val closingTx = Transactions.makeClosingTx(commitment.commitInput, localScriptPubkey, remoteScriptPubkey, commitment.params.localParams.isInitiator, dustLimit, closingFees.preferred, commitment.localCommit.spec) + val closingTx = Transactions.makeClosingTx(commitment.commitInput, localScriptPubkey, remoteScriptPubkey, commitment.params.localParams.paysClosingFees, dustLimit, closingFees.preferred, commitment.localCommit.spec) val localClosingSig = Transactions.sign(closingTx, channelKeys.fundingKey(commitment.fundingTxIndex)) val closingSigned = ClosingSigned(commitment.channelId, closingFees.preferred, localClosingSig, TlvStream(ClosingSignedTlv.FeeRange(closingFees.min, closingFees.max))) return Pair(closingTx, closingSigned) @@ -510,7 +510,7 @@ object Helpers { val outputs = makeCommitTxOutputs( commitment.remoteFundingPubkey, channelKeys.fundingPubKey(commitment.fundingTxIndex), - !localParams.isInitiator, + !localParams.paysCommitTxFees, remoteParams.dustLimit, remoteRevocationPubkey, localParams.toSelfDelay, @@ -624,7 +624,7 @@ object Helpers { val obscuredTxNumber = Transactions.decodeTxNumber(sequence, tx.lockTime) val localPaymentPoint = channelKeys.paymentBasepoint // this tx has been published by remote, so we need to invert local/remote params - val commitmentNumber = Transactions.obscuredCommitTxNumber(obscuredTxNumber, !params.localParams.isInitiator, params.remoteParams.paymentBasepoint, localPaymentPoint) + val commitmentNumber = Transactions.obscuredCommitTxNumber(obscuredTxNumber, !params.localParams.isChannelOpener, params.remoteParams.paymentBasepoint, localPaymentPoint) if (commitmentNumber > 0xffffffffffffL) { // txNumber must be lesser than 48 bits long return null diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt index d077dc252..4ff07bdfd 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt @@ -5,22 +5,17 @@ import fr.acinq.bitcoin.Script.tail import fr.acinq.bitcoin.crypto.musig2.IndividualNonce import fr.acinq.bitcoin.crypto.musig2.Musig2 import fr.acinq.bitcoin.crypto.musig2.SecretNonce -import fr.acinq.bitcoin.utils.getOrDefault -import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.bitcoin.utils.Either import fr.acinq.bitcoin.utils.Try +import fr.acinq.bitcoin.utils.getOrDefault import fr.acinq.bitcoin.utils.runTrying +import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.blockchain.electrum.WalletState import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.crypto.Bolt3Derivation.deriveForCommitment import fr.acinq.lightning.crypto.KeyManager -import fr.acinq.lightning.logging.* -import fr.acinq.lightning.transactions.CommitmentSpec -import fr.acinq.lightning.transactions.DirectedHtlc -import fr.acinq.lightning.transactions.Scripts -import fr.acinq.lightning.transactions.SwapInProtocol -import fr.acinq.lightning.transactions.Transactions +import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.transactions.* import fr.acinq.lightning.utils.* import fr.acinq.lightning.wire.* @@ -227,7 +222,6 @@ sealed class FundingContributionFailure { data class InputBelowDust(val txId: TxId, val outputIndex: Int, val amount: Satoshi, val dustLimit: Satoshi) : FundingContributionFailure() { override fun toString(): String = "invalid input $txId:$outputIndex (below dust: amount=$amount, dust=$dustLimit)" } data class InputTxTooLarge(val tx: Transaction) : FundingContributionFailure() { override fun toString(): String = "invalid input tx ${tx.txid} (too large)" } data class NotEnoughFunding(val fundingAmount: Satoshi, val nonFundingAmount: Satoshi, val providedAmount: Satoshi) : FundingContributionFailure() { override fun toString(): String = "not enough funds provided (expected at least $fundingAmount + $nonFundingAmount, got $providedAmount)" } - data class NotEnoughFees(val currentFees: Satoshi, val expectedFees: Satoshi) : FundingContributionFailure() { override fun toString(): String = "not enough funds to pay fees (expected at least $expectedFees, got $currentFees)" } data class InvalidFundingBalances(val fundingAmount: Satoshi, val localBalance: MilliSatoshi, val remoteBalance: MilliSatoshi) : FundingContributionFailure() { override fun toString(): String = "invalid balances funding_amount=$fundingAmount local=$localBalance remote=$remoteBalance" } // @formatter:on } @@ -239,7 +233,14 @@ data class FundingContributions(val inputs: List, v fun computeSpliceContribution(isInitiator: Boolean, commitment: Commitment, walletInputs: List, localOutputs: List, targetFeerate: FeeratePerKw): Satoshi { val weight = computeWeightPaid(isInitiator, commitment, walletInputs, localOutputs) val fees = Transactions.weight2fee(targetFeerate, weight) - return walletInputs.map { it.amount }.sum() - localOutputs.map { it.amount }.sum() - fees + return when { + // When buying inbound liquidity, we may not have enough funds in our current balance to pay on-chain fees. + // The maximum amount we can use for on-chain fees is our current balance, which is fine because: + // - this will simply result in a splice transaction with a lower feerate than expected + // - liquidity fees will be paid later from future HTLCs relayed to us + walletInputs.isEmpty() && localOutputs.isEmpty() -> -(fees.min(commitment.localCommit.spec.toLocal.truncateToSatoshi())) + else -> walletInputs.map { it.amount }.sum() - localOutputs.map { it.amount }.sum() - fees + } } /** @@ -276,27 +277,19 @@ data class FundingContributions(val inputs: List, v return Either.Left(FundingContributionFailure.NotEnoughFunding(params.localContribution, localOutputs.map { it.amount }.sum(), totalAmountIn)) } - // We compute the fees that we should pay in the shared transaction. - val fundingPubkeyScript = params.fundingPubkeyScript(channelKeys) - val weightWithoutChange = computeWeightPaid(params.isInitiator, sharedUtxo?.first, fundingPubkeyScript, walletInputs, localOutputs) - val weightWithChange = computeWeightPaid(params.isInitiator, sharedUtxo?.first, fundingPubkeyScript, walletInputs, localOutputs + listOf(TxOut(0.sat, Script.pay2wpkh(Transactions.PlaceHolderPubKey)))) - val feesWithoutChange = totalAmountIn - totalAmountOut - // If we're not the initiator, we don't return an error when we're unable to meet the desired feerate. - if (params.isInitiator && feesWithoutChange < Transactions.weight2fee(params.targetFeerate, weightWithoutChange)) { - return Either.Left(FundingContributionFailure.NotEnoughFees(feesWithoutChange, Transactions.weight2fee(params.targetFeerate, weightWithoutChange))) - } - val nextLocalBalance = (sharedUtxo?.second?.toLocal ?: 0.msat) + params.localContribution.toMilliSatoshi() val nextRemoteBalance = (sharedUtxo?.second?.toRemote ?: 0.msat) + params.remoteContribution.toMilliSatoshi() if (nextLocalBalance < 0.msat || nextRemoteBalance < 0.msat) { return Either.Left(FundingContributionFailure.InvalidFundingBalances(params.fundingAmount, nextLocalBalance, nextRemoteBalance)) } + val fundingPubkeyScript = params.fundingPubkeyScript(channelKeys) val sharedOutput = listOf(InteractiveTxOutput.Shared(0, fundingPubkeyScript, nextLocalBalance, nextRemoteBalance, sharedUtxo?.second?.toHtlcs ?: 0.msat)) val nonChangeOutputs = localOutputs.map { o -> InteractiveTxOutput.Local.NonChange(0, o.amount, o.publicKeyScript) } val changeOutput = when (changePubKey) { null -> listOf() else -> { + val weightWithChange = computeWeightPaid(params.isInitiator, sharedUtxo?.first, fundingPubkeyScript, walletInputs, localOutputs + listOf(TxOut(0.sat, Script.pay2wpkh(Transactions.PlaceHolderPubKey)))) val changeAmount = totalAmountIn - totalAmountOut - Transactions.weight2fee(params.targetFeerate, weightWithChange) if (params.dustLimit <= changeAmount) { listOf(InteractiveTxOutput.Local.Change(0, changeAmount, Script.write(Script.pay2wpkh(changePubKey)).byteVector())) @@ -339,7 +332,7 @@ data class FundingContributions(val inputs: List, v fun Transaction.stripInputWitnesses(): Transaction = copy(txIn = txIn.map { it.updateWitness(ScriptWitness.empty) }) /** Compute the weight we need to pay on-chain fees for. */ - private fun computeWeightPaid(isInitiator: Boolean, sharedInput: SharedFundingInput?, sharedOutputScript: ByteVector, walletInputs: List, localOutputs: List): Int { + fun computeWeightPaid(isInitiator: Boolean, sharedInput: SharedFundingInput?, sharedOutputScript: ByteVector, walletInputs: List, localOutputs: List): Int { val walletInputsWeight = weight(walletInputs) val localOutputsWeight = localOutputs.sumOf { it.weight() } return if (isInitiator) { @@ -673,8 +666,7 @@ data class InteractiveTxSession( val isComplete: Boolean = txCompleteSent != null && txCompleteReceived != null fun send(): Pair { - val msg = toSend.firstOrNull() - return when (msg) { + return when (val msg = toSend.firstOrNull()) { null -> { val localSwapIns = localInputs.filterIsInstance() val remoteSwapIns = remoteInputs.filterIsInstance() @@ -941,8 +933,10 @@ data class InteractiveTxSession( return InteractiveTxSessionAction.InvalidTxFeerate(fundingParams.channelId, tx.txid, fundingParams.targetFeerate, nextFeerate) } } else { + // We allow the feerate to be lower than requested: when using on-the-fly liquidity, we may not be able to contribute + // as much as we expected, but that's fine because we instead overshoot the feerate and pays liquidity fees accordingly. val minimumFee = Transactions.weight2fee(fundingParams.targetFeerate, tx.weight()) - if (sharedTx.fees < minimumFee) { + if (sharedTx.fees < minimumFee * 0.5) { return InteractiveTxSessionAction.InvalidTxFeerate(fundingParams.channelId, tx.txid, fundingParams.targetFeerate, Transactions.fee2rate(sharedTx.fees, tx.weight())) } } @@ -987,7 +981,6 @@ data class InteractiveTxSigningSession( val fundingParams: InteractiveTxParams, val fundingTxIndex: Long, val fundingTx: PartiallySignedSharedTransaction, - val liquidityLease: LiquidityAds.Lease?, val localCommit: Either, val remoteCommit: RemoteCommit, ) { @@ -1065,7 +1058,7 @@ data class InteractiveTxSigningSession( sharedTx: SharedTransaction, localPushAmount: MilliSatoshi, remotePushAmount: MilliSatoshi, - liquidityLease: LiquidityAds.Lease?, + liquidityPurchase: LiquidityAds.Purchase?, localCommitmentIndex: Long, remoteCommitmentIndex: Long, commitTxFeerate: FeeratePerKw, @@ -1075,7 +1068,16 @@ data class InteractiveTxSigningSession( val channelKeys = channelParams.localParams.channelKeys(keyManager) val unsignedTx = sharedTx.buildUnsignedTx() val sharedOutputIndex = unsignedTx.txOut.indexOfFirst { it.publicKeyScript == fundingParams.fundingPubkeyScript(channelKeys) } - val liquidityFees = liquidityLease?.fees?.total?.toMilliSatoshi() ?: 0.msat + val liquidityFees = liquidityPurchase?.let { l -> + val fees = l.fees.total.toMilliSatoshi() + when (l.paymentDetails) { + is LiquidityAds.PaymentDetails.FromChannelBalance -> if (fundingParams.isInitiator) fees else -fees + is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> if (fundingParams.isInitiator) fees else -fees + // Fees will be paid later, from relayed HTLCs. + is LiquidityAds.PaymentDetails.FromFutureHtlc -> 0.msat + is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> 0.msat + } + } ?: 0.msat return Helpers.Funding.makeCommitTxs( channelKeys, channelParams.channelId, @@ -1120,7 +1122,7 @@ data class InteractiveTxSigningSession( val unsignedLocalCommit = UnsignedLocalCommit(localCommitmentIndex, firstCommitTx.localSpec, firstCommitTx.localCommitTx, listOf()) val remoteCommit = RemoteCommit(remoteCommitmentIndex, firstCommitTx.remoteSpec, firstCommitTx.remoteCommitTx.tx.txid, remotePerCommitmentPoint) val signedFundingTx = sharedTx.sign(session, keyManager, fundingParams, channelParams.localParams, channelParams.remoteParams.nodeId) - Pair(InteractiveTxSigningSession(fundingParams, fundingTxIndex, signedFundingTx, liquidityLease, Either.Left(unsignedLocalCommit), remoteCommit), commitSig) + Pair(InteractiveTxSigningSession(fundingParams, fundingTxIndex, signedFundingTx, Either.Left(unsignedLocalCommit), remoteCommit), commitSig) } } @@ -1168,7 +1170,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. */ @@ -1177,11 +1179,11 @@ sealed class SpliceStatus { val spliceSession: InteractiveTxSession, val localPushAmount: MilliSatoshi, val remotePushAmount: MilliSatoshi, - val liquidityLease: LiquidityAds.Lease?, - val origins: List + val liquidityPurchase: LiquidityAds.Purchase?, + val origins: List ) : QuiescentSpliceStatus() /** The splice transaction has been negotiated, we're exchanging signatures. */ - data class WaitingForSigs(val session: InteractiveTxSigningSession, val origins: List) : QuiescentSpliceStatus() + data class WaitingForSigs(val session: InteractiveTxSigningSession, val liquidityPurchase: LiquidityAds.Purchase?, val origins: List) : QuiescentSpliceStatus() /** The splice attempt was aborted by us, we're waiting for our peer to ack. */ data object Aborted : QuiescentSpliceStatus() } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt index 1982c8edd..3083829f0 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt @@ -341,7 +341,9 @@ sealed class PersistedChannelState : ChannelState() { sealed class ChannelStateWithCommitments : PersistedChannelState() { abstract val commitments: Commitments override val channelId: ByteVector32 get() = commitments.channelId - val isInitiator: Boolean get() = commitments.params.localParams.isInitiator + val isChannelOpener: Boolean get() = commitments.params.localParams.isChannelOpener + val paysCommitTxFees: Boolean get() = commitments.params.localParams.paysCommitTxFees + val paysClosingFees: Boolean get() = commitments.params.localParams.paysClosingFees val remoteNodeId: PublicKey get() = commitments.remoteNodeId fun ChannelContext.channelKeys(): KeyManager.ChannelKeys = commitments.params.localParams.channelKeys(keyManager) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/LegacyWaitForFundingLocked.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/LegacyWaitForFundingLocked.kt index 338300031..d1963c119 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/LegacyWaitForFundingLocked.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/LegacyWaitForFundingLocked.kt @@ -48,7 +48,6 @@ data class LegacyWaitForFundingLocked( null, null, SpliceStatus.None, - listOf(), ) val actions = listOf( ChannelAction.Storage.StoreState(nextState), diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Negotiating.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Negotiating.kt index 5aea88133..321f2062a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Negotiating.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Negotiating.kt @@ -23,7 +23,7 @@ data class Negotiating( ) : ChannelStateWithCommitments() { init { require(closingTxProposed.isNotEmpty()) { "there must always be a list for the current negotiation" } - require(!commitments.params.localParams.isInitiator || !closingTxProposed.any { it.isEmpty() }) { "initiator must have at least one closing signature for every negotiation attempt because it initiates the closing" } + require(!paysClosingFees || !closingTxProposed.any { it.isEmpty() }) { "the node paying the closing fees must have at least one closing signature for every negotiation attempt because it initiates the closing" } } override fun updateCommitments(input: Commitments): ChannelStateWithCommitments = this.copy(commitments = input) @@ -62,8 +62,8 @@ data class Negotiating( val theirFeeRange = cmd.message.tlvStream.get() val ourFeeRange = closingFeerates ?: ClosingFeerates(currentOnChainFeerates().mutualCloseFeerate) when { - theirFeeRange != null && !commitments.params.localParams.isInitiator -> { - // if we are not the initiator and they proposed a fee range, we pick a value in that range and they should accept it without further negotiation + theirFeeRange != null && !paysClosingFees -> { + // if we are not paying the on-chain fees and they proposed a fee range, we pick a value in that range and they should accept it without further negotiation // we don't care much about the closing fee since they're paying it (not us) and we can use CPFP if we want to speed up confirmation val closingFees = Helpers.Closing.firstClosingFee(commitments.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, ourFeeRange) val closingFee = when { 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 c88ec47c9..f2c98a21a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt @@ -4,9 +4,7 @@ import fr.acinq.bitcoin.Bitcoin import fr.acinq.bitcoin.SigHash import fr.acinq.bitcoin.TxId import fr.acinq.bitcoin.utils.Either -import fr.acinq.lightning.Feature -import fr.acinq.lightning.Features -import fr.acinq.lightning.ShortChannelId +import fr.acinq.lightning.* import fr.acinq.lightning.blockchain.BITCOIN_FUNDING_DEPTHOK import fr.acinq.lightning.blockchain.WatchConfirmed import fr.acinq.lightning.blockchain.WatchEventConfirmed @@ -26,7 +24,6 @@ data class Normal( val remoteShutdown: Shutdown?, val closingFeerates: ClosingFeerates?, val spliceStatus: SpliceStatus, - val liquidityLeases: List, ) : ChannelStateWithCommitments() { override fun updateCommitments(input: Commitments): ChannelStateWithCommitments = this.copy(commitments = input) @@ -181,7 +178,7 @@ data class Normal( logger.info { "waiting for tx_sigs" } Pair(this@Normal.copy(spliceStatus = spliceStatus.copy(session = signingSession1)), listOf()) } - is InteractiveTxSigningSessionAction.SendTxSigs -> sendSpliceTxSigs(spliceStatus.origins, action, spliceStatus.session.liquidityLease, cmd.message.channelData) + is InteractiveTxSigningSessionAction.SendTxSigs -> sendSpliceTxSigs(spliceStatus.origins, action, spliceStatus.liquidityPurchase, cmd.message.channelData) } } ignoreRetransmittedCommitSig(cmd.message) -> { @@ -242,7 +239,7 @@ data class Normal( ShuttingDown(commitments1, localShutdown, remoteShutdown, closingFeerates) } else { logger.warning { "we have no htlcs but have not replied with our Shutdown yet, this should never happen" } - val closingTxProposed = if (isInitiator) { + val closingTxProposed = if (paysClosingFees) { val (closingTx, closingSigned) = Helpers.Closing.makeFirstClosingTx( channelKeys(), commitments1.latest, @@ -313,7 +310,7 @@ data class Normal( if (this@Normal.localShutdown == null) actions.add(ChannelAction.Message.Send(localShutdown)) val commitments1 = commitments.copy(remoteChannelData = cmd.message.channelData) when { - commitments1.hasNoPendingHtlcsOrFeeUpdate() && commitments1.params.localParams.isInitiator -> { + commitments1.hasNoPendingHtlcsOrFeeUpdate() && paysClosingFees -> { val (closingTx, closingSigned) = Helpers.Closing.makeFirstClosingTx( channelKeys(), commitments1.latest, @@ -378,7 +375,7 @@ data class Normal( } is SpliceStatus.InitiatorQuiescent -> { // if both sides send stfu at the same time, the quiescence initiator is the channel initiator - if (!cmd.message.initiator || commitments.params.localParams.isInitiator) { + if (!cmd.message.initiator || isChannelOpener) { if (commitments.isQuiescent()) { val parentCommitment = commitments.active.first() val fundingContribution = FundingContributions.computeSpliceContribution( @@ -389,7 +386,7 @@ data class Normal( targetFeerate = spliceStatus.command.feerate ) val commitTxFees = when { - commitments.params.localParams.isInitiator -> Transactions.commitTxFee(commitments.params.remoteParams.dustLimit, parentCommitment.remoteCommit.spec) + paysCommitTxFees -> Transactions.commitTxFee(commitments.params.remoteParams.dustLimit, parentCommitment.remoteCommit.spec) else -> 0.sat } if (parentCommitment.localCommit.spec.toLocal + fundingContribution.toMilliSatoshi() < parentCommitment.localChannelReserve(commitments.params).max(commitTxFees)) { @@ -408,11 +405,15 @@ data class Normal( add(ChannelAction.Disconnect) } Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) - } else if (spliceStatus.command.requestRemoteFunding?.let { r -> r.rate.fees(spliceStatus.command.feerate, r.fundingAmount, r.fundingAmount).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() } == false) { - val missing = spliceStatus.command.requestRemoteFunding.let { r -> r.rate.fees(spliceStatus.command.feerate, r.fundingAmount, r.fundingAmount).total - parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() } + } else if (!canAffordSpliceLiquidityFees(spliceStatus.command, parentCommitment)) { + val missing = spliceStatus.command.requestRemoteFunding?.let { r -> r.fees(spliceStatus.command.feerate, isChannelCreation = false).total - parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() } logger.warning { "cannot do splice: balance is too low to pay for inbound liquidity (missing=$missing)" } spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds) - Pair(this@Normal, emptyList()) + val actions = buildList { + add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceRequest(channelId).message))) + add(ChannelAction.Disconnect) + } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) } else { val spliceInit = SpliceInit( channelId, @@ -421,7 +422,7 @@ data class Normal( feerate = spliceStatus.command.feerate, fundingPubkey = channelKeys().fundingPubKey(parentCommitment.fundingTxIndex + 1), pushAmount = spliceStatus.command.pushAmount, - requestFunds = spliceStatus.command.requestRemoteFunding?.requestFunds, + requestFunding = spliceStatus.command.requestRemoteFunding, ) logger.info { "initiating splice with local.amount=${spliceInit.fundingContribution} local.push=${spliceInit.pushAmount}" } Pair(this@Normal.copy(spliceStatus = SpliceStatus.Requested(spliceStatus.command, spliceInit)), listOf(ChannelAction.Message.Send(spliceInit))) @@ -492,8 +493,8 @@ data class Normal( session, localPushAmount = 0.msat, remotePushAmount = cmd.message.pushAmount, - liquidityLease = null, - origins = cmd.message.origins + liquidityPurchase = null, + origins = listOf() ) ) Pair(nextState, listOf(ChannelAction.Message.Send(spliceAck))) @@ -513,21 +514,22 @@ data class Normal( is SpliceAck -> when (spliceStatus) { is SpliceStatus.Requested -> { logger.info { "our peer accepted our splice request and will contribute ${cmd.message.fundingContribution} to the funding transaction" } - when (val liquidityLease = LiquidityAds.validateLease( + when (val liquidityPurchase = LiquidityAds.validateRemoteFunding( spliceStatus.command.requestRemoteFunding, remoteNodeId, channelId, Helpers.Funding.makeFundingPubKeyScript(spliceStatus.spliceInit.fundingPubkey, cmd.message.fundingPubkey), cmd.message.fundingContribution, spliceStatus.spliceInit.feerate, + isChannelCreation = false, cmd.message.willFund, )) { is Either.Left -> { - logger.error { "rejecting liquidity proposal: ${liquidityLease.value.message}" } - spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InvalidLiquidityAds(liquidityLease.value)) - Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, liquidityLease.value.message)))) + logger.error { "rejecting liquidity proposal: ${liquidityPurchase.value.message}" } + spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InvalidLiquidityAds(liquidityPurchase.value)) + Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, liquidityPurchase.value.message)))) } - is Either.Right -> { + is Either.Right -> { val parentCommitment = commitments.active.first() val sharedInput = SharedFundingInput.Multisig2of2(parentCommitment) val fundingParams = InteractiveTxParams( @@ -566,7 +568,8 @@ data class Normal( previousLocalBalance = parentCommitment.localCommit.spec.toLocal, previousRemoteBalance = parentCommitment.localCommit.spec.toRemote, localHtlcs = parentCommitment.localCommit.spec.htlcs, - fundingContributions.value, previousTxs = emptyList() + fundingContributions = fundingContributions.value, + previousTxs = emptyList() ).send() when (interactiveTxAction) { is InteractiveTxSessionAction.SendMessage -> { @@ -576,8 +579,8 @@ data class Normal( interactiveTxSession, localPushAmount = spliceStatus.spliceInit.pushAmount, remotePushAmount = cmd.message.pushAmount, - liquidityLease = liquidityLease.value, - origins = spliceStatus.spliceInit.origins + liquidityPurchase = liquidityPurchase.value, + origins = spliceStatus.command.origins, ) ) Pair(nextState, listOf(ChannelAction.Message.Send(interactiveTxAction.msg))) @@ -614,7 +617,7 @@ data class Normal( interactiveTxAction.sharedTx, localPushAmount = spliceStatus.localPushAmount, remotePushAmount = spliceStatus.remotePushAmount, - liquidityLease = spliceStatus.liquidityLease, + liquidityPurchase = spliceStatus.liquidityPurchase, localCommitmentIndex = parentCommitment.localCommit.index, remoteCommitmentIndex = parentCommitment.remoteCommit.index, parentCommitment.localCommit.spec.feerate, @@ -640,10 +643,10 @@ data class Normal( fundingTxId = session.fundingTx.txId, capacity = session.fundingParams.fundingAmount, balance = session.localCommit.fold({ it.spec }, { it.spec }).toLocal, - liquidityLease = spliceStatus.liquidityLease, + liquidityPurchase = spliceStatus.liquidityPurchase, ) ) - val nextState = this@Normal.copy(spliceStatus = SpliceStatus.WaitingForSigs(session, spliceStatus.origins)) + val nextState = this@Normal.copy(spliceStatus = SpliceStatus.WaitingForSigs(session, spliceStatus.liquidityPurchase, spliceStatus.origins)) val actions = buildList { interactiveTxAction.txComplete?.let { add(ChannelAction.Message.Send(it)) } add(ChannelAction.Storage.StoreState(nextState)) @@ -675,7 +678,7 @@ data class Normal( } is Either.Right -> { val action: InteractiveTxSigningSessionAction.SendTxSigs = res.value - sendSpliceTxSigs(spliceStatus.origins, action, spliceStatus.session.liquidityLease, cmd.message.channelData) + sendSpliceTxSigs(spliceStatus.origins, action, spliceStatus.liquidityPurchase, cmd.message.channelData) } } } @@ -770,6 +773,17 @@ data class Normal( Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) } } + is CancelOnTheFlyFunding -> when (spliceStatus) { + is SpliceStatus.Requested -> { + logger.info { "our peer rejected our on-the-fly splice request: ascii='${cmd.message.toAscii()}'" } + spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer(cmd.message.toAscii())) + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), endQuiescence()) + } + else -> { + logger.warning { "received unexpected cancel_on_the_fly_funding (spliceStatus=${spliceStatus::class.simpleName}, message='${cmd.message.toAscii()}')" } + Pair(this@Normal, listOf(ChannelAction.Disconnect)) + } + } is SpliceLocked -> { when (val res = commitments.run { updateRemoteFundingStatus(cmd.message.fundingTxId) }) { is Either.Left -> Pair(this@Normal, emptyList()) @@ -841,10 +855,23 @@ data class Normal( } } + private fun canAffordSpliceLiquidityFees(splice: ChannelCommand.Commitment.Splice.Request, parentCommitment: Commitment): Boolean { + return when (val request = splice.requestRemoteFunding) { + null -> true + else -> when (request.paymentDetails) { + is LiquidityAds.PaymentDetails.FromChannelBalance -> request.fees(splice.feerate, isChannelCreation = false).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() + is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> request.fees(splice.feerate, isChannelCreation = false).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() + // Fees don't need to be paid during the splice, they will be deducted from relayed HTLCs. + is LiquidityAds.PaymentDetails.FromFutureHtlc -> true + is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> true + } + } + } + private fun ChannelContext.sendSpliceTxSigs( - origins: List, + origins: List, action: InteractiveTxSigningSessionAction.SendTxSigs, - liquidityLease: LiquidityAds.Lease?, + liquidityPurchase: LiquidityAds.Purchase?, remoteChannelData: EncryptedChannelData ): Pair> { logger.info { "sending tx_sigs" } @@ -852,7 +879,7 @@ data class Normal( val fundingMinDepth = Helpers.minDepthForFunding(staticParams.nodeParams, action.fundingTx.fundingParams.fundingAmount) val watchConfirmed = WatchConfirmed(channelId, action.commitment.fundingTxId, action.commitment.commitInput.txOut.publicKeyScript, fundingMinDepth.toLong(), BITCOIN_FUNDING_DEPTHOK) val commitments = commitments.add(action.commitment).copy(remoteChannelData = remoteChannelData) - val nextState = this@Normal.copy(commitments = commitments, spliceStatus = SpliceStatus.None, liquidityLeases = liquidityLeases + listOfNotNull(liquidityLease)) + val nextState = this@Normal.copy(commitments = commitments, spliceStatus = SpliceStatus.None) val actions = buildList { add(ChannelAction.Storage.StoreState(nextState)) action.fundingTx.signedTx?.let { add(ChannelAction.Blockchain.PublishTx(it, ChannelAction.Blockchain.PublishTx.Type.FundingTx)) } @@ -861,9 +888,9 @@ data class Normal( // If we received or sent funds as part of the splice, we will add a corresponding entry to our incoming/outgoing payments db addAll(origins.map { origin -> ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn( - amount = origin.amount, - serviceFee = origin.serviceFee, - miningFee = origin.miningFee, + amountReceived = origin.amountReceived(), + serviceFee = origin.fees.serviceFee.toMilliSatoshi(), + miningFee = origin.fees.miningFee, localInputs = action.fundingTx.sharedTx.tx.localInputs.map { it.outPoint }.toSet(), txId = action.fundingTx.txId, origin = origin @@ -872,7 +899,7 @@ data class Normal( // 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, + amountReceived = 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(), @@ -892,12 +919,18 @@ data class Normal( 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 -> + liquidityPurchase?.let { purchase -> // 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)) + val miningFees = action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi() + purchase.fees.miningFee + add(ChannelAction.Storage.StoreOutgoingPayment.ViaInboundLiquidityRequest(txId = action.fundingTx.txId, miningFees = miningFees, purchase = purchase)) } + addAll(origins.map { origin -> + when (origin) { + is Origin.OffChainPayment -> ChannelAction.EmitEvent(LiquidityEvents.Accepted(liquidityPurchase?.amount?.toMilliSatoshi() ?: 0.msat, origin.fees.total.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment)) + is Origin.OnChainWallet -> ChannelAction.EmitEvent(SwapInEvents.Accepted(origin.inputs, origin.amountBeforeFees.truncateToSatoshi(), origin.fees)) + } + }) if (staticParams.useZeroConf) { logger.info { "channel is using 0-conf, sending splice_locked right away" } val spliceLocked = SpliceLocked(channelId, action.fundingTx.txId) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/ShuttingDown.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/ShuttingDown.kt index c713de36e..44a0d0d39 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/ShuttingDown.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/ShuttingDown.kt @@ -45,7 +45,7 @@ data class ShuttingDown( is Either.Right -> { val (commitments1, revocation) = result.value when { - commitments1.hasNoPendingHtlcsOrFeeUpdate() && commitments1.params.localParams.isInitiator -> { + commitments1.hasNoPendingHtlcsOrFeeUpdate() && paysClosingFees -> { val (closingTx, closingSigned) = Helpers.Closing.makeFirstClosingTx( channelKeys(), commitments1.latest, @@ -93,7 +93,7 @@ data class ShuttingDown( val (commitments1, actions) = result.value val actions1 = actions.toMutableList() when { - commitments1.hasNoPendingHtlcsOrFeeUpdate() && commitments1.params.localParams.isInitiator -> { + commitments1.hasNoPendingHtlcsOrFeeUpdate() && paysClosingFees -> { val (closingTx, closingSigned) = Helpers.Closing.makeFirstClosingTx( channelKeys(), commitments1.latest, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt index 082029178..a14f9e471 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt @@ -228,7 +228,7 @@ data class Syncing(val state: PersistedChannelState, val channelReestablishSent: // negotiation restarts from the beginning, and is initialized by the initiator // note: in any case we still need to keep all previously sent closing_signed, because they may publish one of them is Negotiating -> - if (state.commitments.params.localParams.isInitiator) { + if (state.paysClosingFees) { // we could use the last closing_signed we sent, but network fees may have changed while we were offline so it is better to restart from scratch val (closingTx, closingSigned) = Helpers.Closing.makeFirstClosingTx( channelKeys(), 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 c00720b28..baced6109 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt @@ -6,9 +6,7 @@ import fr.acinq.lightning.ChannelEvents import fr.acinq.lightning.channel.* import fr.acinq.lightning.channel.Helpers.Funding.computeChannelId import fr.acinq.lightning.utils.msat -import fr.acinq.lightning.wire.AcceptDualFundedChannel -import fr.acinq.lightning.wire.Error -import fr.acinq.lightning.wire.OpenDualFundedChannel +import fr.acinq.lightning.wire.* /* * We initiated a channel open and are waiting for our peer to accept it. @@ -21,7 +19,8 @@ import fr.acinq.lightning.wire.OpenDualFundedChannel */ data class WaitForAcceptChannel( val init: ChannelCommand.Init.Initiator, - val lastSent: OpenDualFundedChannel + val lastSent: OpenDualFundedChannel, + val channelOrigin: Origin?, ) : ChannelState() { val temporaryChannelId: ByteVector32 get() = lastSent.temporaryChannelId @@ -52,40 +51,65 @@ data class WaitForAcceptChannel( val remoteFundingPubkey = accept.fundingPubkey val dustLimit = accept.dustLimit.max(init.localParams.dustLimit) val fundingParams = InteractiveTxParams(channelId, true, init.fundingAmount, accept.fundingAmount, remoteFundingPubkey, lastSent.lockTime, dustLimit, lastSent.fundingFeerate) - when (val fundingContributions = FundingContributions.create(channelKeys, keyManager.swapInOnChainWallet, fundingParams, init.walletInputs)) { + when (val liquidityPurchase = LiquidityAds.validateRemoteFunding( + lastSent.requestFunding, + staticParams.remoteNodeId, + channelId, + fundingParams.fundingPubkeyScript(channelKeys), + accept.fundingAmount, + lastSent.fundingFeerate, + isChannelCreation = true, + accept.willFund + )) { is Either.Left -> { - logger.error { "could not fund channel: ${fundingContributions.value}" } - Pair(Aborted, listOf(ChannelAction.Message.Send(Error(channelId, ChannelFundingError(channelId).message)))) + logger.error { "rejecting liquidity proposal: ${liquidityPurchase.value.message}" } + Pair(Aborted, listOf(ChannelAction.Message.Send(Error(cmd.message.temporaryChannelId, liquidityPurchase.value.message)))) } - is Either.Right -> { - // The channel initiator always sends the first interactive-tx message. - val (interactiveTxSession, interactiveTxAction) = InteractiveTxSession(staticParams.remoteNodeId, channelKeys, keyManager.swapInOnChainWallet, fundingParams, 0.msat, 0.msat, emptySet(), fundingContributions.value).send() - when (interactiveTxAction) { - is InteractiveTxSessionAction.SendMessage -> { - val nextState = WaitForFundingCreated( - init.localParams, - remoteParams, - interactiveTxSession, - lastSent.pushAmount, - accept.pushAmount, - lastSent.commitmentFeerate, - accept.firstPerCommitmentPoint, - accept.secondPerCommitmentPoint, - lastSent.channelFlags, - init.channelConfig, - channelFeatures, - null - ) - val actions = listOf( - ChannelAction.ChannelId.IdAssigned(staticParams.remoteNodeId, temporaryChannelId, channelId), - ChannelAction.Message.Send(interactiveTxAction.msg), - ChannelAction.EmitEvent(ChannelEvents.Creating(nextState)) - ) - Pair(nextState, actions) - } - else -> { - logger.error { "could not start interactive-tx session: $interactiveTxAction" } - Pair(Aborted, listOf(ChannelAction.Message.Send(Error(channelId, ChannelFundingError(channelId).message)))) + is Either.Right -> when (val fundingContributions = FundingContributions.create(channelKeys, keyManager.swapInOnChainWallet, fundingParams, init.walletInputs)) { + is Either.Left -> { + logger.error { "could not fund channel: ${fundingContributions.value}" } + Pair(Aborted, listOf(ChannelAction.Message.Send(Error(channelId, ChannelFundingError(channelId).message)))) + } + is Either.Right -> { + // The channel initiator always sends the first interactive-tx message. + val (interactiveTxSession, interactiveTxAction) = InteractiveTxSession( + staticParams.remoteNodeId, + channelKeys, + keyManager.swapInOnChainWallet, + fundingParams, + 0.msat, + 0.msat, + emptySet(), + fundingContributions.value + ).send() + when (interactiveTxAction) { + is InteractiveTxSessionAction.SendMessage -> { + val nextState = WaitForFundingCreated( + init.localParams, + remoteParams, + interactiveTxSession, + lastSent.pushAmount, + accept.pushAmount, + lastSent.commitmentFeerate, + accept.firstPerCommitmentPoint, + accept.secondPerCommitmentPoint, + lastSent.channelFlags, + init.channelConfig, + channelFeatures, + liquidityPurchase.value, + channelOrigin + ) + val actions = listOf( + ChannelAction.ChannelId.IdAssigned(staticParams.remoteNodeId, temporaryChannelId, channelId), + ChannelAction.Message.Send(interactiveTxAction.msg), + ChannelAction.EmitEvent(ChannelEvents.Creating(nextState)) + ) + Pair(nextState, actions) + } + else -> { + logger.error { "could not start interactive-tx session: $interactiveTxAction" } + Pair(Aborted, listOf(ChannelAction.Message.Send(Error(channelId, ChannelFundingError(channelId).message)))) + } } } } @@ -97,6 +121,11 @@ data class WaitForAcceptChannel( } } } + is CancelOnTheFlyFunding -> { + // Our peer won't accept this on-the-fly funding attempt: they probably already failed the corresponding HTLCs. + logger.warning { "on-the-fly funding was rejected by our peer: ${cmd.message.toAscii()}" } + Pair(Aborted, listOf()) + } is Error -> handleRemoteError(cmd.message) else -> unhandled(cmd) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForChannelReady.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForChannelReady.kt index c5f5aba61..cff67ea29 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForChannelReady.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForChannelReady.kt @@ -81,7 +81,6 @@ data class WaitForChannelReady( null, null, SpliceStatus.None, - listOf(), ) val actions = listOf( ChannelAction.Storage.StoreState(nextState), diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt index 02e092358..a7d9f2ef3 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt @@ -75,7 +75,7 @@ data class WaitForFundingConfirmed( } } is TxInitRbf -> { - if (isInitiator) { + if (isChannelOpener) { logger.info { "rejecting tx_init_rbf, we're the initiator, not them!" } Pair(this@WaitForFundingConfirmed, listOf(ChannelAction.Message.Send(Error(channelId, InvalidRbfNonInitiator(channelId).message)))) } else { @@ -95,7 +95,7 @@ data class WaitForFundingConfirmed( logger.info { "our peer wants to raise the feerate of the funding transaction (previous=${latestFundingTx.fundingParams.targetFeerate} target=${cmd.message.feerate})" } val fundingParams = InteractiveTxParams( channelId, - isInitiator, + isChannelOpener, latestFundingTx.fundingParams.localContribution, // we don't change our funding contribution cmd.message.fundingContribution, latestFundingTx.fundingParams.remoteFundingPubkey, @@ -128,7 +128,7 @@ data class WaitForFundingConfirmed( logger.info { "our peer accepted our rbf attempt and will contribute ${cmd.message.fundingContribution} to the funding transaction" } val fundingParams = InteractiveTxParams( channelId, - isInitiator, + isChannelOpener, rbfStatus.command.fundingAmount, cmd.message.fundingContribution, latestFundingTx.fundingParams.remoteFundingPubkey, @@ -177,7 +177,7 @@ data class WaitForFundingConfirmed( interactiveTxAction.sharedTx, localPushAmount, remotePushAmount, - liquidityLease = null, + liquidityPurchase = null, localCommitmentIndex = replacedCommitment.localCommit.index, remoteCommitmentIndex = replacedCommitment.remoteCommit.index, replacedCommitment.localCommit.spec.feerate, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreated.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreated.kt index 304ba5f8f..41839bb0c 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreated.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreated.kt @@ -38,9 +38,10 @@ data class WaitForFundingCreated( val commitTxFeerate: FeeratePerKw, val remoteFirstPerCommitmentPoint: PublicKey, val remoteSecondPerCommitmentPoint: PublicKey, - val channelFlags: Byte, + val channelFlags: ChannelFlags, val channelConfig: ChannelConfig, val channelFeatures: ChannelFeatures, + val liquidityPurchase: LiquidityAds.Purchase?, val channelOrigin: Origin? ) : ChannelState() { val channelId: ByteVector32 = interactiveTxSession.fundingParams.channelId @@ -63,7 +64,7 @@ data class WaitForFundingCreated( interactiveTxAction.sharedTx, localPushAmount, remotePushAmount, - liquidityLease = null, + liquidityPurchase, localCommitmentIndex = 0, remoteCommitmentIndex = 0, commitTxFeerate, @@ -83,6 +84,7 @@ data class WaitForFundingCreated( localPushAmount, remotePushAmount, remoteSecondPerCommitmentPoint, + liquidityPurchase, channelOrigin ) val actions = buildList { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSigned.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSigned.kt index e1fba47e5..0e32e026c 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSigned.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSigned.kt @@ -4,14 +4,13 @@ import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.crypto.Pack import fr.acinq.bitcoin.utils.Either -import fr.acinq.lightning.ChannelEvents -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.ShortChannelId +import fr.acinq.lightning.* import fr.acinq.lightning.blockchain.BITCOIN_FUNDING_DEPTHOK import fr.acinq.lightning.blockchain.WatchConfirmed import fr.acinq.lightning.channel.* import fr.acinq.lightning.crypto.ShaChain import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.toMilliSatoshi import fr.acinq.lightning.wire.* import kotlin.math.absoluteValue @@ -42,6 +41,7 @@ data class WaitForFundingSigned( val localPushAmount: MilliSatoshi, val remotePushAmount: MilliSatoshi, val remoteSecondPerCommitmentPoint: PublicKey, + val liquidityPurchase: LiquidityAds.Purchase?, val channelOrigin: Origin?, val remoteChannelData: EncryptedChannelData = EncryptedChannelData.empty ) : PersistedChannelState() { @@ -122,14 +122,28 @@ data class WaitForFundingSigned( // If we receive funds as part of the channel creation, we will add it to our payments db if (action.commitment.localCommit.spec.toLocal > 0.msat) add( ChannelAction.Storage.StoreIncomingPayment.ViaNewChannel( - amount = action.commitment.localCommit.spec.toLocal, - serviceFee = channelOrigin?.serviceFee ?: 0.msat, - miningFee = channelOrigin?.miningFee ?: action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi(), + amountReceived = action.commitment.localCommit.spec.toLocal, + serviceFee = channelOrigin?.fees?.serviceFee?.toMilliSatoshi() ?: 0.msat, + miningFee = channelOrigin?.fees?.miningFee ?: action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi(), localInputs = action.fundingTx.sharedTx.tx.localInputs.map { it.outPoint }.toSet(), txId = action.fundingTx.txId, origin = channelOrigin ) ) + liquidityPurchase?.let { purchase -> + if (channelParams.localParams.isChannelOpener) { + // 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() + purchase.fees.miningFee + add(ChannelAction.Storage.StoreOutgoingPayment.ViaInboundLiquidityRequest(txId = action.fundingTx.txId, miningFees = miningFees, purchase = purchase)) + } + } + channelOrigin?.let { + when (it) { + is Origin.OffChainPayment -> add(ChannelAction.EmitEvent(LiquidityEvents.Accepted(liquidityPurchase?.amount?.toMilliSatoshi() ?: 0.msat, it.fees.total.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment))) + is Origin.OnChainWallet -> add(ChannelAction.EmitEvent(SwapInEvents.Accepted(it.inputs, it.amountBeforeFees.truncateToSatoshi(), it.fees))) + } + } } return if (staticParams.useZeroConf) { logger.info { "channel is using 0-conf, we won't wait for the funding tx to confirm" } 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 c97ce807a..dcdd8b513 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForInit.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForInit.kt @@ -4,7 +4,10 @@ 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.* +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.utils.msat import fr.acinq.lightning.wire.ChannelTlv import fr.acinq.lightning.wire.OpenDualFundedChannel @@ -21,7 +24,8 @@ data object WaitForInit : ChannelState() { cmd.walletInputs, cmd.localParams, cmd.channelConfig, - cmd.remoteInit + cmd.remoteInit, + cmd.fundingRates, ) Pair(nextState, listOf()) } @@ -50,12 +54,12 @@ data object WaitForInit : ChannelState() { tlvStream = TlvStream( buildSet { add(ChannelTlv.ChannelTypeTlv(cmd.channelType)) + cmd.requestRemoteFunding?.let { add(ChannelTlv.RequestFundingTlv(it)) } if (cmd.pushAmount > 0.msat) add(ChannelTlv.PushAmountTlv(cmd.pushAmount)) - if (cmd.channelOrigin != null) add(ChannelTlv.OriginTlv(cmd.channelOrigin)) } ) ) - val nextState = WaitForAcceptChannel(cmd, open) + val nextState = WaitForAcceptChannel(cmd, open, cmd.channelOrigin) Pair(nextState, listOf(ChannelAction.Message.Send(open))) } is ChannelCommand.Init.Restore -> { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannel.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannel.kt index 18dcdba66..ed30eabbb 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannel.kt @@ -27,7 +27,8 @@ data class WaitForOpenChannel( val walletInputs: List, val localParams: LocalParams, val channelConfig: ChannelConfig, - val remoteInit: Init + val remoteInit: Init, + val fundingRates: LiquidityAds.WillFundRates? ) : ChannelState() { override suspend fun ChannelContext.processInternal(cmd: ChannelCommand): Pair> { return when (cmd) { @@ -40,6 +41,15 @@ data class WaitForOpenChannel( val channelFeatures = ChannelFeatures(channelType, localFeatures = localParams.features, remoteFeatures = remoteInit.features) val minimumDepth = if (staticParams.useZeroConf) 0 else Helpers.minDepthForFunding(staticParams.nodeParams, open.fundingAmount) val channelKeys = keyManager.channelKeys(localParams.fundingKeyPath) + val localFundingPubkey = channelKeys.fundingPubKey(0) + val fundingScript = Helpers.Funding.makeFundingPubKeyScript(localFundingPubkey, open.fundingPubkey) + val requestFunding = open.requestFunding + val willFund = when { + fundingRates == null -> null + requestFunding == null -> null + requestFunding.requestedAmount > fundingAmount -> null + else -> fundingRates.validateRequest(staticParams.nodeParams.nodePrivateKey, fundingScript, open.fundingFeerate, requestFunding, isChannelCreation = true) + } val accept = AcceptDualFundedChannel( temporaryChannelId = open.temporaryChannelId, fundingAmount = fundingAmount, @@ -49,7 +59,7 @@ data class WaitForOpenChannel( minimumDepth = minimumDepth.toLong(), toSelfDelay = localParams.toSelfDelay, maxAcceptedHtlcs = localParams.maxAcceptedHtlcs, - fundingPubkey = channelKeys.fundingPubKey(0), + fundingPubkey = localFundingPubkey, revocationBasepoint = channelKeys.revocationBasepoint, paymentBasepoint = channelKeys.paymentBasepoint, delayedPaymentBasepoint = channelKeys.delayedPaymentBasepoint, @@ -59,6 +69,7 @@ data class WaitForOpenChannel( tlvStream = TlvStream( buildSet { add(ChannelTlv.ChannelTypeTlv(channelType)) + willFund?.let { add(ChannelTlv.ProvideFundingTlv(it.willFund)) } if (pushAmount > 0.msat) add(ChannelTlv.PushAmountTlv(pushAmount)) } ), @@ -88,7 +99,8 @@ data class WaitForOpenChannel( is Either.Right -> { val interactiveTxSession = InteractiveTxSession(staticParams.remoteNodeId, channelKeys, keyManager.swapInOnChainWallet, fundingParams, 0.msat, 0.msat, emptySet(), fundingContributions.value) val nextState = WaitForFundingCreated( - localParams, + // If our peer asks us to pay the commit tx fees, we accept (only used in tests, as we're otherwise always the channel opener). + localParams.copy(paysCommitTxFees = open.channelFlags.nonInitiatorPaysCommitFees), remoteParams, interactiveTxSession, pushAmount, @@ -99,7 +111,8 @@ data class WaitForOpenChannel( open.channelFlags, channelConfig, channelFeatures, - open.origin + willFund?.purchase, + channelOrigin = null, ) val actions = listOf( ChannelAction.ChannelId.IdAssigned(staticParams.remoteNodeId, temporaryChannelId, channelId), diff --git a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt index ba6661fab..411dc372e 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt @@ -41,10 +41,6 @@ interface IncomingPaymentsDb { * Mark an incoming payment as received (paid). * Note that this function assumes that there is a matching payment request in the DB, otherwise it will be a no-op. * - * With pay-to-open, there is a delay before we receive the parts, and we may not receive any parts at all if the pay-to-open - * was cancelled due to a disconnection. That is why the payment should not be considered received (and not be displayed to - * the user) if there are no parts. - * * This method is additive: * - receivedWith set is appended to the existing set in database. * - receivedAt must be updated in database. @@ -67,6 +63,9 @@ interface OutgoingPaymentsDb { /** Get information about an outgoing payment (settled or not). */ suspend fun getLightningOutgoingPayment(id: UUID): LightningOutgoingPayment? + /** Get information about a liquidity purchase (for which the funding transaction has been signed). */ + suspend fun getInboundLiquidityPurchase(fundingTxId: TxId): InboundLiquidityOutgoingPayment? + /** Mark an outgoing payment as completed over Lightning. */ suspend fun completeOutgoingPaymentOffchain(id: UUID, preimage: ByteVector32, completedAt: Long = currentTimestampMillis()) @@ -157,7 +156,7 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r 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() + val amount: MilliSatoshi = receivedWith.map { it.amountReceived }.sum() /** Fees applied to receive this payment. */ val fees: MilliSatoshi = receivedWith.map { it.fees }.sum() @@ -165,14 +164,15 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r sealed class ReceivedWith { /** Amount received for this part after applying the fees. This is the final amount we can use. */ - abstract val amount: MilliSatoshi + abstract val amountReceived: MilliSatoshi /** Fees applied to receive this part. Is zero for Lightning payments. */ abstract val fees: MilliSatoshi /** Payment was received via existing lightning channels. */ - data class LightningPayment(override val amount: MilliSatoshi, val channelId: ByteVector32, val htlcId: Long) : ReceivedWith() { - override val fees: MilliSatoshi = 0.msat // with Lightning, the fee is paid by the sender + data class LightningPayment(override val amountReceived: MilliSatoshi, val channelId: ByteVector32, val htlcId: Long, val fundingFee: LiquidityAds.FundingFee?) : ReceivedWith() { + // If there is no funding fee, the fees are paid by the sender for lightning payments. + override val fees: MilliSatoshi = fundingFee?.amount ?: 0.msat } sealed class OnChainIncomingPayment : ReceivedWith() { @@ -188,13 +188,13 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r /** * 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 amountReceived Our side of the balance of this channel when it's created. This is the amount received after 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 amountReceived: MilliSatoshi, override val serviceFee: MilliSatoshi, override val miningFee: Satoshi, override val channelId: ByteVector32, @@ -204,7 +204,7 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r ) : OnChainIncomingPayment() data class SpliceIn( - override val amount: MilliSatoshi, + override val amountReceived: MilliSatoshi, override val serviceFee: MilliSatoshi, override val miningFee: Satoshi, override val channelId: ByteVector32, @@ -418,14 +418,15 @@ data class InboundLiquidityOutgoingPayment( override val channelId: ByteVector32, override val txId: TxId, override val miningFees: Satoshi, - val lease: LiquidityAds.Lease, + val purchase: LiquidityAds.Purchase, override val createdAt: Long, override val confirmedAt: Long?, override val lockedAt: Long?, ) : OnChainOutgoingPayment() { - override val fees: MilliSatoshi = (miningFees + lease.fees.serviceFee).toMilliSatoshi() + override val fees: MilliSatoshi = (miningFees + purchase.fees.serviceFee).toMilliSatoshi() override val amount: MilliSatoshi = fees override val completedAt: Long? = lockedAt + val fundingFee: LiquidityAds.FundingFee = LiquidityAds.FundingFee(purchase.fees.total.toMilliSatoshi(), txId) } enum class ChannelClosingType { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index ce09b5001..46e6dd5d5 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -12,7 +12,6 @@ import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.blockchain.fee.OnChainFeerates import fr.acinq.lightning.blockchain.mempool.MempoolSpaceClient import fr.acinq.lightning.channel.* -import fr.acinq.lightning.channel.ChannelCommand.Commitment.Splice.Response import fr.acinq.lightning.channel.states.* import fr.acinq.lightning.crypto.noise.* import fr.acinq.lightning.db.* @@ -22,6 +21,7 @@ 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.wire.* @@ -38,11 +38,6 @@ import kotlin.time.Duration.Companion.seconds sealed class PeerCommand -/** - * Try to open a channel, consuming all the spendable utxos in the wallet state provided. - */ -data class RequestChannelOpen(val requestId: ByteVector32, val walletInputs: List) : PeerCommand() - /** Open a channel, consuming all the spendable utxos in the wallet state provided. */ data class OpenChannel( val fundingAmount: Satoshi, @@ -50,10 +45,29 @@ data class OpenChannel( val walletInputs: List, val commitTxFeerate: FeeratePerKw, val fundingTxFeerate: FeeratePerKw, - val channelFlags: Byte, val channelType: ChannelType.SupportedChannelType ) : PeerCommand() +/** Consume all the spendable utxos in the wallet state provided to open a channel or splice into an existing channel. */ +data class AddWalletInputsToChannel(val walletInputs: List) : PeerCommand() { + val totalAmount: Satoshi = walletInputs.map { it.amount }.sum() +} + +/** + * Initiate a channel open or a splice to allow receiving an off-chain payment. + * + * @param paymentAmount total amount of the off-chain payment (before fees are paid). + * @param requestedAmount requested inbound liquidity, which will allow receiving the off-chain payment. + * @param fundingRate funding rate applied by our peer for this amount. + * @param preimage preimage of the off-chain payment. + * @param willAddHtlcs HTLCs that will be relayed to us once additional liquidity is available. + */ +data class AddLiquidityForIncomingPayment(val paymentAmount: MilliSatoshi, val requestedAmount: Satoshi, val fundingRate: LiquidityAds.FundingRate, val preimage: ByteVector32, val willAddHtlcs: List) : PeerCommand() { + val paymentHash: ByteVector32 = Crypto.sha256(preimage.toByteArray()).byteVector32() + + fun fees(fundingFeerate: FeeratePerKw, isChannelCreation: Boolean): LiquidityAds.Fees = fundingRate.fees(fundingFeerate, requestedAmount, requestedAmount, isChannelCreation) +} + data class PeerConnection(val id: Long, val output: Channel, val logger: MDCLogger) { fun send(msg: LightningMessage) { // We can safely use trySend because we use unlimited channel buffers. @@ -77,7 +91,6 @@ data object Disconnected : PeerCommand() sealed class PaymentCommand : PeerCommand() private data object CheckPaymentsTimeout : PaymentCommand() private data class CheckInvoiceRequestTimeout(val pathId: ByteVector32, val payOffer: PayOffer) : PaymentCommand() -data class PayToOpenResponseCommand(val payToOpenResponse: PayToOpenResponse) : PeerCommand() // @formatter:off sealed class SendPayment : PaymentCommand() { @@ -94,6 +107,7 @@ data class PayOffer(override val paymentId: UUID, val payerKey: PrivateKey, val data class PurgeExpiredPayments(val fromCreatedAt: Long, val toCreatedAt: Long) : PaymentCommand() data class SendOnionMessage(val message: OnionMessage) : PeerCommand() +data class SendOnTheFlyFundingMessage(val message: OnTheFlyFundingMessage) : PeerCommand() sealed class PeerEvent @@ -129,7 +143,6 @@ data class AddressAssigned(val address: String) : PeerEvent() * @param watcher Watches events from the Electrum client and publishes transactions and events. * @param db Wraps the various databases persisting the channels and payments data related to the Peer. * @param socketBuilder Builds the TCP socket used to connect to the Peer. - * @param trustedSwapInTxs a set of txids that can be used for swap-in even if they are zeroconf (useful when migrating from the legacy phoenix android app). * @param initTlvStream Optional stream of TLV for the [Init] message we send to this Peer after connection. Empty by default. */ @OptIn(ExperimentalStdlibApi::class) @@ -141,7 +154,6 @@ class Peer( val db: Databases, socketBuilder: TcpSocket.Builder?, scope: CoroutineScope, - private val trustedSwapInTxs: Set = emptySet(), private val initTlvStream: TlvStream = TlvStream.empty() ) : CoroutineScope by scope { companion object { @@ -179,9 +191,6 @@ class Peer( private var _channels by _channelsFlow val channels: Map get() = _channelsFlow.value - // pending requests asking our peer to open a channel to us - private var channelRequests: Map = HashMap() - private val _connectionState = MutableStateFlow(Connection.CLOSED(null)) val connectionState: StateFlow get() = _connectionState @@ -201,7 +210,8 @@ class Peer( val currentTipFlow = MutableStateFlow(null) val onChainFeeratesFlow = MutableStateFlow(null) - val swapInFeeratesFlow = MutableStateFlow(null) + val peerFeeratesFlow = MutableStateFlow(null) + val remoteFundingRates = MutableStateFlow(null) private val _channelLogger = nodeParams.loggerFactory.newLogger(ChannelState::class) private suspend fun ChannelState.process(cmd: ChannelCommand): Pair> { @@ -496,17 +506,9 @@ class Peer( swapInJob = launch { swapInWallet.wallet.walletStateFlow .combine(currentTipFlow.filterNotNull()) { walletState, currentTip -> Pair(walletState, currentTip) } - .combine(swapInFeeratesFlow.filterNotNull()) { (walletState, currentTip), feerate -> Triple(walletState, currentTip, feerate) } + .combine(peerFeeratesFlow.filterNotNull()) { (walletState, currentTip), feerates -> Triple(walletState, currentTip, feerates.fundingFeerate) } .combine(nodeParams.liquidityPolicy) { (walletState, currentTip, feerate), policy -> TrySwapInFlow(currentTip, walletState, feerate, policy) } - .collect { w -> - // Local mutual close txs from pre-splice channels can be used as zero-conf inputs for swap-in to facilitate migration - val mutualCloseTxs = channels.values - .filterIsInstance() - .filterNot { it.commitments.params.channelFeatures.hasFeature(Feature.DualFunding) } - .flatMap { state -> state.mutualClosePublished.map { closingTx -> closingTx.tx.txid } } - val trustedTxs = trustedSwapInTxs + mutualCloseTxs - swapInCommands.send(SwapInCommand.TrySwapIn(w.currentBlockHeight, w.walletState, walletParams.swapInParams, trustedTxs)) - } + .collect { w -> swapInCommands.send(SwapInCommand.TrySwapIn(w.currentBlockHeight, w.walletState, walletParams.swapInParams)) } } } } @@ -583,17 +585,17 @@ class Peer( * Estimate the actual feerate to use (and corresponding fee to pay) to purchase inbound liquidity with a splice * that reaches the target feerate. */ - suspend fun estimateFeeForInboundLiquidity(amount: Satoshi, targetFeerate: FeeratePerKw, leaseRate: LiquidityAds.LeaseRate): Pair? { + suspend fun estimateFeeForInboundLiquidity(amount: Satoshi, targetFeerate: FeeratePerKw, fundingRate: LiquidityAds.FundingRate): Pair? { return channels.values .filterIsInstance() .firstOrNull() ?.let { channel -> - val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = channel.commitments.active.first(), walletInputs = emptyList(), localOutputs = emptyList()) + leaseRate.fundingWeight + val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = channel.commitments.active.first(), walletInputs = emptyList(), localOutputs = emptyList()) + fundingRate.fundingWeight // The mining fee below pays for the entirety of the splice transaction, including inputs and outputs from the liquidity provider. val (actualFeerate, miningFee) = client.computeSpliceCpfpFeerate(channel.commitments, targetFeerate, spliceWeight = weight, logger) - // The mining fee in the lease only covers the remote node's inputs and outputs, they are already included in the mining fee above. - val leaseFees = leaseRate.fees(actualFeerate, amount, amount) - Pair(actualFeerate, ChannelManagementFees(miningFee, leaseFees.serviceFee)) + // The mining fee below only covers the remote node's inputs and outputs, which are already included in the mining fee above. + val fundingFees = fundingRate.fees(actualFeerate, amount, amount, isChannelCreation = false) + Pair(actualFeerate, ChannelManagementFees(miningFee, fundingFees.serviceFee)) } } @@ -612,7 +614,8 @@ class Peer( spliceIn = null, spliceOut = ChannelCommand.Commitment.Splice.Request.SpliceOut(amount, scriptPubKey), requestRemoteFunding = null, - feerate = feerate + feerate = feerate, + origins = listOf(), ) send(WrappedChannelCommand(channel.channelId, spliceCommand)) spliceCommand.replyTo.await() @@ -630,25 +633,26 @@ class Peer( spliceIn = null, spliceOut = null, requestRemoteFunding = null, - feerate = feerate + feerate = feerate, + origins = listOf(), ) send(WrappedChannelCommand(channel.channelId, spliceCommand)) spliceCommand.replyTo.await() } } - suspend fun requestInboundLiquidity(amount: Satoshi, feerate: FeeratePerKw, leaseRate: LiquidityAds.LeaseRate): ChannelCommand.Commitment.Splice.Response? { + suspend fun requestInboundLiquidity(amount: Satoshi, feerate: FeeratePerKw, fundingRate: LiquidityAds.FundingRate): ChannelCommand.Commitment.Splice.Response? { return channels.values .filterIsInstance() .firstOrNull() ?.let { channel -> - val leaseStart = currentTipFlow.filterNotNull().first() val spliceCommand = ChannelCommand.Commitment.Splice.Request( replyTo = CompletableDeferred(), spliceIn = null, spliceOut = null, - requestRemoteFunding = LiquidityAds.RequestRemoteFunding(amount, leaseStart, leaseRate), - feerate = feerate + requestRemoteFunding = LiquidityAds.RequestFunding(amount, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance), + feerate = feerate, + origins = listOf(), ) send(WrappedChannelCommand(channel.channelId, spliceCommand)) spliceCommand.replyTo.await() @@ -852,7 +856,7 @@ class Peer( channelId = channelId, txId = action.txId, miningFees = action.miningFees, - lease = action.lease, + purchase = action.purchase, createdAt = currentTimestampMillis(), confirmedAt = null, lockedAt = null @@ -898,11 +902,12 @@ class Peer( } } - private suspend fun processIncomingPayment(item: Either) { + private suspend fun processIncomingPayment(item: Either) { val currentBlockHeight = currentTipFlow.filterNotNull().first() + val currentFeerate = peerFeeratesFlow.filterNotNull().first().fundingFeerate val result = when (item) { - is Either.Right -> incomingPaymentHandler.process(item.value, currentBlockHeight) - is Either.Left -> incomingPaymentHandler.process(item.value, currentBlockHeight) + is Either.Right -> incomingPaymentHandler.process(item.value, currentBlockHeight, currentFeerate, remoteFundingRates.value) + is Either.Left -> incomingPaymentHandler.process(item.value, currentBlockHeight, currentFeerate, remoteFundingRates.value) } when (result) { is IncomingPaymentHandler.ProcessAddResult.Accepted -> { @@ -1002,6 +1007,7 @@ class Peer( else -> { theirInit = msg nodeParams._nodeEvents.emit(PeerConnected(remoteNodeId, msg)) + remoteFundingRates.value = msg.liquidityRates _channels = _channels.mapValues { entry -> val (state1, actions) = entry.value.process(ChannelCommand.Connected(ourInit, msg)) processActions(entry.key, peerConnection, actions) @@ -1010,6 +1016,9 @@ class Peer( } } } + is RecommendedFeerates -> { + peerFeeratesFlow.value = msg + } is Ping -> { val pong = Pong(ByteVector(ByteArray(msg.pongLength))) peerConnection?.send(pong) @@ -1028,50 +1037,13 @@ class Peer( } else if (_channels.containsKey(msg.temporaryChannelId)) { logger.warning { "ignoring open_channel with duplicate temporaryChannelId=${msg.temporaryChannelId}" } } else { - val (walletInputs, fundingAmount, pushAmount) = when (val origin = msg.origin) { - is Origin.PleaseOpenChannelOrigin -> when (val request = channelRequests[origin.requestId]) { - is RequestChannelOpen -> { - val totalFee = origin.serviceFee + origin.miningFee.toMilliSatoshi() - msg.pushAmount - nodeParams.liquidityPolicy.value.maybeReject(request.walletInputs.balance.toMilliSatoshi(), totalFee, LiquidityEvents.Source.OnChainWallet, logger)?.let { rejected -> - logger.info { "rejecting open_channel2: reason=${rejected.reason}" } - nodeParams._nodeEvents.emit(rejected) - swapInCommands.send(SwapInCommand.UnlockWalletInputs(request.walletInputs.map { it.outPoint }.toSet())) - peerConnection?.send(Error(msg.temporaryChannelId, "cancelling open due to local liquidity policy")) - return - } - val fundingFee = Transactions.weight2fee(msg.fundingFeerate, FundingContributions.weight(request.walletInputs)) - // We have to pay the fees for our inputs, so we deduce them from our funding amount. - val fundingAmount = request.walletInputs.balance - fundingFee - // We pay the other fees by pushing the corresponding amount - val pushAmount = origin.serviceFee + origin.miningFee.toMilliSatoshi() - fundingFee.toMilliSatoshi() - nodeParams._nodeEvents.emit(SwapInEvents.Accepted(request.requestId, serviceFee = origin.serviceFee, miningFee = origin.miningFee)) - Triple(request.walletInputs, fundingAmount, pushAmount) - } - - else -> { - logger.warning { "n:$remoteNodeId c:${msg.temporaryChannelId} rejecting open_channel2: cannot find channel request with requestId=${origin.requestId}" } - peerConnection?.send(Error(msg.temporaryChannelId, "no corresponding channel request")) - return - } - } - else -> Triple(listOf(), 0.sat, 0.msat) - } - if (fundingAmount.toMilliSatoshi() < pushAmount) { - logger.warning { "rejecting open_channel2 with invalid funding and push amounts ($fundingAmount < $pushAmount)" } - peerConnection?.send(Error(msg.temporaryChannelId, InvalidPushAmount(msg.temporaryChannelId, pushAmount, fundingAmount.toMilliSatoshi()).message)) - } else { - val localParams = LocalParams(nodeParams, isInitiator = false) - val state = WaitForInit - val channelConfig = ChannelConfig.standard - val (state1, actions1) = state.process(ChannelCommand.Init.NonInitiator(msg.temporaryChannelId, fundingAmount, pushAmount, walletInputs, localParams, channelConfig, theirInit!!)) - val (state2, actions2) = state1.process(ChannelCommand.MessageReceived(msg)) - _channels = _channels + (msg.temporaryChannelId to state2) - when (val origin = msg.origin) { - is Origin.PleaseOpenChannelOrigin -> channelRequests = channelRequests - origin.requestId - else -> Unit - } - processActions(msg.temporaryChannelId, peerConnection, actions1 + actions2) - } + val localParams = LocalParams(nodeParams, isChannelOpener = false, payCommitTxFees = msg.channelFlags.nonInitiatorPaysCommitFees) + val state = WaitForInit + val channelConfig = ChannelConfig.standard + val (state1, actions1) = state.process(ChannelCommand.Init.NonInitiator(msg.temporaryChannelId, 0.sat, 0.msat, listOf(), localParams, channelConfig, theirInit!!, fundingRates = null)) + val (state2, actions2) = state1.process(ChannelCommand.MessageReceived(msg)) + _channels = _channels + (msg.temporaryChannelId to state2) + processActions(msg.temporaryChannelId, peerConnection, actions1 + actions2) } } is ChannelReestablish -> { @@ -1163,24 +1135,32 @@ class Peer( _channels = _channels + (state.channelId to state1) } } - is PayToOpenRequest -> { - logger.info { "received ${msg::class.simpleName}" } - when (selectChannelForSplicing()) { - is SelectChannelResult.Available -> processIncomingPayment(Either.Left(msg)) - SelectChannelResult.None -> processIncomingPayment(Either.Left(msg)) - SelectChannelResult.NotReady -> { - // If a channel is currently being created, it can't process splices yet. We could accept this payment, but - // it wouldn't be reflected in the user balance until the channel is ready, because we only insert - // the payment in db when we will process the corresponding splice and see the pay-to-open origin. This - // can take a long time depending on the confirmation speed. It is better and simpler to reject the incoming - // payment rather that having the user wonder where their money went. - val rejected = LiquidityEvents.Rejected(msg.amountMsat, msg.payToOpenFeeSatoshis.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.ChannelInitializing) - logger.info { "rejecting pay-to-open: reason=${rejected.reason}" } - nodeParams._nodeEvents.emit(rejected) - val action = IncomingPaymentHandler.actionForPayToOpenFailure(nodeParams.nodePrivateKey, TemporaryNodeFailure, msg) - input.send(action) + is WillAddHtlc -> when { + nodeParams.features.hasFeature(Feature.OnTheFlyFunding) -> when { + nodeParams.liquidityPolicy.value is LiquidityPolicy.Disable -> { + logger.warning { "cannot accept on-the-fly funding: policy set to disabled" } + val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(nodeParams.nodePrivateKey, msg, TemporaryNodeFailure) + input.send(SendOnTheFlyFundingMessage(failure)) + nodeParams._nodeEvents.emit(LiquidityEvents.Rejected(msg.amount, 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.PolicySetToDisabled)) + } + else -> when (selectChannelForSplicing()) { + is SelectChannelResult.Available -> processIncomingPayment(Either.Left(msg)) + SelectChannelResult.None -> processIncomingPayment(Either.Left(msg)) + SelectChannelResult.NotReady -> { + // Once the channel will be ready, we may have enough inbound liquidity to receive the payment without + // an on-chain operation, which is more efficient. We thus reject that payment and wait for the sender to retry. + logger.warning { "cannot accept on-the-fly funding: another funding attempt is already in-progress" } + val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(nodeParams.nodePrivateKey, msg, TemporaryNodeFailure) + input.send(SendOnTheFlyFundingMessage(failure)) + nodeParams._nodeEvents.emit(LiquidityEvents.Rejected(msg.amount, 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.ChannelFundingInProgress)) + } } } + else -> { + // If we don't support on-the-fly funding, we simply ignore that proposal. + // Our peer will fail the corresponding HTLCs after a small delay. + logger.info { "ignoring on-the-fly funding (amount=${msg.amount}): on-the-fly funding is disabled" } + } } is PhoenixAndroidLegacyInfo -> { logger.info { "received ${msg::class.simpleName} hasChannels=${msg.hasChannels}" } @@ -1227,63 +1207,8 @@ class Peer( _channels = _channels + (cmd.watch.channelId to state1) } } - is RequestChannelOpen -> { - when (val available = selectChannelForSplicing()) { - is SelectChannelResult.Available -> { - // We have a channel and we are connected (otherwise state would be Offline/Syncing). - val targetFeerate = swapInFeeratesFlow.filterNotNull().first() - val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = available.channel.commitments.active.first(), walletInputs = cmd.walletInputs, localOutputs = emptyList()) - val (feerate, fee) = client.computeSpliceCpfpFeerate(available.channel.commitments, targetFeerate, spliceWeight = weight, logger) - - logger.info { "requesting splice-in using balance=${cmd.walletInputs.balance} feerate=$feerate fee=$fee" } - nodeParams.liquidityPolicy.value.maybeReject(cmd.walletInputs.balance.toMilliSatoshi(), fee.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger)?.let { rejected -> - logger.info { "rejecting splice: reason=${rejected.reason}" } - nodeParams._nodeEvents.emit(rejected) - swapInCommands.send(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) - return - } - - val spliceCommand = ChannelCommand.Commitment.Splice.Request( - replyTo = CompletableDeferred(), - spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(cmd.walletInputs), - spliceOut = null, - requestRemoteFunding = null, - feerate = feerate - ) - // If the splice fails, we immediately unlock the utxos to reuse them in the next attempt. - spliceCommand.replyTo.invokeOnCompletion { ex -> - if (ex == null && spliceCommand.replyTo.getCompleted() is ChannelCommand.Commitment.Splice.Response.Failure) { - swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) - } - } - input.send(WrappedChannelCommand(available.channel.channelId, spliceCommand)) - } - SelectChannelResult.NotReady -> { - // There are existing channels but not immediately usable (e.g. creating, disconnected), we don't do anything yet. - logger.info { "ignoring channel request, existing channels are not ready for splice-in: ${channels.values.map { it::class.simpleName }}" } - swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) - } - SelectChannelResult.None -> { - // Either there are no channels, or they will never be suitable for a splice-in: we request a new channel. - // Grandparents are supplied as a proof of migration. - val grandParents = cmd.walletInputs.map { utxo -> utxo.previousTx.txIn.map { txIn -> txIn.outPoint } }.flatten() - val pleaseOpenChannel = PleaseOpenChannel( - nodeParams.chainHash, - cmd.requestId, - cmd.walletInputs.balance, - cmd.walletInputs.size, - FundingContributions.weight(cmd.walletInputs), - TlvStream(PleaseOpenChannelTlv.GrandParents(grandParents)) - ) - logger.info { "sending please_open_channel with ${cmd.walletInputs.size} utxos (amount = ${cmd.walletInputs.balance})" } - peerConnection?.send(pleaseOpenChannel) - nodeParams._nodeEvents.emit(SwapInEvents.Requested(pleaseOpenChannel)) - channelRequests = channelRequests + (pleaseOpenChannel.requestId to cmd) - } - } - } is OpenChannel -> { - val localParams = LocalParams(nodeParams, isInitiator = true) + val localParams = LocalParams(nodeParams, isChannelOpener = true, payCommitTxFees = true) val state = WaitForInit val (state1, actions1) = state.process( ChannelCommand.Init.Initiator( @@ -1294,18 +1219,247 @@ class Peer( cmd.fundingTxFeerate, localParams, theirInit!!, - cmd.channelFlags, + ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = false), ChannelConfig.standard, - cmd.channelType + cmd.channelType, + requestRemoteFunding = null, + channelOrigin = null, ) ) val msg = actions1.filterIsInstance().map { it.message }.filterIsInstance().first() _channels = _channels + (msg.temporaryChannelId to state1) processActions(msg.temporaryChannelId, peerConnection, actions1) } - is PayToOpenResponseCommand -> { - logger.info { "sending ${cmd.payToOpenResponse::class.simpleName}" } - peerConnection?.send(cmd.payToOpenResponse) + is AddWalletInputsToChannel -> { + when (val available = selectChannelForSplicing()) { + is SelectChannelResult.Available -> { + // We have a channel and we are connected. + val targetFeerate = peerFeeratesFlow.filterNotNull().first().fundingFeerate + val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = available.channel.commitments.active.first(), walletInputs = cmd.walletInputs, localOutputs = emptyList()) + val (feerate, fee) = client.computeSpliceCpfpFeerate(available.channel.commitments, targetFeerate, spliceWeight = weight, logger) + logger.info { "requesting splice-in using balance=${cmd.walletInputs.balance} feerate=$feerate fee=$fee" } + when (val rejected = nodeParams.liquidityPolicy.value.maybeReject(cmd.walletInputs.balance.toMilliSatoshi(), fee.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger)) { + is LiquidityEvents.Rejected -> { + logger.info { "rejecting splice: reason=${rejected.reason}" } + nodeParams._nodeEvents.emit(rejected) + swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) + } + else -> { + val spliceCommand = ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(cmd.walletInputs), + spliceOut = null, + requestRemoteFunding = null, + feerate = feerate, + origins = listOf(Origin.OnChainWallet(cmd.walletInputs.map { it.outPoint }.toSet(), cmd.totalAmount.toMilliSatoshi(), ChannelManagementFees(fee, 0.sat))) + ) + // If the splice fails, we immediately unlock the utxos to reuse them in the next attempt. + spliceCommand.replyTo.invokeOnCompletion { ex -> + if (ex == null && spliceCommand.replyTo.getCompleted() is ChannelCommand.Commitment.Splice.Response.Failure) { + swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) + } + } + input.send(WrappedChannelCommand(available.channel.channelId, spliceCommand)) + nodeParams._nodeEvents.emit(SwapInEvents.Requested(cmd.walletInputs)) + } + } + } + SelectChannelResult.NotReady -> { + // 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 }}" } + swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) + } + SelectChannelResult.None -> { + // 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() + // We need our peer to contribute, because they must have enough funds to pay the commitment fees. + // That means they will add at least one input to the funding transaction, and pay the corresponding mining fees. + // We always request a liquidity purchase, even for a dummy amount, which ensures that we refund their mining fees. + val inboundLiquidityTarget = when (val policy = nodeParams.liquidityPolicy.first()) { + is LiquidityPolicy.Disable -> 1.sat + is LiquidityPolicy.Auto -> policy.inboundLiquidityTarget ?: 1.sat + } + when (val fundingRate = remoteFundingRates.value?.findRate(inboundLiquidityTarget)) { + null -> { + logger.warning { "cannot find suitable funding rate (remoteFundingRates=${remoteFundingRates.value}, inboundLiquidityTarget=$inboundLiquidityTarget)" } + swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) + } + else -> { + val requestRemoteFunding = LiquidityAds.RequestFunding(inboundLiquidityTarget, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance) + val (localFundingAmount, fees) = run { + // We need to know the local channel funding amount to be able use channel opening messages. + // We must pay on-chain fees for our inputs/outputs of the transaction: we compute them first + // and proceed backwards to retrieve the funding amount. + val dummyFundingScript = Script.write(Scripts.multiSig2of2(Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey)).byteVector() + val localMiningFee = Transactions.weight2fee(currentFeerates.fundingFeerate, FundingContributions.computeWeightPaid(isInitiator = true, null, dummyFundingScript, cmd.walletInputs, emptyList())) + val localFundingAmount = cmd.totalAmount - localMiningFee + val fundingFees = requestRemoteFunding.fees(currentFeerates.fundingFeerate, isChannelCreation = true) + // We also refund the liquidity provider for some of the on-chain fees they will pay for their inputs/outputs of the transaction. + // This will be taken from our channel balance during the interactive-tx construction, they shouldn't be deducted from our funding amount. + val totalFees = ChannelManagementFees(miningFee = localMiningFee + fundingFees.miningFee, serviceFee = fundingFees.serviceFee) + Pair(localFundingAmount, totalFees) + } + if (cmd.totalAmount - fees.total < nodeParams.dustLimit) { + 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 { + val totalAmount = cmd.walletInputs.balance.toMilliSatoshi() + requestRemoteFunding.requestedAmount.toMilliSatoshi() + when (val rejected = nodeParams.liquidityPolicy.first().maybeReject(totalAmount, fees.total.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger)) { + is LiquidityEvents.Rejected -> { + logger.info { "rejecting channel open: reason=${rejected.reason}" } + nodeParams._nodeEvents.emit(rejected) + swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) + } + else -> { + // 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) + val (state, actions) = WaitForInit.process( + ChannelCommand.Init.Initiator( + fundingAmount = localFundingAmount, + pushAmount = 0.msat, + walletInputs = cmd.walletInputs, + commitTxFeerate = currentFeerates.commitmentFeerate, + fundingTxFeerate = currentFeerates.fundingFeerate, + localParams = localParams, + remoteInit = theirInit!!, + channelFlags = channelFlags, + channelConfig = ChannelConfig.standard, + channelType = ChannelType.SupportedChannelType.AnchorOutputsZeroReserve, + requestRemoteFunding = requestRemoteFunding, + channelOrigin = Origin.OnChainWallet(cmd.walletInputs.map { it.outPoint }.toSet(), cmd.totalAmount.toMilliSatoshi(), fees), + ) + ) + val msg = actions.filterIsInstance().map { it.message }.filterIsInstance().first() + _channels = _channels + (msg.temporaryChannelId to state) + processActions(msg.temporaryChannelId, peerConnection, actions) + nodeParams._nodeEvents.emit(SwapInEvents.Requested(cmd.walletInputs)) + } + } + } + } + } + } + } + } + is AddLiquidityForIncomingPayment -> { + val currentFeerates = peerFeeratesFlow.filterNotNull().first() + val paymentTypes = remoteFundingRates.value?.paymentTypes ?: setOf() + when (val available = selectChannelForSplicing()) { + is SelectChannelResult.Available -> { + // We don't contribute any input or output, but we must pay on-chain fees for the shared input and output. + // We pay those on-chain fees using our current channel balance. + val localBalance = available.channel.commitments.active.first().localCommit.spec.toLocal + val spliceWeight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = available.channel.commitments.active.first(), walletInputs = listOf(), localOutputs = listOf()) + val (fundingFeerate, localMiningFee) = client.computeSpliceCpfpFeerate(available.channel.commitments, currentFeerates.fundingFeerate, spliceWeight, logger) + val (targetFeerate, paymentDetails) = when { + localBalance >= localMiningFee + cmd.fees(fundingFeerate, isChannelCreation = false).total -> { + // We have enough funds to pay the mining fee and the lease fees. + // This the ideal scenario because the fees can be paid immediately with the splice transaction. + Pair(fundingFeerate, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(listOf(cmd.paymentHash))) + } + else -> { + val targetFeerate = when { + localBalance >= localMiningFee * 0.75 -> fundingFeerate + // Our current balance is too low to pay the mining fees for our weight of the splice transaction. + // If we don't do anything, the resulting transaction will thus have a lower feerate than requested and may not confirm. + // To avoid that, we ask our peer to target a higher feerate than the one we actually want. + // They will pay more mining fees to satisfy that feerate, while we'll pay whatever we can from our current balance. + // We should be paying for the shared input and shared output, which is a lot of weight, so we add 50%. + // This is hacky but should result in an effective feerate that is somewhat close to the initial feerate we wanted. + // Note that we will pay liquidity fees based on the target feerate, which will refund our peer for this hack. + else -> fundingFeerate * 1.5 + } + // We cannot pay the liquidity fees from our channel balance, so we fall back to future HTLCs. + val paymentDetails = when { + paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlc) -> LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(cmd.paymentHash)) + paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlcWithPreimage) -> LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(listOf(cmd.preimage)) + else -> null + } + Pair(targetFeerate, paymentDetails) + } + } + when (paymentDetails) { + null -> { + // Our peer doesn't allow paying liquidity fees from future HTLCs. + // We'll need to wait until we have more channel balance or do a splice-in to purchase more inbound liquidity. + logger.warning { "cannot request on-the-fly splice: payment types not supported (${paymentTypes.joinToString()})" } + } + else -> { + val leaseFees = cmd.fees(targetFeerate, isChannelCreation = false) + val totalFees = ChannelManagementFees(miningFee = localMiningFee.min(localBalance.truncateToSatoshi()) + leaseFees.miningFee, serviceFee = leaseFees.serviceFee) + logger.info { "requesting on-the-fly splice for paymentHash=${cmd.paymentHash} feerate=$targetFeerate fee=${totalFees.total} paymentType=${paymentDetails.paymentType}" } + val spliceCommand = ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = null, + spliceOut = null, + requestRemoteFunding = LiquidityAds.RequestFunding(cmd.requestedAmount, cmd.fundingRate, paymentDetails), + feerate = targetFeerate, + origins = listOf(Origin.OffChainPayment(cmd.preimage, cmd.paymentAmount, totalFees)) + ) + val (state, actions) = available.channel.process(spliceCommand) + _channels = _channels + (available.channel.channelId to state) + processActions(available.channel.channelId, peerConnection, actions) + } + } + } + SelectChannelResult.None -> { + // We ask our peer to pay the commit tx fees. + val localParams = LocalParams(nodeParams, isChannelOpener = true, payCommitTxFees = false) + val channelFlags = ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = true) + // Since we don't have inputs to contribute, we're unable to pay on-chain fees for the shared output. + // We target a higher feerate so that the effective feerate isn't too low compared to our target. + // We only need to cover the shared output, which doesn't add too much weight, so we add 25%. + val fundingFeerate = currentFeerates.fundingFeerate * 1.25 + // We don't pay any local on-chain fees, our fee is only for the liquidity lease. + val leaseFees = cmd.fees(fundingFeerate, isChannelCreation = true) + val totalFees = ChannelManagementFees(miningFee = leaseFees.miningFee, serviceFee = leaseFees.serviceFee) + // We cannot pay the liquidity fees from our channel balance, so we fall back to future HTLCs. + val paymentDetails = when { + paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlc) -> LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(cmd.paymentHash)) + paymentTypes.contains(LiquidityAds.PaymentType.FromFutureHtlcWithPreimage) -> LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(listOf(cmd.preimage)) + else -> null + } + when (paymentDetails) { + null -> { + // Our peer doesn't allow paying liquidity fees from future HTLCs. + // We'll need to swap-in some funds to create a new channel. + logger.warning { "cannot request on-the-fly channel: payment types not supported (${paymentTypes.joinToString()})" } + } + else -> { + logger.info { "requesting on-the-fly channel for paymentHash=${cmd.paymentHash} feerate=$fundingFeerate fee=${totalFees.total} paymentType=${paymentDetails.paymentType}" } + val (state, actions) = WaitForInit.process( + ChannelCommand.Init.Initiator( + fundingAmount = 0.sat, // we don't have funds to contribute + pushAmount = 0.msat, + walletInputs = listOf(), + commitTxFeerate = currentFeerates.commitmentFeerate, + fundingTxFeerate = fundingFeerate, + localParams = localParams, + remoteInit = theirInit!!, + channelFlags = channelFlags, + channelConfig = ChannelConfig.standard, + channelType = ChannelType.SupportedChannelType.AnchorOutputsZeroReserve, + requestRemoteFunding = LiquidityAds.RequestFunding(cmd.requestedAmount, cmd.fundingRate, paymentDetails), + channelOrigin = Origin.OffChainPayment(cmd.preimage, cmd.paymentAmount, totalFees), + ) + ) + val msg = actions.filterIsInstance().map { it.message }.filterIsInstance().first() + _channels = _channels + (msg.temporaryChannelId to state) + processActions(msg.temporaryChannelId, peerConnection, actions) + } + } + } + SelectChannelResult.NotReady -> { + // There is an existing channel but not immediately usable (e.g. we're already in the process of funding it). + logger.warning { "cancelling on-the-fly funding, existing channels are not ready for splice-in: ${channels.values.map { it::class.simpleName }}" } + cmd.willAddHtlcs.forEach { + val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(nodeParams.nodePrivateKey, it, TemporaryNodeFailure) + input.send(SendOnTheFlyFundingMessage(failure)) + } + nodeParams._nodeEvents.emit(LiquidityEvents.Rejected(cmd.requestedAmount.toMilliSatoshi(), 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.ChannelFundingInProgress)) + } + } } is PayInvoice -> { val currentTip = currentTipFlow.filterNotNull().first() @@ -1370,6 +1524,7 @@ class Peer( } is CheckInvoiceRequestTimeout -> offerManager.checkInvoiceRequestTimeout(cmd.pathId, cmd.payOffer) is SendOnionMessage -> peerConnection?.send(cmd.message) + is SendOnTheFlyFundingMessage -> peerConnection?.send(cmd.message) } } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt b/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt index 21ac144a0..d4456f6a2 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt @@ -63,9 +63,10 @@ JsonSerializers.InteractiveTxSigningSessionSerializer::class, JsonSerializers.RbfStatusSerializer::class, JsonSerializers.SpliceStatusSerializer::class, - JsonSerializers.LiquidityLeaseFeesSerializer::class, - JsonSerializers.LiquidityLeaseWitnessSerializer::class, - JsonSerializers.LiquidityLeaseSerializer::class, + JsonSerializers.LiquidityFeesSerializer::class, + JsonSerializers.LiquidityPaymentDetailsSerializer::class, + JsonSerializers.LiquidityPurchaseSerializer::class, + JsonSerializers.ChannelFlagsSerializer::class, JsonSerializers.ChannelParamsSerializer::class, JsonSerializers.ChannelOriginSerializer::class, JsonSerializers.CommitmentChangesSerializer::class, @@ -300,14 +301,17 @@ object JsonSerializers { object SpliceStatusSerializer : StringSerializer({ it::class.simpleName!! }) - @Serializer(forClass = LiquidityAds.LeaseFees::class) - object LiquidityLeaseFeesSerializer + @Serializer(forClass = LiquidityAds.Fees::class) + object LiquidityFeesSerializer - @Serializer(forClass = LiquidityAds.LeaseWitness::class) - object LiquidityLeaseWitnessSerializer + @Serializer(forClass = LiquidityAds.PaymentDetails::class) + object LiquidityPaymentDetailsSerializer - @Serializer(forClass = LiquidityAds.Lease::class) - object LiquidityLeaseSerializer + @Serializer(forClass = LiquidityAds.Purchase::class) + object LiquidityPurchaseSerializer + + @Serializer(forClass = ChannelFlags::class) + object ChannelFlagsSerializer @Serializer(forClass = ChannelParams::class) object ChannelParamsSerializer diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index fd814cabc..9180e14ef 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -1,20 +1,17 @@ package fr.acinq.lightning.payment -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.Crypto -import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 -import fr.acinq.lightning.channel.ChannelAction -import fr.acinq.lightning.channel.ChannelCommand -import fr.acinq.lightning.channel.Origin +import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.channel.* import fr.acinq.lightning.crypto.sphinx.Sphinx import fr.acinq.lightning.db.IncomingPayment -import fr.acinq.lightning.db.IncomingPaymentsDb -import fr.acinq.lightning.io.PayToOpenResponseCommand +import fr.acinq.lightning.db.PaymentsDb +import fr.acinq.lightning.io.AddLiquidityForIncomingPayment import fr.acinq.lightning.io.PeerCommand +import fr.acinq.lightning.io.SendOnTheFlyFundingMessage import fr.acinq.lightning.io.WrappedChannelCommand import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.logging.mdc @@ -37,24 +34,22 @@ data class HtlcPart(val htlc: UpdateAddHtlc, override val finalPayload: PaymentO override fun toString(): String = "htlc(channelId=${htlc.channelId},id=${htlc.id})" } -data class PayToOpenPart(val payToOpenRequest: PayToOpenRequest, override val finalPayload: PaymentOnion.FinalPayload) : PaymentPart() { - override val amount: MilliSatoshi = payToOpenRequest.amountMsat +data class WillAddHtlcPart(val htlc: WillAddHtlc, override val finalPayload: PaymentOnion.FinalPayload) : PaymentPart() { + override val amount: MilliSatoshi = htlc.amount override val totalAmount: MilliSatoshi = finalPayload.totalAmount - override val paymentHash: ByteVector32 = payToOpenRequest.paymentHash - override val onionPacket: OnionRoutingPacket = payToOpenRequest.finalPacket - override fun toString(): String = "pay-to-open(amount=${payToOpenRequest.amountMsat})" + override val paymentHash: ByteVector32 = htlc.paymentHash + override val onionPacket: OnionRoutingPacket = htlc.finalPacket + override fun toString(): String = "future-htlc(id=${htlc.id},amount=${htlc.amount})" } -class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPaymentsDb) { +class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) { sealed class ProcessAddResult { abstract val actions: List data class Accepted(override val actions: List, val incomingPayment: IncomingPayment, val received: IncomingPayment.Received) : ProcessAddResult() data class Rejected(override val actions: List, val incomingPayment: IncomingPayment?) : ProcessAddResult() - data class Pending(val incomingPayment: IncomingPayment, val pendingPayment: PendingPayment) : ProcessAddResult() { - override val actions: List = listOf() - } + data class Pending(val incomingPayment: IncomingPayment, val pendingPayment: PendingPayment, override val actions: List = listOf()) : ProcessAddResult() } /** @@ -70,6 +65,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment constructor(firstPart: PaymentPart) : this(setOf(firstPart), firstPart.totalAmount, currentTimestampSeconds()) val amountReceived: MilliSatoshi = parts.map { it.amount }.sum() + val fundingFee: MilliSatoshi = parts.filterIsInstance().map { it.htlc.fundingFee?.amount ?: 0.msat }.sum() fun add(part: PaymentPart): PendingPayment = copy(parts = parts + part) } @@ -108,17 +104,12 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment return pr } - /** - * Save the "received-with" details of an incoming amount. - * - * - for a pay-to-open origin, the payment already exists and we only add a received-with. - * - for a swap-in origin, a new incoming payment must be created. We use a random. - */ + /** Save the "received-with" details of an incoming on-chain amount. */ suspend fun process(channelId: ByteVector32, action: ChannelAction.Storage.StoreIncomingPayment) { val receivedWith = when (action) { is ChannelAction.Storage.StoreIncomingPayment.ViaNewChannel -> IncomingPayment.ReceivedWith.NewChannel( - amount = action.amount, + amountReceived = action.amountReceived, serviceFee = action.serviceFee, miningFee = action.miningFee, channelId = channelId, @@ -128,7 +119,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment ) is ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn -> IncomingPayment.ReceivedWith.SpliceIn( - amount = action.amount, + amountReceived = action.amountReceived, serviceFee = action.serviceFee, miningFee = action.miningFee, channelId = channelId, @@ -137,16 +128,8 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment lockedAt = null, ) } - when (val origin = action.origin) { - is Origin.PayToOpenOrigin -> { - // there already is a corresponding Lightning invoice in the db - db.receivePayment( - paymentHash = origin.paymentHash, - receivedWith = listOf(receivedWith) - ) - nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(origin.paymentHash, listOf(receivedWith))) - } - else -> { + when (action.origin) { + is Origin.OnChainWallet -> { // this is a swap, there was no pre-existing invoice, we need to create a fake one val incomingPayment = db.addIncomingPayment( preimage = randomBytes32(), // not used, placeholder @@ -158,46 +141,39 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment ) nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(incomingPayment.paymentHash, listOf(receivedWith))) } + is Origin.OffChainPayment -> { + // There is nothing to do, since we haven't been paid anything in the funding/splice transaction. + // We will receive HTLCs later for the payment that triggered the on-the-fly funding transaction. + } + null -> {} } } - /** - * Process an incoming htlc. - * Before calling this, the htlc must be committed and ack-ed by both sides. - * - * @return A result that indicates whether or not the packet was - * accepted, rejected, or still pending (as the case may be for multipart payments). - * Also includes the list of actions to be queued. - */ - suspend fun process(htlc: UpdateAddHtlc, currentBlockHeight: Int): ProcessAddResult { - // Security note: - // There are several checks we could perform before decrypting the onion. - // However an error message here would differ from an error message below, - // as we don't know the `onion.totalAmount` yet. - // So to prevent any kind of information leakage, we always peel the onion first. - return when (val res = toPaymentPart(privateKey, htlc)) { - is Either.Left -> res.value - is Either.Right -> processPaymentPart(res.value, currentBlockHeight) - } + /** Process an incoming htlc. Before calling this, the htlc must be committed and ack-ed by both peers. */ + suspend fun process(htlc: UpdateAddHtlc, currentBlockHeight: Int, currentFeerate: FeeratePerKw, remoteFundingRates: LiquidityAds.WillFundRates?): ProcessAddResult { + return process(Either.Right(htlc), currentBlockHeight, currentFeerate, remoteFundingRates) } - /** - * Process an incoming pay-to-open request. - * This is very similar to the processing of an htlc. - */ - suspend fun process(payToOpenRequest: PayToOpenRequest, currentBlockHeight: Int): ProcessAddResult { - return when (val res = toPaymentPart(privateKey, payToOpenRequest)) { + /** Process an incoming on-the-fly funding request. */ + suspend fun process(htlc: WillAddHtlc, currentBlockHeight: Int, currentFeerate: FeeratePerKw, remoteFundingRates: LiquidityAds.WillFundRates?): ProcessAddResult { + return process(Either.Left(htlc), currentBlockHeight, currentFeerate, remoteFundingRates) + } + + private suspend fun process(htlc: Either, currentBlockHeight: Int, currentFeerate: FeeratePerKw, remoteFundingRates: LiquidityAds.WillFundRates?): ProcessAddResult { + // There are several checks we could perform *before* decrypting the onion. + // But we need to carefully handle which error message is returned to prevent information leakage, so we always peel the onion first. + return when (val res = toPaymentPart(privateKey, htlc)) { is Either.Left -> res.value - is Either.Right -> processPaymentPart(res.value, currentBlockHeight) + is Either.Right -> processPaymentPart(res.value, currentBlockHeight, currentFeerate, remoteFundingRates) } } /** 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, remoteFundingRates: LiquidityAds.WillFundRates?): ProcessAddResult { val logger = MDCLogger(logger.logger, staticMdc = paymentPart.mdc()) when (paymentPart) { is HtlcPart -> logger.info { "processing htlc part expiry=${paymentPart.htlc.cltvExpiry}" } - is PayToOpenPart -> logger.info { "processing pay-to-open part amount=${paymentPart.payToOpenRequest.amountMsat} fees=${paymentPart.payToOpenRequest.payToOpenFeeSatoshis}" } + is WillAddHtlcPart -> logger.info { "processing on-the-fly funding part amount=${paymentPart.amount} expiry=${paymentPart.htlc.expiry}" } } return when (val validationResult = validatePaymentPart(paymentPart, currentBlockHeight)) { is Either.Left -> validationResult.value @@ -226,98 +202,162 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment ProcessAddResult.Rejected(listOf(action), incomingPayment) } } - is PayToOpenPart -> { - logger.info { "rejecting pay-to-open part for an invoice that has already been paid" } - val action = actionForPayToOpenFailure(privateKey, IncorrectOrUnknownPaymentDetails(paymentPart.totalAmount, currentBlockHeight.toLong()), paymentPart.payToOpenRequest) + is WillAddHtlcPart -> { + logger.info { "rejecting on-the-fly funding part for an invoice that has already been paid" } + val action = actionForWillAddHtlcFailure(privateKey, IncorrectOrUnknownPaymentDetails(paymentPart.totalAmount, currentBlockHeight.toLong()), paymentPart.htlc) ProcessAddResult.Rejected(listOf(action), incomingPayment) } } } else { val payment = pending[paymentPart.paymentHash]?.add(paymentPart) ?: PendingPayment(paymentPart) - when { + return when { paymentPart.totalAmount != payment.totalAmount -> { // Bolt 04: // - SHOULD fail the entire HTLC set if `total_msat` is not the same for all HTLCs in the set. logger.warning { "invalid total_amount_msat: ${paymentPart.totalAmount}, expected ${payment.totalAmount}" } - val actions = payment.parts.map { part -> - val failureMsg = IncorrectOrUnknownPaymentDetails(part.totalAmount, currentBlockHeight.toLong()) - when (part) { - is HtlcPart -> actionForFailureMessage(failureMsg, part.htlc) - is PayToOpenPart -> actionForPayToOpenFailure(privateKey, failureMsg, part.payToOpenRequest) // NB: this will fail all parts, we could only return one - } - } - pending.remove(paymentPart.paymentHash) - return ProcessAddResult.Rejected(actions, incomingPayment) + val failure = IncorrectOrUnknownPaymentDetails(payment.totalAmount, currentBlockHeight.toLong()) + rejectPayment(payment, incomingPayment, failure) } - payment.amountReceived < payment.totalAmount -> { + payment.amountReceived + payment.fundingFee < payment.totalAmount -> { // Still waiting for more payments. pending[paymentPart.paymentHash] = payment - return ProcessAddResult.Pending(incomingPayment, payment) + ProcessAddResult.Pending(incomingPayment, payment) } else -> { - if (payment.parts.filterIsInstance().isNotEmpty()) { - // We consider the total amount received (not only the pay-to-open parts) to evaluate whether or not to accept the payment - val payToOpenFee = payment.parts.filterIsInstance().map { it.payToOpenRequest.payToOpenFeeSatoshis }.sum() - nodeParams.liquidityPolicy.value.maybeReject(payment.amountReceived, payToOpenFee.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, logger)?.let { rejected -> - logger.info { "rejecting pay-to-open: reason=${rejected.reason}" } - nodeParams._nodeEvents.emit(rejected) - val actions = payment.parts.map { part -> - val failureMsg = TemporaryNodeFailure - when (part) { - is HtlcPart -> actionForFailureMessage(failureMsg, part.htlc) - is PayToOpenPart -> actionForPayToOpenFailure(privateKey, failureMsg, part.payToOpenRequest) // NB: this will fail all parts, we could only return one + val htlcParts = payment.parts.filterIsInstance() + val willAddHtlcParts = payment.parts.filterIsInstance() + when { + payment.parts.size > nodeParams.maxAcceptedHtlcs -> { + logger.warning { "rejecting on-the-fly funding: too many parts (${payment.parts.size} > ${nodeParams.maxAcceptedHtlcs}" } + nodeParams._nodeEvents.emit(LiquidityEvents.Rejected(payment.amountReceived, 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.TooManyParts(payment.parts.size))) + rejectPayment(payment, incomingPayment, TemporaryNodeFailure) + } + willAddHtlcParts.isNotEmpty() -> when (val result = validateOnTheFlyFundingRate(willAddHtlcParts.map { it.amount }.sum(), currentFeerate, remoteFundingRates)) { + is Either.Left -> { + logger.warning { "rejecting on-the-fly funding: reason=${result.value.reason}" } + nodeParams._nodeEvents.emit(result.value) + rejectPayment(payment, incomingPayment, TemporaryNodeFailure) + } + is Either.Right -> { + val (requestedAmount, fundingRate) = result.value + val actions = listOf(AddLiquidityForIncomingPayment(payment.amountReceived, requestedAmount, fundingRate, incomingPayment.preimage, willAddHtlcParts.map { it.htlc })) + val paymentOnlyHtlcs = payment.copy( + // We need to splice before receiving the remaining HTLC parts. + // We extend the duration of the MPP timeout to give more time for funding to complete. + startedAtSeconds = payment.startedAtSeconds + 30, + // We keep the currently added HTLCs, and should receive the remaining HTLCs after the open/splice. + parts = htlcParts.toSet() + ) + when { + paymentOnlyHtlcs.parts.isNotEmpty() -> pending[paymentPart.paymentHash] = paymentOnlyHtlcs + else -> pending.remove(paymentPart.paymentHash) } + ProcessAddResult.Pending(incomingPayment, paymentOnlyHtlcs, actions) } - pending.remove(paymentPart.paymentHash) - return ProcessAddResult.Rejected(actions, incomingPayment) } - } - - when (val finalPayload = paymentPart.finalPayload) { - is PaymentOnion.FinalPayload.Standard -> when (finalPayload.paymentMetadata) { - null -> logger.info { "payment received (${payment.amountReceived}) without payment metadata" } - else -> logger.info { "payment received (${payment.amountReceived}) with payment metadata (${finalPayload.paymentMetadata})" } - } - is PaymentOnion.FinalPayload.Blinded -> logger.info { "payment received (${payment.amountReceived}) with blinded route" } - } - val htlcParts = payment.parts.filterIsInstance() - val payToOpenParts = payment.parts.filterIsInstance() - // We only fill the DB with htlc parts, because we cannot be sure yet that our peer will honor the pay-to-open part(s). - // When the payment contains pay-to-open parts, it will be considered received, but the sum of all parts will be smaller - // than the expected amount. The pay-to-open part(s) will be added once we received the corresponding new channel or a splice-in. - val receivedWith = htlcParts.map { part -> - IncomingPayment.ReceivedWith.LightningPayment( - amount = part.amount, - htlcId = part.htlc.id, - channelId = part.htlc.channelId - ) - } - val actions = buildList { - htlcParts.forEach { part -> - val cmd = ChannelCommand.Htlc.Settlement.Fulfill(part.htlc.id, incomingPayment.preimage, true) - add(WrappedChannelCommand(part.htlc.channelId, cmd)) - } - // We avoid sending duplicate pay-to-open responses, since the preimage is the same for every part. - if (payToOpenParts.isNotEmpty()) { - val response = PayToOpenResponse(nodeParams.chainHash, incomingPayment.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage)) - add(PayToOpenResponseCommand(response)) + else -> when (val fundingFee = validateFundingFee(htlcParts)) { + is Either.Left -> { + logger.warning { "rejecting htlcs with invalid on-the-fly funding fee: ${fundingFee.value.message}" } + val failure = IncorrectOrUnknownPaymentDetails(paymentPart.totalAmount, currentBlockHeight.toLong()) + rejectPayment(payment, incomingPayment, failure) + } + is Either.Right -> { + pending.remove(paymentPart.paymentHash) + val receivedWith = htlcParts.map { part -> IncomingPayment.ReceivedWith.LightningPayment(part.amount, part.htlc.channelId, part.htlc.id, part.htlc.fundingFee) } + val received = IncomingPayment.Received(receivedWith = receivedWith) + val actions = htlcParts.map { part -> + val cmd = ChannelCommand.Htlc.Settlement.Fulfill(part.htlc.id, incomingPayment.preimage, true) + WrappedChannelCommand(part.htlc.channelId, cmd) + } + if (incomingPayment.origin is IncomingPayment.Origin.Offer) { + // We didn't store the Bolt 12 invoice in our DB when receiving the invoice_request (to protect against DoS). + // We need to create the DB entry now otherwise the payment won't be recorded. + db.addIncomingPayment(incomingPayment.preimage, incomingPayment.origin) + } + db.receivePayment(paymentPart.paymentHash, received.receivedWith) + nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(paymentPart.paymentHash, received.receivedWith)) + ProcessAddResult.Accepted(actions, incomingPayment.copy(received = received), received) + } } } + } + } + } + } + } + } - pending.remove(paymentPart.paymentHash) - val received = IncomingPayment.Received(receivedWith = receivedWith) - if (incomingPayment.origin is IncomingPayment.Origin.Offer) { - // We didn't store the Bolt 12 invoice in our DB when receiving the invoice_request (to protect against DoS). - // We need to create the DB entry now otherwise the payment won't be recorded. - db.addIncomingPayment(incomingPayment.preimage, incomingPayment.origin) - } - db.receivePayment(paymentPart.paymentHash, received.receivedWith) - nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(paymentPart.paymentHash, received.receivedWith)) - return ProcessAddResult.Accepted(actions, incomingPayment.copy(received = received), received) + private fun rejectPayment(payment: PendingPayment, incomingPayment: IncomingPayment, failure: FailureMessage): ProcessAddResult.Rejected { + pending.remove(incomingPayment.paymentHash) + val actions = payment.parts.map { part -> + when (part) { + is HtlcPart -> actionForFailureMessage(failure, part.htlc) + is WillAddHtlcPart -> actionForWillAddHtlcFailure(nodeParams.nodePrivateKey, failure, part.htlc) + } + } + return ProcessAddResult.Rejected(actions, incomingPayment) + } + + private fun validateOnTheFlyFundingRate(willAddHtlcAmount: MilliSatoshi, currentFeerate: FeeratePerKw, remoteFundingRates: LiquidityAds.WillFundRates?): Either> { + return when (val liquidityPolicy = nodeParams.liquidityPolicy.value) { + is LiquidityPolicy.Disable -> Either.Left(LiquidityEvents.Rejected(willAddHtlcAmount, 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.PolicySetToDisabled)) + is LiquidityPolicy.Auto -> { + // Whenever we receive on-the-fly funding, we take this opportunity to purchase inbound liquidity, if configured. + // This reduces the frequency of on-chain funding and thus the overall on-chain fees paid. + val additionalInboundLiquidity = liquidityPolicy.inboundLiquidityTarget ?: 0.sat + // We must round up to the nearest satoshi value instead of rounding down. + val requestedAmount = (willAddHtlcAmount + 999.msat).truncateToSatoshi() + additionalInboundLiquidity + when (val fundingRate = remoteFundingRates?.findRate(requestedAmount)) { + null -> Either.Left(LiquidityEvents.Rejected(requestedAmount.toMilliSatoshi(), 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.NoMatchingFundingRate)) + else -> { + // We don't know at that point if we'll need a channel or if we already have one. + // We must use the worst case fees that applies to channel creation. + val fees = fundingRate.fees(currentFeerate, requestedAmount, requestedAmount, isChannelCreation = true).total + val rejected = when { + // We only initiate on-the-fly funding if the missing amount is greater than the fees paid. + // Otherwise our peer may not be able to claim the funding fees from the relayed HTLCs. + willAddHtlcAmount < fees * 2 -> LiquidityEvents.Rejected( + requestedAmount.toMilliSatoshi(), + fees.toMilliSatoshi(), + LiquidityEvents.Source.OffChainPayment, + LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow(willAddHtlcAmount) + ) + else -> liquidityPolicy.maybeReject(requestedAmount.toMilliSatoshi(), fees.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, logger) + } + when (rejected) { + null -> Either.Right(Pair(requestedAmount, fundingRate)) + else -> Either.Left(rejected) + } + } + } + } + } + } + + private suspend fun validateFundingFee(parts: List): Either { + return when (val fundingTxId = parts.map { it.htlc.fundingFee?.fundingTxId }.firstOrNull()) { + is TxId -> { + val channelId = parts.first().htlc.channelId + val paymentHash = parts.first().htlc.paymentHash + val fundingFee = parts.map { it.htlc.fundingFee?.amount ?: 0.msat }.sum() + when (val purchase = db.getInboundLiquidityPurchase(fundingTxId)?.purchase) { + null -> Either.Left(UnexpectedLiquidityAdsFundingFee(channelId, fundingTxId)) + else -> { + val fundingFeeOk = when (val details = purchase.paymentDetails) { + is LiquidityAds.PaymentDetails.FromFutureHtlc -> details.paymentHashes.contains(paymentHash) && fundingFee <= purchase.fees.total.toMilliSatoshi() + is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> details.preimages.any { Crypto.sha256(it).byteVector32() == paymentHash } && fundingFee <= purchase.fees.total.toMilliSatoshi() + // Fees have already been paid from our channel balance. + is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> details.paymentHashes.contains(paymentHash) && fundingFee == 0.msat + is LiquidityAds.PaymentDetails.FromChannelBalance -> false + } + when { + fundingFeeOk -> Either.Right(LiquidityAds.FundingFee(fundingFee, fundingTxId)) + else -> Either.Left(InvalidLiquidityAdsFundingFee(channelId, fundingTxId, paymentHash, purchase.fees.total, fundingFee)) } } } } + else -> Either.Right(null) } } @@ -433,7 +473,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment payment.parts.forEach { part -> when (part) { is HtlcPart -> actions += actionForFailureMessage(PaymentTimeout, part.htlc) - is PayToOpenPart -> actions += actionForPayToOpenFailure(privateKey, PaymentTimeout, part.payToOpenRequest) + is WillAddHtlcPart -> actions += actionForWillAddHtlcFailure(privateKey, PaymentTimeout, part.htlc) } } } @@ -459,38 +499,29 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment /** * If we are disconnected, we must forget pending payment parts. - * Pay-to-open requests will be forgotten by the LSP, so we need to do the same otherwise we will accept outdated ones. + * On-the-fly funding proposals will be forgotten by our peer, so we need to do the same. * Offered HTLCs that haven't been resolved will be re-processed when we reconnect. */ fun purgePendingPayments() { - pending.forEach { (paymentHash, pending) -> logger.info { "purging pending incoming payments for paymentHash=$paymentHash: ${pending.parts.map { it.toString() }.joinToString(", ")}" } } + pending.forEach { (paymentHash, pending) -> logger.info { "purging pending incoming payments for paymentHash=$paymentHash: ${pending.parts.joinToString(", ") { it.toString() }}" } } pending.clear() } companion object { /** Convert an incoming htlc to a payment part abstraction. Payment parts are then summed together to reach the full payment amount. */ - private fun toPaymentPart(privateKey: PrivateKey, htlc: UpdateAddHtlc): Either { - // NB: IncomingPacket.decrypt does additional validation on top of IncomingPacket.decryptOnion + private fun toPaymentPart(privateKey: PrivateKey, htlc: Either): Either { return when (val decrypted = IncomingPaymentPacket.decrypt(htlc, privateKey)) { - is Either.Left -> { // Unable to decrypt onion - val action = actionForFailureMessage(decrypted.value, htlc) - Either.Left(ProcessAddResult.Rejected(listOf(action), null)) - } - is Either.Right -> Either.Right(HtlcPart(htlc, decrypted.value)) - } - } - - /** - * Convert a incoming pay-to-open request to a payment part abstraction. - * This is very similar to the processing of a htlc, except that we only have a packet, to decrypt into a final payload. - */ - private fun toPaymentPart(privateKey: PrivateKey, payToOpenRequest: PayToOpenRequest): Either { - return when (val decrypted = IncomingPaymentPacket.decryptOnion(payToOpenRequest.paymentHash, payToOpenRequest.finalPacket, privateKey, payToOpenRequest.blinding)) { is Either.Left -> { - val action = actionForPayToOpenFailure(privateKey, decrypted.value, payToOpenRequest) + val action = when (htlc) { + is Either.Left -> actionForWillAddHtlcFailure(privateKey, decrypted.value, htlc.value) + is Either.Right -> actionForFailureMessage(decrypted.value, htlc.value) + } Either.Left(ProcessAddResult.Rejected(listOf(action), null)) } - is Either.Right -> Either.Right(PayToOpenPart(payToOpenRequest, decrypted.value)) + is Either.Right -> when (htlc) { + is Either.Left -> Either.Right(WillAddHtlcPart(htlc.value, decrypted.value)) + is Either.Right -> Either.Right(HtlcPart(htlc.value, decrypted.value)) + } } } @@ -501,7 +532,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment } val rejectedAction = when (paymentPart) { is HtlcPart -> actionForFailureMessage(failureMsg, paymentPart.htlc) - is PayToOpenPart -> actionForPayToOpenFailure(privateKey, failureMsg, paymentPart.payToOpenRequest) + is WillAddHtlcPart -> actionForWillAddHtlcFailure(privateKey, failureMsg, paymentPart.htlc) } return ProcessAddResult.Rejected(listOf(rejectedAction), incomingPayment) } @@ -514,13 +545,9 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment return WrappedChannelCommand(htlc.channelId, cmd) } - fun actionForPayToOpenFailure(privateKey: PrivateKey, failure: FailureMessage, payToOpenRequest: PayToOpenRequest): PayToOpenResponseCommand { - val reason = ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(failure) - val encryptedReason = when (failure) { - is BadOnion -> null - else -> OutgoingPaymentPacket.buildHtlcFailure(privateKey, payToOpenRequest.paymentHash, payToOpenRequest.finalPacket, reason).right - } - return PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest.chainHash, payToOpenRequest.paymentHash, PayToOpenResponse.Result.Failure(encryptedReason))) + private fun actionForWillAddHtlcFailure(privateKey: PrivateKey, failure: FailureMessage, htlc: WillAddHtlc): SendOnTheFlyFundingMessage { + val msg = OutgoingPaymentPacket.buildWillAddHtlcFailure(privateKey, htlc, failure) + return SendOnTheFlyFundingMessage(msg) } private fun minFinalCltvExpiry(paymentRequest: Bolt11Invoice, currentBlockHeight: Int): CltvExpiry { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt index b95f861d9..ee709a8a6 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt @@ -6,14 +6,23 @@ import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.utils.Either import fr.acinq.bitcoin.utils.flatMap +import fr.acinq.lightning.CltvExpiry import fr.acinq.lightning.Features +import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.crypto.RouteBlinding import fr.acinq.lightning.crypto.sphinx.Sphinx import fr.acinq.lightning.crypto.sphinx.Sphinx.hash +import fr.acinq.lightning.utils.msat import fr.acinq.lightning.wire.* object IncomingPaymentPacket { + /** Decrypt the onion packet of a received htlc. */ + fun decrypt(add: UpdateAddHtlc, privateKey: PrivateKey): Either = decrypt(Either.Right(add), privateKey) + + /** Decrypt the onion packet of a received on-the-fly funding request. */ + fun decrypt(add: WillAddHtlc, privateKey: PrivateKey): Either = decrypt(Either.Left(add), privateKey) + /** * Decrypt the onion packet of a received htlc. We expect to be the final recipient, and we validate that the HTLC * fields match the onion fields (this prevents intermediate nodes from sending an invalid amount or expiry). @@ -24,29 +33,37 @@ object IncomingPaymentPacket { * - a decrypted and valid onion final payload * - or a Bolt4 failure message that can be returned to the sender if the HTLC is invalid */ - fun decrypt(add: UpdateAddHtlc, privateKey: PrivateKey): Either { - return decryptOnion(add.paymentHash, add.onionRoutingPacket, privateKey, add.blinding).flatMap { outer -> + fun decrypt(add: Either, privateKey: PrivateKey): Either { + // The previous node may forward a smaller amount than expected to cover liquidity fees. + // But the amount used for validation should take this funding fee into account. + // We will verify later in the IncomingPaymentHandler whether the funding fee is valid or not. + val htlcAmount = add.fold({ it.amount }, { it.amountMsat + (it.fundingFee?.amount ?: 0.msat) }) + val htlcExpiry = add.fold({ it.expiry }, { it.cltvExpiry }) + val paymentHash = add.fold({ it.paymentHash }, { it.paymentHash }) + val blinding = add.fold({ it.blinding }, { it.blinding }) + val onion = add.fold({ it.finalPacket }, { it.onionRoutingPacket }) + return decryptOnion(paymentHash, onion, privateKey, blinding).flatMap { outer -> when (outer) { is PaymentOnion.FinalPayload.Standard -> when (val trampolineOnion = outer.records.get()) { - null -> validate(add, outer) + null -> validate(htlcAmount, htlcExpiry, outer) else -> { - when (val inner = decryptOnion(add.paymentHash, trampolineOnion.packet, privateKey, null)) { + when (val inner = decryptOnion(paymentHash, trampolineOnion.packet, privateKey, null)) { is Either.Left -> Either.Left(inner.value) is Either.Right -> when (val innerPayload = inner.value) { - is PaymentOnion.FinalPayload.Standard -> validate(add, outer, innerPayload) + is PaymentOnion.FinalPayload.Standard -> validate(htlcAmount, htlcExpiry, outer, innerPayload) // Blinded trampoline paths are not supported. is PaymentOnion.FinalPayload.Blinded -> Either.Left(InvalidOnionPayload(0, 0)) } } } } - is PaymentOnion.FinalPayload.Blinded -> validate(add, outer) + is PaymentOnion.FinalPayload.Blinded -> validate(htlcAmount, htlcExpiry, onion, outer) } } } - fun decryptOnion(paymentHash: ByteVector32, packet: OnionRoutingPacket, privateKey: PrivateKey, blinding: PublicKey?): Either { + private fun decryptOnion(paymentHash: ByteVector32, packet: OnionRoutingPacket, privateKey: PrivateKey, blinding: PublicKey?): Either { val onionDecryptionKey = blinding?.let { RouteBlinding.derivePrivateKey(privateKey, it) } ?: privateKey return Sphinx.peel(onionDecryptionKey, paymentHash, packet).flatMap { decrypted -> when { @@ -80,32 +97,32 @@ object IncomingPaymentPacket { .flatMap { blindedTlvs -> PaymentOnion.FinalPayload.Blinded.validate(tlvs, blindedTlvs) } } - private fun validate(add: UpdateAddHtlc, payload: PaymentOnion.FinalPayload.Standard): Either { + private fun validate(htlcAmount: MilliSatoshi, htlcExpiry: CltvExpiry, payload: PaymentOnion.FinalPayload.Standard): Either { return when { - add.amountMsat < payload.amount -> Either.Left(FinalIncorrectHtlcAmount(add.amountMsat)) - add.cltvExpiry < payload.expiry -> Either.Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) + htlcAmount < payload.amount -> Either.Left(FinalIncorrectHtlcAmount(htlcAmount)) + htlcExpiry < payload.expiry -> Either.Left(FinalIncorrectCltvExpiry(htlcExpiry)) else -> Either.Right(payload) } } - private fun validate(add: UpdateAddHtlc, payload: PaymentOnion.FinalPayload.Blinded): Either { + private fun validate(htlcAmount: MilliSatoshi, htlcExpiry: CltvExpiry, onion: OnionRoutingPacket, payload: PaymentOnion.FinalPayload.Blinded): Either { return when { - payload.recipientData.paymentConstraints?.let { add.amountMsat < it.minAmount } == true -> Either.Left(InvalidOnionBlinding(hash(add.onionRoutingPacket))) - payload.recipientData.paymentConstraints?.let { it.maxCltvExpiry < add.cltvExpiry } == true -> Either.Left(InvalidOnionBlinding(hash(add.onionRoutingPacket))) + payload.recipientData.paymentConstraints?.let { htlcAmount < it.minAmount } == true -> Either.Left(InvalidOnionBlinding(hash(onion))) + payload.recipientData.paymentConstraints?.let { it.maxCltvExpiry < htlcExpiry } == true -> Either.Left(InvalidOnionBlinding(hash(onion))) // We currently don't set the allowed_features field in our invoices. - !Features.areCompatible(Features.empty, payload.recipientData.allowedFeatures) -> Either.Left(InvalidOnionBlinding(hash(add.onionRoutingPacket))) - add.amountMsat < payload.amount -> Either.Left(InvalidOnionBlinding(hash(add.onionRoutingPacket))) - add.cltvExpiry < payload.expiry -> Either.Left(InvalidOnionBlinding(hash(add.onionRoutingPacket))) + !Features.areCompatible(Features.empty, payload.recipientData.allowedFeatures) -> Either.Left(InvalidOnionBlinding(hash(onion))) + htlcAmount < payload.amount -> Either.Left(InvalidOnionBlinding(hash(onion))) + htlcExpiry < payload.expiry -> Either.Left(InvalidOnionBlinding(hash(onion))) else -> Either.Right(payload) } } - private fun validate(add: UpdateAddHtlc, outerPayload: PaymentOnion.FinalPayload.Standard, innerPayload: PaymentOnion.FinalPayload.Standard): Either { + private fun validate(htlcAmount: MilliSatoshi, htlcExpiry: CltvExpiry, outerPayload: PaymentOnion.FinalPayload.Standard, innerPayload: PaymentOnion.FinalPayload.Standard): Either { return when { - add.amountMsat < outerPayload.amount -> Either.Left(FinalIncorrectHtlcAmount(add.amountMsat)) - add.cltvExpiry < outerPayload.expiry -> Either.Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) + htlcAmount < outerPayload.amount -> Either.Left(FinalIncorrectHtlcAmount(htlcAmount)) + htlcExpiry < outerPayload.expiry -> Either.Left(FinalIncorrectCltvExpiry(htlcExpiry)) // previous trampoline didn't forward the right expiry - outerPayload.expiry != innerPayload.expiry -> Either.Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) + outerPayload.expiry != innerPayload.expiry -> Either.Left(FinalIncorrectCltvExpiry(htlcExpiry)) // previous trampoline didn't forward the right amount outerPayload.totalAmount != innerPayload.amount -> Either.Left(FinalIncorrectHtlcAmount(outerPayload.totalAmount)) else -> { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt index 101dc7bb7..2209b2523 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt @@ -3,24 +3,26 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.LiquidityEvents import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.logging.* +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() /** - * Allow automated liquidity managements, within relative and absolute fee limits. Both conditions must be met. + * Allow automated liquidity management, within relative and absolute fee limits. Both conditions must be met. + * + * @param inboundLiquidityTarget amount of inbound liquidity the buyer would like to purchase whenever necessary (can be set to null to disable) * @param maxAbsoluteFee max absolute fee * @param maxRelativeFeeBasisPoints max relative fee (all included: service fee and mining fee) (1_000 bips = 10 %) * @param skipAbsoluteFeeCheck only applies for off-chain payments, being more lax may make sense when the sender doesn't retry payments */ - data class Auto(val maxAbsoluteFee: Satoshi, val maxRelativeFeeBasisPoints: Int, val skipAbsoluteFeeCheck: Boolean) : LiquidityPolicy() + data class Auto(val inboundLiquidityTarget: Satoshi?, val maxAbsoluteFee: Satoshi, val maxRelativeFeeBasisPoints: Int, val skipAbsoluteFeeCheck: Boolean) : LiquidityPolicy() - /** Make decision for a particular liquidity event */ + /** Make a decision for a particular liquidity event. */ fun maybeReject(amount: MilliSatoshi, fee: MilliSatoshi, source: LiquidityEvents.Source, logger: MDCLogger): LiquidityEvents.Rejected? { return when (this) { is Disable -> LiquidityEvents.Rejected.Reason.PolicySetToDisabled @@ -28,13 +30,12 @@ sealed class LiquidityPolicy { val maxAbsoluteFee = if (skipAbsoluteFeeCheck && source == LiquidityEvents.Source.OffChainPayment) Long.MAX_VALUE.msat else this.maxAbsoluteFee.toMilliSatoshi() val maxRelativeFee = amount * maxRelativeFeeBasisPoints / 10_000 logger.info { "liquidity policy check: fee=$fee maxAbsoluteFee=$maxAbsoluteFee maxRelativeFee=$maxRelativeFee policy=$this" } - if (fee > maxRelativeFee) { - LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(maxRelativeFeeBasisPoints) - } else if (fee > maxAbsoluteFee) { - LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(this.maxAbsoluteFee) - } else null + when { + fee > maxRelativeFee -> LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(maxRelativeFeeBasisPoints) + fee > maxAbsoluteFee -> LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(this.maxAbsoluteFee) + else -> null // accept + } } }?.let { reason -> LiquidityEvents.Rejected(amount, fee, source, reason) } } - } \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentPacket.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentPacket.kt index 7d0a1fc0d..920ac64d1 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentPacket.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentPacket.kt @@ -17,10 +17,7 @@ import fr.acinq.lightning.router.ChannelHop import fr.acinq.lightning.router.Hop import fr.acinq.lightning.router.NodeHop import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.wire.FailureMessage -import fr.acinq.lightning.wire.OnionPaymentPayloadTlv -import fr.acinq.lightning.wire.OnionRoutingPacket -import fr.acinq.lightning.wire.PaymentOnion +import fr.acinq.lightning.wire.* object OutgoingPaymentPacket { @@ -165,4 +162,12 @@ object OutgoingPaymentPacket { } } + fun buildWillAddHtlcFailure(nodeSecret: PrivateKey, willAddHtlc: WillAddHtlc, failure: FailureMessage): OnTheFlyFundingMessage { + val reason = ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(failure) + return when (val f = buildHtlcFailure(nodeSecret, willAddHtlc.paymentHash, willAddHtlc.finalPacket, reason)) { + is Either.Right -> WillFailHtlc(willAddHtlc.id, willAddHtlc.paymentHash, f.value) + is Either.Left -> WillFailMalformedHtlc(willAddHtlc.id, willAddHtlc.paymentHash, Sphinx.hash(willAddHtlc.finalPacket), f.value.code) + } + } + } \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt index c714a277b..1ea499568 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt @@ -43,6 +43,7 @@ import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.channel.ChannelFlags import fr.acinq.lightning.channel.InteractiveTxOutput import fr.acinq.lightning.channel.SpliceStatus import fr.acinq.lightning.channel.states.* @@ -97,7 +98,7 @@ object UpdateAddHtlcSerializer : KSerializer { override fun deserialize(decoder: Decoder): UpdateAddHtlc { val surrogate = decoder.decodeSerializableValue(Surrogate.serializer()) - return UpdateAddHtlc(surrogate.channelId, surrogate.id, surrogate.amountMsat, surrogate.paymentHash, surrogate.cltvExpiry, surrogate.onionRoutingPacket, null) + return UpdateAddHtlc(surrogate.channelId, surrogate.id, surrogate.amountMsat, surrogate.paymentHash, surrogate.cltvExpiry, surrogate.onionRoutingPacket, null, null) } } @@ -295,7 +296,7 @@ internal data class RevokedCommitPublished( * This means that they will be recomputed once when we convert serialized data to their "live" counterparts. */ @Serializable -internal data class LocalParams constructor( +internal data class LocalParams( val nodeId: PublicKey, val fundingKeyPath: KeyPath, val dustLimit: Satoshi, @@ -317,6 +318,7 @@ internal data class LocalParams constructor( toSelfDelay, maxAcceptedHtlcs, isFunder, + isFunder, defaultFinalScriptPubKey, features ) @@ -410,7 +412,7 @@ internal data class Commitments( ChannelVersion.channelFeatures, localParams.export(), remoteParams.export(), - channelFlags + ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = false), ), fr.acinq.lightning.channel.CommitmentChanges( localChanges.export(), @@ -534,7 +536,6 @@ internal data class Normal( remoteShutdown, null, SpliceStatus.None, - listOf(), ) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt index 38a4e595e..1dc862ed1 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt @@ -43,6 +43,7 @@ import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.channel.ChannelFlags import fr.acinq.lightning.channel.InteractiveTxOutput import fr.acinq.lightning.channel.SpliceStatus import fr.acinq.lightning.channel.states.* @@ -97,7 +98,7 @@ object UpdateAddHtlcSerializer : KSerializer { override fun deserialize(decoder: Decoder): UpdateAddHtlc { val surrogate = decoder.decodeSerializableValue(Surrogate.serializer()) - return UpdateAddHtlc(surrogate.channelId, surrogate.id, surrogate.amountMsat, surrogate.paymentHash, surrogate.cltvExpiry, surrogate.onionRoutingPacket, null) + return UpdateAddHtlc(surrogate.channelId, surrogate.id, surrogate.amountMsat, surrogate.paymentHash, surrogate.cltvExpiry, surrogate.onionRoutingPacket, null, null) } } @@ -295,7 +296,7 @@ internal data class RevokedCommitPublished( * This means that they will be recomputed once when we convert serialized data to their "live" counterparts. */ @Serializable -internal data class LocalParams constructor( +internal data class LocalParams( val nodeId: PublicKey, val fundingKeyPath: KeyPath, val dustLimit: Satoshi, @@ -317,6 +318,7 @@ internal data class LocalParams constructor( toSelfDelay, maxAcceptedHtlcs, isFunder, + isFunder, defaultFinalScriptPubKey, features ) @@ -403,7 +405,7 @@ internal data class Commitments( channelFeatures.export(), localParams.export(), remoteParams.export(), - channelFlags + ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = false), ), fr.acinq.lightning.channel.CommitmentChanges( localChanges.export(), @@ -536,7 +538,6 @@ internal data class Normal( remoteShutdown, closingFeerates?.export(), SpliceStatus.None, - listOf(), ) } 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 d7d5ff33a..a0d4c40a1 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt @@ -15,7 +15,10 @@ import fr.acinq.lightning.channel.states.* import fr.acinq.lightning.crypto.ShaChain import fr.acinq.lightning.transactions.* import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.* -import fr.acinq.lightning.utils.* +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.utils.toByteVector import fr.acinq.lightning.wire.* object Deserialization { @@ -32,13 +35,15 @@ object Deserialization { 0x09 -> readLegacyWaitForFundingLocked() 0x00 -> readWaitForFundingConfirmed() 0x01 -> readWaitForChannelReady() - 0x02 -> readNormal() + 0x02 -> readNormalLegacy() 0x03 -> readShuttingDown() 0x04 -> readNegotiating() 0x05 -> readClosing() 0x06 -> readWaitForRemotePublishFutureCommitment() 0x07 -> readClosed() - 0x0a -> readWaitForFundingSigned() + 0x0a -> readWaitForFundingSignedLegacy() + 0x0b -> readNormal() + 0x0c -> readWaitForFundingSigned() else -> error("unknown discriminator $discriminator for class ${PersistedChannelState::class}") } @@ -65,6 +70,17 @@ object Deserialization { localPushAmount = readNumber().msat, remotePushAmount = readNumber().msat, remoteSecondPerCommitmentPoint = readPublicKey(), + liquidityPurchase = readNullable { readLiquidityPurchase() }, + channelOrigin = readNullable { readChannelOrigin() } + ) + + private fun Input.readWaitForFundingSignedLegacy() = WaitForFundingSigned( + channelParams = readChannelParams(), + signingSession = readInteractiveTxSigningSession(), + localPushAmount = readNumber().msat, + remotePushAmount = readNumber().msat, + remoteSecondPerCommitmentPoint = readPublicKey(), + liquidityPurchase = null, channelOrigin = readNullable { readChannelOrigin() } ) @@ -97,19 +113,24 @@ object Deserialization { closingFeerates = readNullable { readClosingFeerates() }, spliceStatus = when (val discriminator = read()) { 0x00 -> SpliceStatus.None - 0x01 -> SpliceStatus.WaitingForSigs( - session = readInteractiveTxSigningSession(), - origins = readCollection { readChannelOrigin() as Origin.PayToOpenOrigin }.toList() - ) + 0x01 -> SpliceStatus.WaitingForSigs(readInteractiveTxSigningSession(), readNullable { readLiquidityPurchase() }, readCollection { readChannelOrigin() }.toList()) + else -> error("unknown discriminator $discriminator for class ${SpliceStatus::class}") + }, + ) + + private fun Input.readNormalLegacy(): Normal = Normal( + commitments = readCommitments(), + shortChannelId = ShortChannelId(readNumber()), + channelUpdate = readLightningMessage() as ChannelUpdate, + remoteChannelUpdate = readNullable { readLightningMessage() as ChannelUpdate }, + localShutdown = readNullable { readLightningMessage() as Shutdown }, + remoteShutdown = readNullable { readLightningMessage() as Shutdown }, + closingFeerates = readNullable { readClosingFeerates() }, + spliceStatus = when (val discriminator = read()) { + 0x00 -> SpliceStatus.None + 0x01 -> SpliceStatus.WaitingForSigs(readInteractiveTxSigningSession(), null, readCollection { readChannelOrigin() }.toList()) else -> error("unknown discriminator $discriminator for class ${SpliceStatus::class}") }, - liquidityLeases = when { - availableBytes == 0 -> listOf() - else -> when (val discriminator = read()) { - 0x01 -> readCollection { readLiquidityLease() }.toList() - else -> error("unknown discriminator $discriminator for class ${Normal::class}") - } - } ) private fun Input.readShuttingDown(): ShuttingDown = ShuttingDown( @@ -388,30 +409,50 @@ object Deserialization { ) ) - private fun Input.readLiquidityLease(): LiquidityAds.Lease = LiquidityAds.Lease( - amount = readNumber().sat, - fees = LiquidityAds.LeaseFees(miningFee = readNumber().sat, serviceFee = readNumber().sat), - sellerSig = readByteVector64(), - witness = LiquidityAds.LeaseWitness( - fundingScript = readNBytes(readNumber().toInt())!!.toByteVector(), - leaseDuration = readNumber().toInt(), - leaseEnd = readNumber().toInt(), - maxRelayFeeProportional = readNumber().toInt(), - maxRelayFeeBase = readNumber().msat, - ), - ) + private fun Input.readLiquidityFees(): LiquidityAds.Fees = LiquidityAds.Fees(miningFee = readNumber().sat, serviceFee = readNumber().sat) + + private fun Input.readLiquidityPurchase(): LiquidityAds.Purchase = when (val discriminator = read()) { + 0x00 -> LiquidityAds.Purchase.Standard( + amount = readNumber().sat, + fees = readLiquidityFees(), + paymentDetails = when (val paymentDetailsDiscriminator = read()) { + 0x00 -> LiquidityAds.PaymentDetails.FromChannelBalance + 0x80 -> LiquidityAds.PaymentDetails.FromFutureHtlc(readCollection { readByteVector32() }.toList()) + 0x81 -> LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(readCollection { readByteVector32() }.toList()) + 0x82 -> LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(readCollection { readByteVector32() }.toList()) + else -> error("unknown discriminator $paymentDetailsDiscriminator for class ${LiquidityAds.PaymentDetails::class}") + } + ) + else -> error("unknown discriminator $discriminator for class ${LiquidityAds.Purchase::class}") + } + + private fun Input.skipLegacyLiquidityLease() { + readNumber() // amount + readNumber() // mining fee + readNumber() // service fee + readByteVector64() // seller signature + readNBytes(readNumber().toInt()) // funding script + readNumber() // lease duration + readNumber() // lease end + readNumber() // maximum proportional relay fee + readNumber() // maximum base relay fee + } private fun Input.readInteractiveTxSigningSession(): InteractiveTxSigningSession { val fundingParams = readInteractiveTxParams() val fundingTxIndex = readNumber() val fundingTx = readSignedSharedTransaction() as PartiallySignedSharedTransaction - // liquidityLease and localCommit are logically independent, this is just a serialization trick for backwards - // compatibility since the liquidityLease field was introduced later. - val (liquidityLease, localCommit) = when (val discriminator = read()) { - 0 -> Pair(null, Either.Left(readUnsignedLocalCommitWithHtlcs())) - 1 -> Pair(null, Either.Right(readLocalCommitWithHtlcs())) - 2 -> Pair(readLiquidityLease(), Either.Left(readUnsignedLocalCommitWithHtlcs())) - 3 -> Pair(readLiquidityLease(), Either.Right(readLocalCommitWithHtlcs())) + val localCommit = when (val discriminator = read()) { + 0 -> Either.Left(readUnsignedLocalCommitWithHtlcs()) + 1 -> Either.Right(readLocalCommitWithHtlcs()) + 2 -> { + skipLegacyLiquidityLease() + Either.Left(readUnsignedLocalCommitWithHtlcs()) + } + 3 -> { + skipLegacyLiquidityLease() + Either.Right(readLocalCommitWithHtlcs()) + } else -> error("unknown discriminator $discriminator for class ${InteractiveTxSigningSession::class}") } val remoteCommit = RemoteCommit( @@ -420,55 +461,80 @@ object Deserialization { txid = readTxId(), remotePerCommitmentPoint = readPublicKey() ) - return InteractiveTxSigningSession(fundingParams, fundingTxIndex, fundingTx, liquidityLease, localCommit, remoteCommit) + return InteractiveTxSigningSession(fundingParams, fundingTxIndex, fundingTx, localCommit, remoteCommit) } private fun Input.readChannelOrigin(): Origin = when (val discriminator = read()) { - 0x01 -> Origin.PayToOpenOrigin( - paymentHash = readByteVector32(), - serviceFee = readNumber().msat, - miningFee = readNumber().sat, - amount = readNumber().msat, + 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 + val amount = readNumber().msat + Origin.OffChainPayment(paymentHash, amount, ChannelManagementFees(miningFee, serviceFee.truncateToSatoshi())) + } + 0x02 -> { + readByteVector32() // unused requestId + val serviceFee = readNumber().msat + val miningFee = readNumber().sat + val amount = readNumber().msat + Origin.OnChainWallet(setOf(), amount, ChannelManagementFees(miningFee, serviceFee.truncateToSatoshi())) + } + 0x03 -> Origin.OffChainPayment( + paymentPreimage = readByteVector32(), + amountBeforeFees = readNumber().msat, + fees = ChannelManagementFees(miningFee = readNumber().sat, serviceFee = readNumber().sat), ) - 0x02 -> Origin.PleaseOpenChannelOrigin( - requestId = readByteVector32(), - serviceFee = readNumber().msat, - miningFee = readNumber().sat, - amount = readNumber().msat, + 0x04 -> Origin.OnChainWallet( + inputs = readCollection { readOutPoint() }.toSet(), + amountBeforeFees = readNumber().msat, + fees = ChannelManagementFees(miningFee = readNumber().sat, serviceFee = readNumber().sat), ) else -> error("unknown discriminator $discriminator for class ${Origin::class}") } + private fun Input.readLocalParams(): LocalParams { + val nodeId = readPublicKey() + val fundingKeyPath = KeyPath(readCollection { readNumber() }.toList()) + val dustLimit = readNumber().sat + val maxHtlcValueInFlightMsat = readNumber() + val htlcMinimum = readNumber().msat + val toSelfDelay = CltvExpiryDelta(readNumber().toInt()) + val maxAcceptedHtlcs = readNumber().toInt() + val flags = readNumber().toInt() + val isChannelOpener = flags.and(1) != 0 + val payCommitTxFees = flags.and(2) != 0 + val defaultFinalScriptPubKey = readDelimitedByteArray().toByteVector() + val features = Features(readDelimitedByteArray().toByteVector()) + return LocalParams(nodeId, fundingKeyPath, dustLimit, maxHtlcValueInFlightMsat, htlcMinimum, toSelfDelay, maxAcceptedHtlcs, isChannelOpener, payCommitTxFees, defaultFinalScriptPubKey, features) + } + + private fun Input.readRemoteParams(): RemoteParams = RemoteParams( + nodeId = readPublicKey(), + dustLimit = readNumber().sat, + maxHtlcValueInFlightMsat = readNumber(), + htlcMinimum = readNumber().msat, + toSelfDelay = CltvExpiryDelta(readNumber().toInt()), + maxAcceptedHtlcs = readNumber().toInt(), + revocationBasepoint = readPublicKey(), + paymentBasepoint = readPublicKey(), + delayedPaymentBasepoint = readPublicKey(), + htlcBasepoint = readPublicKey(), + features = Features(readDelimitedByteArray().toByteVector()) + ) + + private fun Input.readChannelFlags(): ChannelFlags { + val flags = readNumber().toInt() + return ChannelFlags(announceChannel = flags.and(1) != 0, nonInitiatorPaysCommitFees = flags.and(2) != 0) + } + private fun Input.readChannelParams(): ChannelParams = ChannelParams( channelId = readByteVector32(), channelConfig = ChannelConfig(readDelimitedByteArray()), channelFeatures = ChannelFeatures(Features(readDelimitedByteArray()).activated.keys), - localParams = LocalParams( - nodeId = readPublicKey(), - fundingKeyPath = KeyPath(readCollection { readNumber() }.toList()), - dustLimit = readNumber().sat, - maxHtlcValueInFlightMsat = readNumber(), - htlcMinimum = readNumber().msat, - toSelfDelay = CltvExpiryDelta(readNumber().toInt()), - maxAcceptedHtlcs = readNumber().toInt(), - isInitiator = readBoolean(), - defaultFinalScriptPubKey = readDelimitedByteArray().toByteVector(), - features = Features(readDelimitedByteArray().toByteVector()) - ), - remoteParams = RemoteParams( - nodeId = readPublicKey(), - dustLimit = readNumber().sat, - maxHtlcValueInFlightMsat = readNumber(), - htlcMinimum = readNumber().msat, - toSelfDelay = CltvExpiryDelta(readNumber().toInt()), - maxAcceptedHtlcs = readNumber().toInt(), - revocationBasepoint = readPublicKey(), - paymentBasepoint = readPublicKey(), - delayedPaymentBasepoint = readPublicKey(), - htlcBasepoint = readPublicKey(), - features = Features(readDelimitedByteArray().toByteVector()) - ), - channelFlags = readNumber().toByte(), + localParams = readLocalParams(), + remoteParams = readRemoteParams(), + channelFlags = readChannelFlags(), ) private fun Input.readCommitmentChanges(): CommitmentChanges = CommitmentChanges( 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 48aab0206..6261412d8 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt @@ -57,7 +57,7 @@ object Serialization { write(0x01); writeWaitForChannelReady(o) } is Normal -> { - write(0x02); writeNormal(o) + write(0x0b); writeNormal(o) } is ShuttingDown -> { write(0x03); writeShuttingDown(o) @@ -75,7 +75,7 @@ object Serialization { write(0x07); writeClosed(o) } is WaitForFundingSigned -> { - write(0x0a); writeWaitForFundingSigned(o) + write(0x0c); writeWaitForFundingSigned(o) } } @@ -102,6 +102,7 @@ object Serialization { writeNumber(localPushAmount.toLong()) writeNumber(remotePushAmount.toLong()) writePublicKey(remoteSecondPerCommitmentPoint) + writeNullable(liquidityPurchase) { writeLiquidityPurchase(it) } writeNullable(channelOrigin) { writeChannelOrigin(it) } } @@ -140,14 +141,13 @@ object Serialization { is SpliceStatus.WaitingForSigs -> { write(0x01) writeInteractiveTxSigningSession(spliceStatus.session) + writeNullable(spliceStatus.liquidityPurchase) { writeLiquidityPurchase(it) } writeCollection(spliceStatus.origins) { writeChannelOrigin(it) } } else -> { write(0x00) } } - write(0x01) - writeCollection(liquidityLeases) { writeLiquidityLease(it) } } private fun Output.writeShuttingDown(o: ShuttingDown) = o.run { @@ -406,53 +406,43 @@ object Serialization { } } - private fun Output.writeLiquidityLease(lease: LiquidityAds.Lease) { - writeNumber(lease.amount.toLong()) - writeNumber(lease.fees.miningFee.toLong()) - writeNumber(lease.fees.serviceFee.toLong()) - writeByteVector64(lease.sellerSig) - writeNumber(lease.witness.fundingScript.size()) - write(lease.witness.fundingScript.toByteArray()) - writeNumber(lease.witness.leaseDuration) - writeNumber(lease.witness.leaseEnd) - writeNumber(lease.witness.maxRelayFeeProportional) - writeNumber(lease.witness.maxRelayFeeBase.toLong()) + private fun Output.writeLiquidityFees(fees: LiquidityAds.Fees) { + writeNumber(fees.miningFee.toLong()) + writeNumber(fees.serviceFee.toLong()) + } + + private fun Output.writeLiquidityPurchase(purchase: LiquidityAds.Purchase) { + when (purchase) { + is LiquidityAds.Purchase.Standard -> { + write(0x00) // discriminator + writeNumber(purchase.amount.toLong()) + writeLiquidityFees(purchase.fees) + when (val paymentDetails = purchase.paymentDetails) { + is LiquidityAds.PaymentDetails.FromChannelBalance -> write(0x00) + is LiquidityAds.PaymentDetails.FromFutureHtlc -> { + write(0x80) + writeCollection(paymentDetails.paymentHashes) { writeByteVector32(it) } + } + is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> { + write(0x81) + writeCollection(paymentDetails.preimages) { writeByteVector32(it) } + } + is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> { + write(0x82) + writeCollection(paymentDetails.paymentHashes) { writeByteVector32(it) } + } + } + } + } } private fun Output.writeInteractiveTxSigningSession(s: InteractiveTxSigningSession) = s.run { writeInteractiveTxParams(fundingParams) writeNumber(s.fundingTxIndex) writeSignedSharedTransaction(fundingTx) - // The liquidity purchase field was added afterwards. For backwards-compatibility, we extend the discriminator - // we previously used for the local commit to insert the liquidity purchase if available. // Note that we don't bother removing the duplication across HTLCs in the local commit: this is a short-lived // state during which the channel cannot be used for payments. - when (liquidityLease) { - // Before introducing the liquidity purchase field, we serialized the local commit as an Either, with - // discriminators 0 and 1. - null -> when (localCommit) { - is Either.Left -> { - write(0) - writeUnsignedLocalCommitWithHtlcs(localCommit.value) - } - is Either.Right -> { - write(1) - writeLocalCommitWithHtlcs(localCommit.value) - } - } - else -> when (localCommit) { - is Either.Left -> { - write(2) - writeLiquidityLease(liquidityLease) - writeUnsignedLocalCommitWithHtlcs(localCommit.value) - } - is Either.Right -> { - write(3) - writeLiquidityLease(liquidityLease) - writeLocalCommitWithHtlcs(localCommit.value) - } - } - } + writeEither(localCommit, { localCommit -> writeUnsignedLocalCommitWithHtlcs(localCommit) }, { localCommit -> writeLocalCommitWithHtlcs(localCommit) }) remoteCommit.run { writeNumber(index) writeCommitmentSpecWithHtlcs(spec) @@ -462,19 +452,19 @@ object Serialization { } private fun Output.writeChannelOrigin(o: Origin) = when (o) { - is Origin.PayToOpenOrigin -> { - write(0x01) - writeByteVector32(o.paymentHash) - writeNumber(o.serviceFee.toLong()) - writeNumber(o.miningFee.toLong()) - writeNumber(o.amount.toLong()) + is Origin.OffChainPayment -> { + write(0x03) + writeByteVector32(o.paymentPreimage) + writeNumber(o.amountBeforeFees.toLong()) + writeNumber(o.fees.miningFee.toLong()) + writeNumber(o.fees.serviceFee.toLong()) } - is Origin.PleaseOpenChannelOrigin -> { - write(0x02) - writeByteVector32(o.requestId) - writeNumber(o.serviceFee.toLong()) - writeNumber(o.miningFee.toLong()) - writeNumber(o.amount.toLong()) + is Origin.OnChainWallet -> { + write(0x04) + writeCollection(o.inputs) { writeBtcObject(it) } + writeNumber(o.amountBeforeFees.toLong()) + writeNumber(o.fees.miningFee.toLong()) + writeNumber(o.fees.serviceFee.toLong()) } } @@ -490,7 +480,10 @@ object Serialization { writeNumber(htlcMinimum.toLong()) writeNumber(toSelfDelay.toLong()) writeNumber(maxAcceptedHtlcs) - writeBoolean(isInitiator) + // We encode those two booleans in the same byte. + val isOpenerFlag = if (isChannelOpener) 1 else 0 + val payCommitTxFeesFlag = if (paysCommitTxFees) 2 else 0 + writeNumber(isOpenerFlag + payCommitTxFeesFlag) writeDelimited(defaultFinalScriptPubKey.toByteArray()) writeDelimited(features.toByteArray()) } @@ -507,7 +500,10 @@ object Serialization { writePublicKey(htlcBasepoint) writeDelimited(features.toByteArray()) } - writeNumber(channelFlags) + // We encode channel flags in the same byte. + val announceChannelFlag = if (channelFlags.announceChannel) 1 else 0 + val nonInitiatorPaysCommitFeesFlag = if (channelFlags.nonInitiatorPaysCommitFees) 2 else 0 + writeNumber(announceChannelFlag + nonInitiatorPaysCommitFeesFlag) } private fun Output.writeCommitmentChanges(o: CommitmentChanges) = o.run { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/transactions/Transactions.kt b/src/commonMain/kotlin/fr/acinq/lightning/transactions/Transactions.kt index cce8aa158..fe755c57b 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/transactions/Transactions.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/transactions/Transactions.kt @@ -309,7 +309,7 @@ object Transactions { fun makeCommitTxOutputs( localFundingPubkey: PublicKey, remoteFundingPubkey: PublicKey, - localIsInitiator: Boolean, + localPaysCommitTxFees: Boolean, localDustLimit: Satoshi, localRevocationPubkey: PublicKey, toLocalDelay: CltvExpiryDelta, @@ -321,7 +321,7 @@ object Transactions { ): TransactionsCommitmentOutputs { val commitFee = commitTxFee(localDustLimit, spec) - val (toLocalAmount: Satoshi, toRemoteAmount: Satoshi) = if (localIsInitiator) { + val (toLocalAmount: Satoshi, toRemoteAmount: Satoshi) = if (localPaysCommitTxFees) { Pair(spec.toLocal.truncateToSatoshi() - commitFee, spec.toRemote.truncateToSatoshi()) } else { Pair(spec.toLocal.truncateToSatoshi(), spec.toRemote.truncateToSatoshi() - commitFee) @@ -383,11 +383,11 @@ object Transactions { commitTxNumber: Long, localPaymentBasePoint: PublicKey, remotePaymentBasePoint: PublicKey, - localIsInitiator: Boolean, + localIsChannelOpener: Boolean, outputs: TransactionsCommitmentOutputs ): TransactionWithInputInfo.CommitTx { - val txnumber = obscuredCommitTxNumber(commitTxNumber, localIsInitiator, localPaymentBasePoint, remotePaymentBasePoint) - val (sequence, locktime) = encodeTxNumber(txnumber) + val txNumber = obscuredCommitTxNumber(commitTxNumber, localIsChannelOpener, localPaymentBasePoint, remotePaymentBasePoint) + val (sequence, locktime) = encodeTxNumber(txNumber) val tx = Transaction( version = 2, @@ -739,14 +739,14 @@ object Transactions { commitTxInput: InputInfo, localScriptPubKey: ByteArray, remoteScriptPubKey: ByteArray, - localIsInitiator: Boolean, + localPaysClosingFees: Boolean, dustLimit: Satoshi, closingFee: Satoshi, spec: CommitmentSpec ): TransactionWithInputInfo.ClosingTx { require(spec.htlcs.isEmpty()) { "there shouldn't be any pending htlcs" } - val (toLocalAmount, toRemoteAmount) = if (localIsInitiator) { + val (toLocalAmount, toRemoteAmount) = if (localPaysClosingFees) { Pair(spec.toLocal.truncateToSatoshi() - closingFee, spec.toRemote.truncateToSatoshi()) } else { Pair(spec.toLocal.truncateToSatoshi(), spec.toRemote.truncateToSatoshi() - closingFee) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt index 0ac111bab..02da88516 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt @@ -8,7 +8,6 @@ import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.ShortChannelId import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.ChannelType -import fr.acinq.lightning.channel.Origin import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.toByteVector @@ -67,125 +66,26 @@ sealed class ChannelTlv : Tlv { } /** Request inbound liquidity from our peer. */ - data class RequestFunds(val amount: Satoshi, val leaseDuration: Int, val leaseExpiry: Int) : ChannelTlv() { - override val tag: Long get() = RequestFunds.tag + data class RequestFundingTlv(val request: LiquidityAds.RequestFunding) : ChannelTlv() { + override val tag: Long get() = RequestFundingTlv.tag - override fun write(out: Output) { - LightningCodecs.writeU64(amount.toLong(), out) - LightningCodecs.writeU16(leaseDuration, out) - LightningCodecs.writeU32(leaseExpiry, out) - } - - companion object : TlvValueReader { - const val tag: Long = 1337 - - override fun read(input: Input): RequestFunds = RequestFunds( - amount = LightningCodecs.u64(input).sat, - leaseDuration = LightningCodecs.u16(input), - leaseExpiry = LightningCodecs.u32(input), - ) - } - } - - /** Liquidity rates applied to an incoming [[RequestFunds]]. */ - data class WillFund(val sig: ByteVector64, val fundingWeight: Int, val leaseFeeProportional: Int, val leaseFeeBase: Satoshi, val maxRelayFeeProportional: Int, val maxRelayFeeBase: MilliSatoshi) : ChannelTlv() { - override val tag: Long get() = WillFund.tag + override fun write(out: Output) = request.write(out) - fun leaseRate(leaseDuration: Int): LiquidityAds.LeaseRate = LiquidityAds.LeaseRate(leaseDuration, fundingWeight, leaseFeeProportional, leaseFeeBase, maxRelayFeeProportional, maxRelayFeeBase) - - override fun write(out: Output) { - LightningCodecs.writeBytes(sig, out) - LightningCodecs.writeU16(fundingWeight, out) - LightningCodecs.writeU16(leaseFeeProportional, out) - LightningCodecs.writeU32(leaseFeeBase.sat.toInt(), out) - LightningCodecs.writeU16(maxRelayFeeProportional, out) - LightningCodecs.writeU32(maxRelayFeeBase.msat.toInt(), out) - } - - companion object : TlvValueReader { - const val tag: Long = 1337 - - override fun read(input: Input): WillFund = WillFund( - sig = LightningCodecs.bytes(input, 64).toByteVector64(), - fundingWeight = LightningCodecs.u16(input), - leaseFeeProportional = LightningCodecs.u16(input), - leaseFeeBase = LightningCodecs.u32(input).sat, - maxRelayFeeProportional = LightningCodecs.u16(input), - maxRelayFeeBase = LightningCodecs.u32(input).msat, - ) + companion object : TlvValueReader { + const val tag: Long = 1339 + override fun read(input: Input): RequestFundingTlv = RequestFundingTlv(LiquidityAds.RequestFunding.read(input)) } } - data class OriginTlv(val origin: Origin) : ChannelTlv() { - override val tag: Long get() = OriginTlv.tag - - override fun write(out: Output) { - when (origin) { - is Origin.PayToOpenOrigin -> { - LightningCodecs.writeU16(1, out) - LightningCodecs.writeBytes(origin.paymentHash, out) - LightningCodecs.writeU64(origin.miningFee.toLong(), out) - LightningCodecs.writeU64(origin.serviceFee.toLong(), out) - LightningCodecs.writeU64(origin.amount.toLong(), out) - } - - is Origin.PleaseOpenChannelOrigin -> { - LightningCodecs.writeU16(4, out) - LightningCodecs.writeBytes(origin.requestId, out) - LightningCodecs.writeU64(origin.miningFee.toLong(), out) - LightningCodecs.writeU64(origin.serviceFee.toLong(), out) - LightningCodecs.writeU64(origin.amount.toLong(), out) - } - } - } - - companion object : TlvValueReader { - const val tag: Long = 0x47000005 - - override fun read(input: Input): OriginTlv { - val origin = when (LightningCodecs.u16(input)) { - 1 -> Origin.PayToOpenOrigin( - paymentHash = LightningCodecs.bytes(input, 32).byteVector32(), - miningFee = LightningCodecs.u64(input).sat, - serviceFee = LightningCodecs.u64(input).msat, - amount = LightningCodecs.u64(input).msat - ) + /** Accept inbound liquidity request. */ + data class ProvideFundingTlv(val willFund: LiquidityAds.WillFund) : ChannelTlv() { + override val tag: Long get() = ProvideFundingTlv.tag - 4 -> Origin.PleaseOpenChannelOrigin( - requestId = LightningCodecs.bytes(input, 32).byteVector32(), - miningFee = LightningCodecs.u64(input).sat, - serviceFee = LightningCodecs.u64(input).msat, - amount = LightningCodecs.u64(input).msat - ) + override fun write(out: Output) = willFund.write(out) - else -> error("Unsupported channel origin discriminator") - } - return OriginTlv(origin) - } - } - } - - /** With rbfed splices we can have multiple origins*/ - data class OriginsTlv(val origins: List) : ChannelTlv() { - override val tag: Long get() = OriginsTlv.tag - - override fun write(out: Output) { - LightningCodecs.writeU16(origins.size, out) - origins.forEach { OriginTlv(it).write(out) } - } - - companion object : TlvValueReader { - const val tag: Long = 0x47000009 - - override fun read(input: Input): OriginsTlv { - val size = LightningCodecs.u16(input) - val origins = buildList { - for (i in 0 until size) { - add(OriginTlv.read(input).origin) - } - } - return OriginsTlv(origins) - } + companion object : TlvValueReader { + const val tag: Long = 1339 + override fun read(input: Input): ProvideFundingTlv = ProvideFundingTlv(LiquidityAds.WillFund.read(input)) } } @@ -339,51 +239,3 @@ sealed class ClosingSignedTlv : Tlv { } } } - -sealed class PleaseOpenChannelTlv : Tlv { - // NB: this is a temporary tlv that is only used to ensure a smooth migration to lightning-kmp for the android version of Phoenix. - data class GrandParents(val outpoints: List) : PleaseOpenChannelTlv() { - override val tag: Long get() = GrandParents.tag - override fun write(out: Output) { - outpoints.forEach { outpoint -> - LightningCodecs.writeTxHash(outpoint.hash, out) - LightningCodecs.writeU64(outpoint.index, out) - } - } - - companion object : TlvValueReader { - const val tag: Long = 561 - override fun read(input: Input): GrandParents { - val count = input.availableBytes / 40 - val outpoints = (0 until count).map { OutPoint(LightningCodecs.txHash(input), LightningCodecs.u64(input)) } - return GrandParents(outpoints) - } - } - } -} - -sealed class PleaseOpenChannelRejectedTlv : Tlv { - data class ExpectedFees(val fees: MilliSatoshi) : PleaseOpenChannelRejectedTlv() { - override val tag: Long get() = ExpectedFees.tag - override fun write(out: Output) = LightningCodecs.writeTU64(fees.toLong(), out) - - companion object : TlvValueReader { - const val tag: Long = 1 - override fun read(input: Input): ExpectedFees = ExpectedFees(LightningCodecs.tu64(input).msat) - } - } -} - -sealed class PayToOpenRequestTlv : Tlv { - /** Blinding ephemeral public key that should be used to derive shared secrets when using route blinding. */ - data class Blinding(val publicKey: PublicKey) : PayToOpenRequestTlv() { - override val tag: Long get() = Blinding.tag - - override fun write(out: Output) = LightningCodecs.writeBytes(publicKey.value, out) - - companion object : TlvValueReader { - const val tag: Long = 0 - override fun read(input: Input): Blinding = Blinding(PublicKey(LightningCodecs.bytes(input, 33))) - } - } -} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/HtlcTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/HtlcTlv.kt index e24adc3cd..56f49b9b7 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/HtlcTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/HtlcTlv.kt @@ -1,8 +1,11 @@ package fr.acinq.lightning.wire import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.TxHash +import fr.acinq.bitcoin.TxId import fr.acinq.bitcoin.io.Input import fr.acinq.bitcoin.io.Output +import fr.acinq.lightning.utils.msat sealed class UpdateAddHtlcTlv : Tlv { /** Blinding ephemeral public key that should be used to derive shared secrets when using route blinding. */ @@ -16,4 +19,38 @@ sealed class UpdateAddHtlcTlv : Tlv { override fun read(input: Input): Blinding = Blinding(PublicKey(LightningCodecs.bytes(input, 33))) } } + + /** When on-the-fly funding is used, the liquidity fees may be taken from HTLCs relayed after funding. */ + data class FundingFeeTlv(val fee: LiquidityAds.FundingFee) : UpdateAddHtlcTlv() { + override val tag: Long get() = FundingFeeTlv.tag + + override fun write(out: Output) { + LightningCodecs.writeU64(fee.amount.toLong(), out) + LightningCodecs.writeTxHash(TxHash(fee.fundingTxId), out) + } + + companion object : TlvValueReader { + const val tag: Long = 41041 + override fun read(input: Input): FundingFeeTlv = FundingFeeTlv( + fee = LiquidityAds.FundingFee( + amount = LightningCodecs.u64(input).msat, + fundingTxId = TxId(LightningCodecs.txHash(input)), + ) + ) + } + } +} + +sealed class WillAddHtlcTlv : Tlv { + /** Blinding ephemeral public key that should be used to derive shared secrets when using route blinding. */ + data class Blinding(val publicKey: PublicKey) : WillAddHtlcTlv() { + override val tag: Long get() = Blinding.tag + + override fun write(out: Output) = LightningCodecs.writeBytes(publicKey.value, out) + + companion object : TlvValueReader { + const val tag: Long = 0 + override fun read(input: Input): Blinding = Blinding(PublicKey(LightningCodecs.bytes(input, 33))) + } + } } \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt index a4ae87672..acae643ba 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt @@ -32,21 +32,14 @@ sealed class InitTlv : Tlv { } /** Rates at which we sell inbound liquidity to remote peers. */ - data class LiquidityAdsRates(val leaseRates: List) : InitTlv() { - override val tag: Long get() = LiquidityAdsRates.tag + data class OptionWillFund(val rates: LiquidityAds.WillFundRates) : InitTlv() { + override val tag: Long get() = OptionWillFund.tag - override fun write(out: Output) { - leaseRates.forEach { it.write(out) } - } + override fun write(out: Output) = rates.write(out) - companion object : TlvValueReader { - const val tag: Long = 1337 - - override fun read(input: Input): LiquidityAdsRates { - val count = input.availableBytes / 16 - val rates = (0 until count).map { LiquidityAds.LeaseRate.read(input) } - return LiquidityAdsRates(rates) - } + companion object : TlvValueReader { + const val tag: Long = 1339 + override fun read(input: Input): OptionWillFund = OptionWillFund(LiquidityAds.WillFundRates.read(input)) } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index 48db001c1..a52873faa 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -8,9 +8,9 @@ import fr.acinq.bitcoin.io.Input import fr.acinq.bitcoin.io.Output 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.* +import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.router.Announcements import fr.acinq.lightning.utils.* import fr.acinq.secp256k1.Hex @@ -79,14 +79,16 @@ interface LightningMessage { Shutdown.type -> Shutdown.read(stream) ClosingSigned.type -> ClosingSigned.read(stream) OnionMessage.type -> OnionMessage.read(stream) - PayToOpenRequest.type -> PayToOpenRequest.read(stream) - PayToOpenResponse.type -> PayToOpenResponse.read(stream) + WillAddHtlc.type -> WillAddHtlc.read(stream) + WillFailHtlc.type -> WillFailHtlc.read(stream) + WillFailMalformedHtlc.type -> WillFailMalformedHtlc.read(stream) + CancelOnTheFlyFunding.type -> CancelOnTheFlyFunding.read(stream) FCMToken.type -> FCMToken.read(stream) UnsetFCMToken.type -> UnsetFCMToken DNSAddressRequest.type -> DNSAddressRequest.read(stream) DNSAddressResponse.type -> DNSAddressResponse.read(stream) PhoenixAndroidLegacyInfo.type -> PhoenixAndroidLegacyInfo.read(stream) - PleaseOpenChannel.type -> PleaseOpenChannel.read(stream) + RecommendedFeerates.type -> RecommendedFeerates.read(stream) Stfu.type -> Stfu.read(stream) SpliceInit.type -> SpliceInit.read(stream) SpliceAck.type -> SpliceAck.read(stream) @@ -185,16 +187,18 @@ interface HasEncryptedChannelData : HasChannelId { interface ChannelMessage +interface OnTheFlyFundingMessage : LightningMessage + data class Init(val features: Features, val tlvs: TlvStream = TlvStream.empty()) : SetupMessage { val networks = tlvs.get()?.chainHashes ?: listOf() - val liquidityRates = tlvs.get()?.leaseRates ?: listOf() + val liquidityRates = tlvs.get()?.rates - constructor(features: Features, chainHashs: List, liquidityRates: List) : this( + constructor(features: Features, chainHashs: List, liquidityRates: LiquidityAds.WillFundRates?) : this( features, TlvStream( setOfNotNull( if (chainHashs.isNotEmpty()) InitTlv.Networks(chainHashs) else null, - if (liquidityRates.isNotEmpty()) InitTlv.LiquidityAdsRates(liquidityRates) else null, + liquidityRates?.let { InitTlv.OptionWillFund(it) }, ) ) ) @@ -216,7 +220,7 @@ data class Init(val features: Features, val tlvs: TlvStream = TlvStream @Suppress("UNCHECKED_CAST") val readers = mapOf( InitTlv.Networks.tag to InitTlv.Networks.Companion as TlvValueReader, - InitTlv.LiquidityAdsRates.tag to InitTlv.LiquidityAdsRates.Companion as TlvValueReader, + InitTlv.OptionWillFund.tag to InitTlv.OptionWillFund.Companion as TlvValueReader, InitTlv.PhoenixAndroidLegacyNodeId.tag to InitTlv.PhoenixAndroidLegacyNodeId.Companion as TlvValueReader, ) @@ -668,13 +672,12 @@ data class OpenDualFundedChannel( val htlcBasepoint: PublicKey, val firstPerCommitmentPoint: PublicKey, val secondPerCommitmentPoint: PublicKey, - val channelFlags: Byte, + val channelFlags: ChannelFlags, val tlvStream: TlvStream = TlvStream.empty() ) : ChannelMessage, HasTemporaryChannelId, HasChainHash { val channelType: ChannelType? get() = tlvStream.get()?.channelType val pushAmount: MilliSatoshi get() = tlvStream.get()?.amount ?: 0.msat - val requestFunds: ChannelTlv.RequestFunds? get() = tlvStream.get() - val origin: Origin? get() = tlvStream.get()?.origin + val requestFunding: LiquidityAds.RequestFunding? get() = tlvStream.get()?.request override val type: Long get() = OpenDualFundedChannel.type @@ -697,7 +700,9 @@ data class OpenDualFundedChannel( LightningCodecs.writeBytes(htlcBasepoint.value, out) LightningCodecs.writeBytes(firstPerCommitmentPoint.value, out) LightningCodecs.writeBytes(secondPerCommitmentPoint.value, out) - LightningCodecs.writeByte(channelFlags.toInt(), out) + val announceChannelFlag = if (channelFlags.announceChannel) 1 else 0 + val commitFeesFlag = if (channelFlags.nonInitiatorPaysCommitFees) 2 else 0 + LightningCodecs.writeByte(announceChannelFlag + commitFeesFlag, out) TlvStreamSerializer(false, readers).write(tlvStream, out) } @@ -709,33 +714,55 @@ data class OpenDualFundedChannel( ChannelTlv.UpfrontShutdownScriptTlv.tag to ChannelTlv.UpfrontShutdownScriptTlv.Companion as TlvValueReader, ChannelTlv.ChannelTypeTlv.tag to ChannelTlv.ChannelTypeTlv.Companion as TlvValueReader, ChannelTlv.RequireConfirmedInputsTlv.tag to ChannelTlv.RequireConfirmedInputsTlv as TlvValueReader, - ChannelTlv.RequestFunds.tag to ChannelTlv.RequestFunds as TlvValueReader, - ChannelTlv.OriginTlv.tag to ChannelTlv.OriginTlv.Companion as TlvValueReader, + ChannelTlv.RequestFundingTlv.tag to ChannelTlv.RequestFundingTlv as TlvValueReader, ChannelTlv.PushAmountTlv.tag to ChannelTlv.PushAmountTlv.Companion as TlvValueReader, ) - override fun read(input: Input): OpenDualFundedChannel = OpenDualFundedChannel( - BlockHash(LightningCodecs.bytes(input, 32)), - ByteVector32(LightningCodecs.bytes(input, 32)), - FeeratePerKw(LightningCodecs.u32(input).toLong().sat), - FeeratePerKw(LightningCodecs.u32(input).toLong().sat), - Satoshi(LightningCodecs.u64(input)), - Satoshi(LightningCodecs.u64(input)), - LightningCodecs.u64(input), // this is not MilliSatoshi because it can exceed the total amount of MilliSatoshi - MilliSatoshi(LightningCodecs.u64(input)), - CltvExpiryDelta(LightningCodecs.u16(input)), - LightningCodecs.u16(input), - LightningCodecs.u32(input).toLong(), - PublicKey(LightningCodecs.bytes(input, 33)), - PublicKey(LightningCodecs.bytes(input, 33)), - PublicKey(LightningCodecs.bytes(input, 33)), - PublicKey(LightningCodecs.bytes(input, 33)), - PublicKey(LightningCodecs.bytes(input, 33)), - PublicKey(LightningCodecs.bytes(input, 33)), - PublicKey(LightningCodecs.bytes(input, 33)), - LightningCodecs.byte(input).toByte(), - TlvStreamSerializer(false, readers).read(input) - ) + override fun read(input: Input): OpenDualFundedChannel { + val chainHash = BlockHash(LightningCodecs.bytes(input, 32)) + val temporaryChannelId = ByteVector32(LightningCodecs.bytes(input, 32)) + val fundingFeerate = FeeratePerKw(LightningCodecs.u32(input).toLong().sat) + val commitmentFeerate = FeeratePerKw(LightningCodecs.u32(input).toLong().sat) + val fundingAmount = Satoshi(LightningCodecs.u64(input)) + val dustLimit = Satoshi(LightningCodecs.u64(input)) + val maxHtlcValueInFlightMsat = LightningCodecs.u64(input) // this is not MilliSatoshi because it can exceed the total amount of MilliSatoshi + val htlcMinimum = MilliSatoshi(LightningCodecs.u64(input)) + val toSelfDelay = CltvExpiryDelta(LightningCodecs.u16(input)) + val maxAcceptedHtlcs = LightningCodecs.u16(input) + val lockTime = LightningCodecs.u32(input).toLong() + val fundingPubkey = PublicKey(LightningCodecs.bytes(input, 33)) + val revocationBasepoint = PublicKey(LightningCodecs.bytes(input, 33)) + val paymentBasepoint = PublicKey(LightningCodecs.bytes(input, 33)) + val delayedPaymentBasepoint = PublicKey(LightningCodecs.bytes(input, 33)) + val htlcBasepoint = PublicKey(LightningCodecs.bytes(input, 33)) + val firstPerCommitmentPoint = PublicKey(LightningCodecs.bytes(input, 33)) + val secondPerCommitmentPoint = PublicKey(LightningCodecs.bytes(input, 33)) + val encodedChannelFlags = LightningCodecs.byte(input).toByte() + val channelFlags = ChannelFlags(announceChannel = encodedChannelFlags.toInt().and(1) != 0, nonInitiatorPaysCommitFees = encodedChannelFlags.toInt().and(2) != 0) + val tlvs = TlvStreamSerializer(false, readers).read(input) + return OpenDualFundedChannel( + chainHash = chainHash, + temporaryChannelId = temporaryChannelId, + fundingFeerate = fundingFeerate, + commitmentFeerate = commitmentFeerate, + fundingAmount = fundingAmount, + dustLimit = dustLimit, + maxHtlcValueInFlightMsat = maxHtlcValueInFlightMsat, + htlcMinimum = htlcMinimum, + toSelfDelay = toSelfDelay, + maxAcceptedHtlcs = maxAcceptedHtlcs, + lockTime = lockTime, + fundingPubkey = fundingPubkey, + revocationBasepoint = revocationBasepoint, + paymentBasepoint = paymentBasepoint, + delayedPaymentBasepoint = delayedPaymentBasepoint, + htlcBasepoint = htlcBasepoint, + firstPerCommitmentPoint = firstPerCommitmentPoint, + secondPerCommitmentPoint = secondPerCommitmentPoint, + channelFlags = channelFlags, + tlvStream = tlvs + ) + } } } @@ -758,7 +785,7 @@ data class AcceptDualFundedChannel( val tlvStream: TlvStream = TlvStream.empty() ) : ChannelMessage, HasTemporaryChannelId { val channelType: ChannelType? get() = tlvStream.get()?.channelType - val willFund: ChannelTlv.WillFund? get() = tlvStream.get() + val willFund: LiquidityAds.WillFund? get() = tlvStream.get()?.willFund val pushAmount: MilliSatoshi get() = tlvStream.get()?.amount ?: 0.msat override val type: Long get() = AcceptDualFundedChannel.type @@ -790,7 +817,7 @@ data class AcceptDualFundedChannel( ChannelTlv.UpfrontShutdownScriptTlv.tag to ChannelTlv.UpfrontShutdownScriptTlv.Companion as TlvValueReader, ChannelTlv.ChannelTypeTlv.tag to ChannelTlv.ChannelTypeTlv.Companion as TlvValueReader, ChannelTlv.RequireConfirmedInputsTlv.tag to ChannelTlv.RequireConfirmedInputsTlv as TlvValueReader, - ChannelTlv.WillFund.tag to ChannelTlv.WillFund as TlvValueReader, + ChannelTlv.ProvideFundingTlv.tag to ChannelTlv.ProvideFundingTlv as TlvValueReader, ChannelTlv.PushAmountTlv.tag to ChannelTlv.PushAmountTlv.Companion as TlvValueReader, ) @@ -928,17 +955,21 @@ data class SpliceInit( ) : ChannelMessage, HasChannelId { override val type: Long get() = SpliceInit.type val requireConfirmedInputs: Boolean = tlvStream.get()?.let { true } ?: false - val requestFunds: ChannelTlv.RequestFunds? get() = tlvStream.get() + val requestFunding: LiquidityAds.RequestFunding? = tlvStream.get()?.request val pushAmount: MilliSatoshi = tlvStream.get()?.amount ?: 0.msat - val origins: List = tlvStream.get()?.origins?.filterIsInstance() ?: emptyList() - constructor(channelId: ByteVector32, fundingContribution: Satoshi, pushAmount: MilliSatoshi, feerate: FeeratePerKw, lockTime: Long, fundingPubkey: PublicKey, requestFunds: ChannelTlv.RequestFunds?) : this( + constructor(channelId: ByteVector32, fundingContribution: Satoshi, pushAmount: MilliSatoshi, feerate: FeeratePerKw, lockTime: Long, fundingPubkey: PublicKey, requestFunding: LiquidityAds.RequestFunding?) : this( channelId, fundingContribution, feerate, lockTime, fundingPubkey, - TlvStream(setOfNotNull(if (pushAmount > 0.msat) ChannelTlv.PushAmountTlv(pushAmount) else null, requestFunds)) + TlvStream( + setOfNotNull( + if (pushAmount > 0.msat) ChannelTlv.PushAmountTlv(pushAmount) else null, + requestFunding?.let { ChannelTlv.RequestFundingTlv(it) }, + ) + ) ) override fun write(out: Output) { @@ -956,9 +987,8 @@ data class SpliceInit( @Suppress("UNCHECKED_CAST") private val readers = mapOf( ChannelTlv.RequireConfirmedInputsTlv.tag to ChannelTlv.RequireConfirmedInputsTlv as TlvValueReader, - ChannelTlv.RequestFunds.tag to ChannelTlv.RequestFunds as TlvValueReader, + ChannelTlv.RequestFundingTlv.tag to ChannelTlv.RequestFundingTlv as TlvValueReader, ChannelTlv.PushAmountTlv.tag to ChannelTlv.PushAmountTlv.Companion as TlvValueReader, - ChannelTlv.OriginsTlv.tag to ChannelTlv.OriginsTlv.Companion as TlvValueReader ) override fun read(input: Input): SpliceInit = SpliceInit( @@ -980,14 +1010,18 @@ data class SpliceAck( ) : ChannelMessage, HasChannelId { override val type: Long get() = SpliceAck.type val requireConfirmedInputs: Boolean = tlvStream.get()?.let { true } ?: false - val willFund: ChannelTlv.WillFund? get() = tlvStream.get() + val willFund: LiquidityAds.WillFund? = tlvStream.get()?.willFund val pushAmount: MilliSatoshi = tlvStream.get()?.amount ?: 0.msat - constructor(channelId: ByteVector32, fundingContribution: Satoshi, pushAmount: MilliSatoshi, fundingPubkey: PublicKey, willFund: ChannelTlv.WillFund?) : this( + constructor(channelId: ByteVector32, fundingContribution: Satoshi, pushAmount: MilliSatoshi, fundingPubkey: PublicKey, willFund: LiquidityAds.WillFund?) : this( channelId, fundingContribution, fundingPubkey, - TlvStream(setOfNotNull(if (pushAmount > 0.msat) ChannelTlv.PushAmountTlv(pushAmount) else null, willFund)) + TlvStream( + setOfNotNull( + if (pushAmount > 0.msat) ChannelTlv.PushAmountTlv(pushAmount) else null, + willFund?.let { ChannelTlv.ProvideFundingTlv(it) } + )) ) override fun write(out: Output) { @@ -1003,7 +1037,7 @@ data class SpliceAck( @Suppress("UNCHECKED_CAST") private val readers = mapOf( ChannelTlv.RequireConfirmedInputsTlv.tag to ChannelTlv.RequireConfirmedInputsTlv as TlvValueReader, - ChannelTlv.WillFund.tag to ChannelTlv.WillFund as TlvValueReader, + ChannelTlv.ProvideFundingTlv.tag to ChannelTlv.ProvideFundingTlv as TlvValueReader, ChannelTlv.PushAmountTlv.tag to ChannelTlv.PushAmountTlv.Companion as TlvValueReader, ) @@ -1054,6 +1088,7 @@ data class UpdateAddHtlc( override val type: Long get() = UpdateAddHtlc.type val blinding: PublicKey? = tlvStream.get()?.publicKey + val fundingFee: LiquidityAds.FundingFee? = tlvStream.get()?.fee override fun write(out: Output) { LightningCodecs.writeBytes(channelId, out) @@ -1070,7 +1105,8 @@ data class UpdateAddHtlc( @Suppress("UNCHECKED_CAST") private val readers = mapOf( - UpdateAddHtlcTlv.Blinding.tag to UpdateAddHtlcTlv.Blinding as TlvValueReader + UpdateAddHtlcTlv.Blinding.tag to UpdateAddHtlcTlv.Blinding as TlvValueReader, + UpdateAddHtlcTlv.FundingFeeTlv.tag to UpdateAddHtlcTlv.FundingFeeTlv as TlvValueReader, ) override fun read(input: Input): UpdateAddHtlc { @@ -1091,10 +1127,14 @@ data class UpdateAddHtlc( paymentHash: ByteVector32, cltvExpiry: CltvExpiry, onionRoutingPacket: OnionRoutingPacket, - blinding: PublicKey? + blinding: PublicKey?, + fundingFee: LiquidityAds.FundingFee? ): UpdateAddHtlc { - val tlvStream = TlvStream(setOfNotNull(blinding?.let { UpdateAddHtlcTlv.Blinding(it) })) - return UpdateAddHtlc(channelId, id, amountMsat, paymentHash, cltvExpiry, onionRoutingPacket, tlvStream) + val tlvs = setOfNotNull( + blinding?.let { UpdateAddHtlcTlv.Blinding(it) }, + fundingFee?.let { UpdateAddHtlcTlv.FundingFeeTlv(it) } + ) + return UpdateAddHtlc(channelId, id, amountMsat, paymentHash, cltvExpiry, onionRoutingPacket, TlvStream(tlvs)) } } } @@ -1602,111 +1642,137 @@ data class OnionMessage( } /** - * When we don't have enough incoming liquidity to receive a payment, our peer may open a channel to us on-the-fly to carry that payment. - * This message contains details that allow us to recalculate the fee that our peer will take in exchange for the new channel. - * This allows us to combine multiple requests for the same payment and figure out the final fee that will be applied. - * - * @param chainHash chain we're on. - * @param amountMsat payment amount covered by this new channel: we will receive push_msat = amountMsat - fees. - * @param payToOpenFeeSatoshis fees that will be deducted from the amount pushed to us (this fee covers the on-chain fees our peer will pay to open the channel). - * @param paymentHash payment hash. - * @param expireAt after the proposal expires, our peer will fail the payment and won't open a channel to us. - * @param finalPacket onion packet that we would have received if there had been a channel to forward the payment to. + * This message is sent when an HTLC couldn't be relayed to our node because we don't have enough inbound liquidity. + * This allows us to treat it as an incoming payment, and request on-the-fly liquidity accordingly if we wish to receive that payment. + * If we accept the payment, we will send an [OpenDualFundedChannel] or [SpliceInit] message containing [ChannelTlv.RequestFundingTlv]. + * Our peer will then provide the requested funding liquidity and will relay the corresponding HTLC(s) afterwards. */ -data class PayToOpenRequest( +data class WillAddHtlc( override val chainHash: BlockHash, - val amountMsat: MilliSatoshi, - val payToOpenFeeSatoshis: Satoshi, + val id: ByteVector32, + val amount: MilliSatoshi, val paymentHash: ByteVector32, - val expireAt: Long, + val expiry: CltvExpiry, val finalPacket: OnionRoutingPacket, - val liquidity: Satoshi = 0.sat, - val tlvStream: TlvStream = TlvStream.empty(), -) : LightningMessage, HasChainHash { - override val type: Long get() = PayToOpenRequest.type + val tlvStream: TlvStream = TlvStream.empty() +) : OnTheFlyFundingMessage, HasChainHash { + override val type: Long get() = WillAddHtlc.type - val blinding: PublicKey? = tlvStream.get()?.publicKey + val blinding: PublicKey? = tlvStream.get()?.publicKey override fun write(out: Output) { LightningCodecs.writeBytes(chainHash.value, out) - LightningCodecs.writeU64(0, out) // backward compat for removed field fundingSatoshis - LightningCodecs.writeU64(amountMsat.toLong(), out) - LightningCodecs.writeU64(0, out) // backward compat for removed field payToOpenMinAmountMsat - LightningCodecs.writeU64(payToOpenFeeSatoshis.toLong(), out) + LightningCodecs.writeBytes(id, out) + LightningCodecs.writeU64(amount.toLong(), out) LightningCodecs.writeBytes(paymentHash, out) - LightningCodecs.writeU32(expireAt.toInt(), out) - LightningCodecs.writeU16(finalPacket.payload.size(), out) - OnionRoutingPacketSerializer(finalPacket.payload.size()).write(finalPacket, out) - LightningCodecs.writeU64(liquidity.toLong(), out) + LightningCodecs.writeU32(expiry.toLong().toInt(), out) + OnionRoutingPacketSerializer(OnionRoutingPacket.PaymentPacketLength).write(finalPacket, out) TlvStreamSerializer(false, readers).write(tlvStream, out) } - companion object : LightningMessageReader { - const val type: Long = 35021 + companion object : LightningMessageReader { + const val type: Long = 41041 @Suppress("UNCHECKED_CAST") private val readers = mapOf( - PayToOpenRequestTlv.Blinding.tag to PayToOpenRequestTlv.Blinding as TlvValueReader + WillAddHtlcTlv.Blinding.tag to WillAddHtlcTlv.Blinding as TlvValueReader, ) - override fun read(input: Input): PayToOpenRequest { - return PayToOpenRequest( - chainHash = BlockHash(LightningCodecs.bytes(input, 32)) - .also { LightningCodecs.u64(input) }, // ignoring removed field fundingSatoshis - amountMsat = MilliSatoshi(LightningCodecs.u64(input)) - .also { LightningCodecs.u64(input) }, // ignoring removed field payToOpenMinAmountMsat - payToOpenFeeSatoshis = Satoshi(LightningCodecs.u64(input)), - paymentHash = ByteVector32(LightningCodecs.bytes(input, 32)), - expireAt = LightningCodecs.u32(input).toLong(), - finalPacket = OnionRoutingPacketSerializer(LightningCodecs.u16(input)).read(input), - liquidity = Satoshi(LightningCodecs.u64(input)), - tlvStream = TlvStreamSerializer(false, readers).read(input), - ) + override fun read(input: Input): WillAddHtlc = WillAddHtlc( + chainHash = BlockHash(LightningCodecs.bytes(input, 32)), + id = LightningCodecs.bytes(input, 32).byteVector32(), + amount = LightningCodecs.u64(input).msat, + paymentHash = LightningCodecs.bytes(input, 32).byteVector32(), + expiry = CltvExpiry(LightningCodecs.u32(input).toLong()), + finalPacket = OnionRoutingPacketSerializer(OnionRoutingPacket.PaymentPacketLength).read(input), + tlvStream = TlvStreamSerializer(false, readers).read(input) + ) + + operator fun invoke( + chainHash: BlockHash, + id: ByteVector32, + amount: MilliSatoshi, + paymentHash: ByteVector32, + cltvExpiry: CltvExpiry, + onionRoutingPacket: OnionRoutingPacket, + blinding: PublicKey? + ): WillAddHtlc { + val tlvStream = TlvStream(setOfNotNull(blinding?.let { WillAddHtlcTlv.Blinding(it) })) + return WillAddHtlc(chainHash, id, amount, paymentHash, cltvExpiry, onionRoutingPacket, tlvStream) } } } -data class PayToOpenResponse(override val chainHash: BlockHash, val paymentHash: ByteVector32, val result: Result) : LightningMessage, HasChainHash { - override val type: Long get() = PayToOpenResponse.type +data class WillFailHtlc(val id: ByteVector32, val paymentHash: ByteVector32, val reason: ByteVector) : OnTheFlyFundingMessage { + override val type: Long get() = WillFailHtlc.type - sealed class Result { - // @formatter:off - data class Success(val paymentPreimage: ByteVector32) : Result() - /** reason is an onion-encrypted failure message, like those in UpdateFailHtlc */ - data class Failure(val reason: ByteVector?) : Result() - // @formatter:on + override fun write(out: Output) { + LightningCodecs.writeBytes(id, out) + LightningCodecs.writeBytes(paymentHash, out) + LightningCodecs.writeU16(reason.size(), out) + LightningCodecs.writeBytes(reason, out) + } + + companion object : LightningMessageReader { + const val type: Long = 41042 + + override fun read(input: Input): WillFailHtlc = WillFailHtlc( + id = LightningCodecs.bytes(input, 32).byteVector32(), + paymentHash = LightningCodecs.bytes(input, 32).byteVector32(), + reason = LightningCodecs.bytes(input, LightningCodecs.u16(input)).byteVector() + ) } +} + +data class WillFailMalformedHtlc(val id: ByteVector32, val paymentHash: ByteVector32, val onionHash: ByteVector32, val failureCode: Int) : OnTheFlyFundingMessage { + override val type: Long get() = WillFailMalformedHtlc.type override fun write(out: Output) { - LightningCodecs.writeBytes(chainHash.value, out) + LightningCodecs.writeBytes(id, out) LightningCodecs.writeBytes(paymentHash, out) - when (result) { - is Result.Success -> LightningCodecs.writeBytes(result.paymentPreimage, out) - is Result.Failure -> { - LightningCodecs.writeBytes(ByteVector32.Zeroes, out) // this is for backward compatibility - result.reason?.let { - LightningCodecs.writeU16(it.size(), out) - LightningCodecs.writeBytes(it, out) - } - } - } + LightningCodecs.writeBytes(onionHash, out) + LightningCodecs.writeU16(failureCode, out) } - companion object : LightningMessageReader { - const val type: Long = 35003 + companion object : LightningMessageReader { + const val type: Long = 41043 - override fun read(input: Input): PayToOpenResponse { - val chainHash = BlockHash(LightningCodecs.bytes(input, 32)) - val paymentHash = LightningCodecs.bytes(input, 32).toByteVector32() - return when (val preimage = LightningCodecs.bytes(input, 32).toByteVector32()) { - ByteVector32.Zeroes -> { - val failure = if (input.availableBytes > 0) LightningCodecs.bytes(input, LightningCodecs.u16(input)).toByteVector() else null - PayToOpenResponse(chainHash, paymentHash, Result.Failure(failure)) - } - - else -> PayToOpenResponse(chainHash, paymentHash, Result.Success(preimage)) - } - } + override fun read(input: Input): WillFailMalformedHtlc = WillFailMalformedHtlc( + id = LightningCodecs.bytes(input, 32).byteVector32(), + paymentHash = LightningCodecs.bytes(input, 32).byteVector32(), + onionHash = LightningCodecs.bytes(input, 32).byteVector32(), + failureCode = LightningCodecs.u16(input), + ) + } +} + +/** + * This message is sent in response to an [OpenDualFundedChannel] or [SpliceInit] message containing an invalid [LiquidityAds.RequestFunds]. + * The receiver must consider the funding attempt failed when receiving this message. + */ +data class CancelOnTheFlyFunding(override val channelId: ByteVector32, val paymentHashes: List, val reason: ByteVector) : OnTheFlyFundingMessage, HasChannelId { + constructor(channelId: ByteVector32, paymentHashes: List, message: String?) : this(channelId, paymentHashes, ByteVector(message?.encodeToByteArray() ?: ByteArray(0))) + + override val type: Long get() = CancelOnTheFlyFunding.type + + fun toAscii(): String = reason.toByteArray().decodeToString() + + override fun write(out: Output) { + LightningCodecs.writeBytes(channelId, out) + LightningCodecs.writeU16(paymentHashes.size, out) + paymentHashes.forEach { LightningCodecs.writeBytes(it, out) } + LightningCodecs.writeU16(reason.size(), out) + LightningCodecs.writeBytes(reason, out) + } + + companion object : LightningMessageReader { + const val type: Long = 41044 + + override fun read(input: Input): CancelOnTheFlyFunding = CancelOnTheFlyFunding( + channelId = LightningCodecs.bytes(input, 32).byteVector32(), + paymentHashes = (0 until LightningCodecs.u16(input)).map { LightningCodecs.bytes(input, 32).byteVector32() }, + reason = LightningCodecs.bytes(input, LightningCodecs.u16(input)).byteVector() + ) } } @@ -1807,48 +1873,35 @@ data class DNSAddressResponse(override val chainHash: BlockHash, val address: St } } -/** - * This message is used to request a channel open from a remote node, with local contributions to the funding transaction. - * If the remote node won't open a channel, it will respond with [PleaseOpenChannelRejected]. - * Otherwise, it will respond with [OpenDualFundedChannel] and a fee that must be paid by a corresponding push_amount - * in the [AcceptDualFundedChannel] message. - */ -data class PleaseOpenChannel( +data class RecommendedFeerates( override val chainHash: BlockHash, - val requestId: ByteVector32, - val localFundingAmount: Satoshi, - val localInputsCount: Int, - val localInputsWeight: Int, - val tlvs: TlvStream = TlvStream.empty(), + val fundingFeerate: FeeratePerKw, + val commitmentFeerate: FeeratePerKw, + val tlvStream: TlvStream = TlvStream.empty(), ) : LightningMessage, HasChainHash { - override val type: Long get() = PleaseOpenChannel.type - - val grandParents: List = tlvs.get()?.outpoints ?: listOf() + override val type: Long get() = RecommendedFeerates.type override fun write(out: Output) { LightningCodecs.writeBytes(chainHash.value, out) - LightningCodecs.writeBytes(requestId.toByteArray(), out) - LightningCodecs.writeU64(localFundingAmount.toLong(), out) - LightningCodecs.writeU16(localInputsCount, out) - LightningCodecs.writeU32(localInputsWeight, out) - TlvStreamSerializer(false, readers).write(tlvs, out) + LightningCodecs.writeU32(fundingFeerate.toLong().toInt(), out) + LightningCodecs.writeU32(commitmentFeerate.toLong().toInt(), out) + TlvStreamSerializer(false, readers).write(tlvStream, out) } - companion object : LightningMessageReader { - const val type: Long = 36001 + companion object : LightningMessageReader { + const val type: Long = 39409 @Suppress("UNCHECKED_CAST") - val readers = mapOf( - PleaseOpenChannelTlv.GrandParents.tag to PleaseOpenChannelTlv.GrandParents.Companion as TlvValueReader, + private val readers = mapOf( + RecommendedFeeratesTlv.FundingFeerateRange.tag to RecommendedFeeratesTlv.FundingFeerateRange as TlvValueReader, + RecommendedFeeratesTlv.CommitmentFeerateRange.tag to RecommendedFeeratesTlv.CommitmentFeerateRange as TlvValueReader, ) - override fun read(input: Input): PleaseOpenChannel = PleaseOpenChannel( - BlockHash(LightningCodecs.bytes(input, 32)), - LightningCodecs.bytes(input, 32).toByteVector32(), - LightningCodecs.u64(input).sat, - LightningCodecs.u16(input), - LightningCodecs.u32(input), - TlvStreamSerializer(false, readers).read(input) + override fun read(input: Input): RecommendedFeerates = RecommendedFeerates( + chainHash = BlockHash(LightningCodecs.bytes(input, 32)), + fundingFeerate = FeeratePerKw(LightningCodecs.u32(input).sat), + commitmentFeerate = FeeratePerKw(LightningCodecs.u32(input).sat), + tlvStream = TlvStreamSerializer(false, readers).read(input) ) } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt index b3e0439ce..cd2a7afc5 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt @@ -9,151 +9,314 @@ import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.* import fr.acinq.lightning.transactions.Transactions -import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.BitField import fr.acinq.lightning.utils.sat /** * Liquidity ads create a decentralized market for channel liquidity. - * Nodes advertise fee rates for their available liquidity using the gossip protocol. + * Nodes advertise funding rates for their available liquidity using the gossip protocol. * Other nodes can then pay the advertised rate to get inbound liquidity allocated towards them. */ object LiquidityAds { /** - * @param miningFee fee paid to miners for the underlying on-chain transaction. + * @param miningFee we refund the liquidity provider for some of the fee they paid to miners for the underlying on-chain transaction. * @param serviceFee fee paid to the liquidity provider for the inbound liquidity. */ - data class LeaseFees(val miningFee: Satoshi, val serviceFee: Satoshi) { + data class Fees(val miningFee: Satoshi, val serviceFee: Satoshi) { val total: Satoshi = miningFee + serviceFee } + /** Fees paid for the funding transaction that provides liquidity. */ + data class FundingFee(val amount: MilliSatoshi, val fundingTxId: TxId) + /** - * Liquidity is leased using the following rates: - * - * - the buyer pays [leaseFeeBase] regardless of the amount contributed by the seller - * - the buyer pays [leaseFeeProportional] (expressed in basis points) of the amount contributed by the seller - * - the seller will have to add inputs/outputs to the transaction and pay on-chain fees for them, but the buyer - * refunds on-chain fees for [fundingWeight] vbytes + * Rate at which a liquidity seller sells its liquidity. + * Liquidity fees are computed based on multiple components. * - * The seller promises that their relay fees towards the buyer will never exceed [maxRelayFeeBase] and [maxRelayFeeProportional]. - * This cannot be enforced, but if the buyer notices the seller cheating, they should blacklist them and can prove - * that they misbehaved using the seller's signature of the [LeaseWitness]. + * @param minAmount minimum amount that can be purchased at this rate. + * @param maxAmount maximum amount that can be purchased at this rate. + * @param fundingWeight the seller will have to add inputs/outputs to the transaction and pay on-chain fees for them. + * The buyer refunds those on-chain fees for the given vbytes. + * @param feeProportional proportional fee (expressed in basis points) based on the amount contributed by the seller. + * @param feeBase flat fee that must be paid regardless of the amount contributed by the seller. + * @param channelCreationFee flat fee that must be paid when a new channel is created. */ - data class LeaseRate(val leaseDuration: Int, val fundingWeight: Int, val leaseFeeProportional: Int, val leaseFeeBase: Satoshi, val maxRelayFeeProportional: Int, val maxRelayFeeBase: MilliSatoshi) { - /** - * Fees paid by the liquidity buyer: the resulting amount must be added to the seller's output in the corresponding - * commitment transaction. - */ - fun fees(feerate: FeeratePerKw, requestedAmount: Satoshi, contributedAmount: Satoshi): LeaseFees { + data class FundingRate(val minAmount: Satoshi, val maxAmount: Satoshi, val fundingWeight: Int, val feeProportional: Int, val feeBase: Satoshi, val channelCreationFee: Satoshi) { + /** Fees paid by the liquidity buyer. */ + fun fees(feerate: FeeratePerKw, requestedAmount: Satoshi, contributedAmount: Satoshi, isChannelCreation: Boolean): Fees { val onChainFees = Transactions.weight2fee(feerate, fundingWeight) // If the seller adds more liquidity than requested, the buyer doesn't pay for that extra liquidity. - val proportionalFee = requestedAmount.min(contributedAmount) * leaseFeeProportional / 10_000 - return LeaseFees(onChainFees, leaseFeeBase + proportionalFee) + val proportionalFee = requestedAmount.min(contributedAmount) * feeProportional / 10_000 + val flatFee = if (isChannelCreation) channelCreationFee + feeBase else feeBase + return Fees(onChainFees, flatFee + proportionalFee) } - fun signLease(nodeKey: PrivateKey, fundingScript: ByteVector, requestFunds: ChannelTlv.RequestFunds): ChannelTlv.WillFund { - val witness = LeaseWitness(fundingScript, requestFunds.leaseDuration, requestFunds.leaseExpiry, maxRelayFeeProportional, maxRelayFeeBase) - val sig = witness.sign(nodeKey) - return ChannelTlv.WillFund(sig, fundingWeight, leaseFeeProportional, leaseFeeBase, maxRelayFeeProportional, maxRelayFeeBase) + /** When liquidity is purchased, the seller provides a signature of the funding rate and funding script. */ + fun signedData(fundingScript: ByteVector): ByteArray { + // We use a tagged hash to ensure that our signature cannot be reused in a different context. + val tag = "liquidity_ads_purchase" + val tmp = ByteArrayOutput() + write(tmp) + return Crypto.sha256(tag.encodeToByteArray() + tmp.toByteArray() + fundingScript.toByteArray()) } fun write(out: Output) { - LightningCodecs.writeU16(leaseDuration, out) + LightningCodecs.writeU32(minAmount.sat.toInt(), out) + LightningCodecs.writeU32(maxAmount.sat.toInt(), out) LightningCodecs.writeU16(fundingWeight, out) - LightningCodecs.writeU16(leaseFeeProportional, out) - LightningCodecs.writeU32(leaseFeeBase.sat.toInt(), out) - LightningCodecs.writeU16(maxRelayFeeProportional, out) - LightningCodecs.writeU32(maxRelayFeeBase.msat.toInt(), out) + LightningCodecs.writeU16(feeProportional, out) + LightningCodecs.writeU32(feeBase.sat.toInt(), out) + LightningCodecs.writeU32(channelCreationFee.sat.toInt(), out) } companion object { - fun read(input: Input): LeaseRate = LeaseRate( - leaseDuration = LightningCodecs.u16(input), + fun read(input: Input): FundingRate = FundingRate( + minAmount = LightningCodecs.u32(input).sat, + maxAmount = LightningCodecs.u32(input).sat, fundingWeight = LightningCodecs.u16(input), - leaseFeeProportional = LightningCodecs.u16(input), - leaseFeeBase = LightningCodecs.u32(input).sat, - maxRelayFeeProportional = LightningCodecs.u16(input), - maxRelayFeeBase = LightningCodecs.u32(input).msat, + feeProportional = LightningCodecs.u16(input), + feeBase = LightningCodecs.u32(input).sat, + channelCreationFee = LightningCodecs.u32(input).sat, + ) + } + } + + /** The fees associated with a given [FundingRate] can be paid using various options. */ + sealed class PaymentType { + /** Fees are transferred from the buyer's channel balance to the seller's during the interactive-tx construction. */ + data object FromChannelBalance : PaymentType() + /** Fees will be deducted from future HTLCs that will be relayed to the buyer. */ + data object FromFutureHtlc : PaymentType() + /** Fees will be deducted from future HTLCs that will be relayed to the buyer, but the preimage is revealed immediately. */ + data object FromFutureHtlcWithPreimage : PaymentType() + /** Similar to [FromChannelBalance] but expects HTLCs to be relayed after funding. */ + data object FromChannelBalanceForFutureHtlc : PaymentType() + /** Sellers may support unknown payment types, which we must ignore. */ + data class Unknown(val bitIndex: Int) : PaymentType() + + companion object { + fun encode(paymentTypes: Set): ByteArray { + val bitIndices = paymentTypes.map { + when (it) { + is FromChannelBalance -> 0 + is FromFutureHtlc -> 128 + is FromFutureHtlcWithPreimage -> 129 + is FromChannelBalanceForFutureHtlc -> 130 + is Unknown -> it.bitIndex + } + } + val bits = BitField.forAtMost(bitIndices.max() + 1) + bitIndices.forEach { bits.setRight(it) } + return bits.bytes + } + + fun decode(bytes: ByteArray): Set { + return BitField.from(bytes).asRightSequence().withIndex().mapNotNull { + when { + it.value && it.index == 0 -> FromChannelBalance + it.value && it.index == 128 -> FromFutureHtlc + it.value && it.index == 129 -> FromFutureHtlcWithPreimage + it.value && it.index == 130 -> FromChannelBalanceForFutureHtlc + it.value -> Unknown(it.index) + else -> null + } + }.toSet() + } + } + } + + /** When purchasing liquidity, we provide payment details matching one of the [PaymentType]s supported by the seller. */ + sealed class PaymentDetails { + abstract val paymentType: PaymentType + + // @formatter:off + data object FromChannelBalance : PaymentDetails() { override val paymentType: PaymentType = PaymentType.FromChannelBalance } + data class FromFutureHtlc(val paymentHashes: List) : PaymentDetails() { override val paymentType: PaymentType = PaymentType.FromFutureHtlc } + data class FromFutureHtlcWithPreimage(val preimages: List) : PaymentDetails() { override val paymentType: PaymentType = PaymentType.FromFutureHtlcWithPreimage } + data class FromChannelBalanceForFutureHtlc(val paymentHashes: List) : PaymentDetails() { override val paymentType: PaymentType = PaymentType.FromChannelBalanceForFutureHtlc } + // @formatter:on + + fun write(out: Output) = when (this) { + is FromChannelBalance -> { + LightningCodecs.writeBigSize(0, out) // tag + LightningCodecs.writeBigSize(0, out) // length + } + is FromFutureHtlc -> { + LightningCodecs.writeBigSize(128, out) // tag + LightningCodecs.writeBigSize(32 * paymentHashes.size.toLong(), out) // length + paymentHashes.forEach { LightningCodecs.writeBytes(it, out) } + } + is FromFutureHtlcWithPreimage -> { + LightningCodecs.writeBigSize(129, out) // tag + LightningCodecs.writeBigSize(32 * preimages.size.toLong(), out) // length + preimages.forEach { LightningCodecs.writeBytes(it, out) } + } + is FromChannelBalanceForFutureHtlc -> { + LightningCodecs.writeBigSize(130, out) // tag + LightningCodecs.writeBigSize(32 * paymentHashes.size.toLong(), out) // length + paymentHashes.forEach { LightningCodecs.writeBytes(it, out) } + } + } + + companion object { + fun read(input: Input): PaymentDetails = when (val tag = LightningCodecs.bigSize(input)) { + 0L -> { + require(LightningCodecs.bigSize(input) == 0L) { "invalid length for from_channel_balance payment details" } + FromChannelBalance + } + 128L -> { + val count = LightningCodecs.bigSize(input) / 32 + val paymentHashes = (0 until count).map { LightningCodecs.bytes(input, 32).byteVector32() } + FromFutureHtlc(paymentHashes) + } + 129L -> { + val count = LightningCodecs.bigSize(input) / 32 + val preimages = (0 until count).map { LightningCodecs.bytes(input, 32).byteVector32() } + FromFutureHtlcWithPreimage(preimages) + } + 130L -> { + val count = LightningCodecs.bigSize(input) / 32 + val paymentHashes = (0 until count).map { LightningCodecs.bytes(input, 32).byteVector32() } + FromChannelBalanceForFutureHtlc(paymentHashes) + } + else -> throw IllegalArgumentException("unknown payment details (tag=$tag)") + } + } + } + + /** Sellers offer various rates and payment options. */ + data class WillFundRates(val fundingRates: List, val paymentTypes: Set) { + fun validateRequest(nodeKey: PrivateKey, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding, isChannelCreation: Boolean): WillFundPurchase? { + val paymentTypeOk = paymentTypes.contains(request.paymentDetails.paymentType) + val rateOk = fundingRates.contains(request.fundingRate) + val amountOk = request.fundingRate.minAmount <= request.requestedAmount && request.requestedAmount <= request.fundingRate.maxAmount + return when { + paymentTypeOk && rateOk && amountOk -> { + val sig = Crypto.sign(request.fundingRate.signedData(fundingScript), nodeKey) + val purchase = Purchase.Standard(request.requestedAmount, request.fees(fundingFeerate, isChannelCreation), request.paymentDetails) + WillFundPurchase(WillFund(request.fundingRate, fundingScript, sig), purchase) + } + else -> null + } + } + + fun findRate(requestedAmount: Satoshi): FundingRate? { + return fundingRates.firstOrNull { it.minAmount <= requestedAmount && requestedAmount <= it.maxAmount } + } + + fun write(out: Output) { + LightningCodecs.writeU16(fundingRates.size, out) + fundingRates.forEach { it.write(out) } + val encoded = PaymentType.encode(paymentTypes) + LightningCodecs.writeU16(encoded.size, out) + LightningCodecs.writeBytes(encoded, out) + } + + companion object { + fun read(input: Input): WillFundRates { + val fundingRatesCount = LightningCodecs.u16(input) + val fundingRates = (0 until fundingRatesCount).mapNotNull { FundingRate.read(input) } + val paymentTypes = PaymentType.decode(LightningCodecs.bytes(input, LightningCodecs.u16(input))) + return WillFundRates(fundingRates, paymentTypes) + } + } + } + + /** Provide inbound liquidity to a remote peer that wants to purchase liquidity. */ + data class WillFund(val fundingRate: FundingRate, val fundingScript: ByteVector, val signature: ByteVector64) { + fun write(out: Output) { + fundingRate.write(out) + LightningCodecs.writeU16(fundingScript.size(), out) + LightningCodecs.writeBytes(fundingScript, out) + LightningCodecs.writeBytes(signature, out) + } + + companion object { + fun read(input: Input): WillFund = WillFund( + fundingRate = FundingRate.read(input), + fundingScript = LightningCodecs.bytes(input, LightningCodecs.u16(input)).byteVector(), + signature = LightningCodecs.bytes(input, 64).byteVector64(), ) } } /** Request inbound liquidity from a remote peer that supports liquidity ads. */ - data class RequestRemoteFunding(val fundingAmount: Satoshi, val leaseStart: Int, val rate: LeaseRate) { - private val leaseExpiry: Int = leaseStart + rate.leaseDuration - val requestFunds: ChannelTlv.RequestFunds = ChannelTlv.RequestFunds(fundingAmount, rate.leaseDuration, leaseExpiry) + data class RequestFunding(val requestedAmount: Satoshi, val fundingRate: FundingRate, val paymentDetails: PaymentDetails) { + fun fees(feerate: FeeratePerKw, isChannelCreation: Boolean): Fees = fundingRate.fees(feerate, requestedAmount, requestedAmount, isChannelCreation) - fun validateLease( + fun validateRemoteFunding( remoteNodeId: PublicKey, channelId: ByteVector32, fundingScript: ByteVector, remoteFundingAmount: Satoshi, fundingFeerate: FeeratePerKw, - willFund: ChannelTlv.WillFund? - ): Either { + isChannelCreation: Boolean, + willFund: WillFund? + ): Either { return when (willFund) { // If the remote peer doesn't want to provide inbound liquidity, we immediately fail the attempt. // The user should retry this funding attempt without requesting inbound liquidity. null -> Either.Left(MissingLiquidityAds(channelId)) - else -> { - val witness = LeaseWitness(fundingScript, rate.leaseDuration, leaseExpiry, willFund.maxRelayFeeProportional, willFund.maxRelayFeeBase) - return if (!witness.verify(remoteNodeId, willFund.sig)) { - Either.Left(InvalidLiquidityAdsSig(channelId)) - } else if (remoteFundingAmount < fundingAmount) { - Either.Left(InvalidLiquidityAdsAmount(channelId, remoteFundingAmount, fundingAmount)) - } else if (willFund.leaseRate(rate.leaseDuration) != rate) { - Either.Left(InvalidLiquidityRates(channelId)) - } else { - val leaseAmount = fundingAmount.min(remoteFundingAmount) - val leaseFees = rate.fees(fundingFeerate, fundingAmount, remoteFundingAmount) - Either.Right(Lease(leaseAmount, leaseFees, willFund.sig, witness)) + else -> when { + // Note that we use fundingRate instead of willFund.fundingRate: this way we verify that the funding rates match. + !Crypto.verifySignature(fundingRate.signedData(fundingScript), willFund.signature, remoteNodeId) -> Either.Left(InvalidLiquidityAdsSig(channelId)) + remoteFundingAmount < requestedAmount -> Either.Left(InvalidLiquidityAdsAmount(channelId, remoteFundingAmount, requestedAmount)) + willFund.fundingRate != fundingRate -> Either.Left(InvalidLiquidityAdsRate(channelId)) + else -> { + val purchasedAmount = requestedAmount.min(remoteFundingAmount) + val fees = fundingRate.fees(fundingFeerate, requestedAmount, remoteFundingAmount, isChannelCreation) + Either.Right(Purchase.Standard(purchasedAmount, fees, paymentDetails)) } } } } + + fun write(out: Output) { + LightningCodecs.writeU64(requestedAmount.toLong(), out) + fundingRate.write(out) + paymentDetails.write(out) + } + + companion object { + fun chooseRate(requestedAmount: Satoshi, paymentDetails: PaymentDetails, rates: WillFundRates): RequestFunding? = when { + rates.paymentTypes.contains(paymentDetails.paymentType) -> rates.findRate(requestedAmount)?.let { RequestFunding(requestedAmount, it, paymentDetails) } + else -> null + } + + fun read(input: Input): RequestFunding = RequestFunding( + requestedAmount = LightningCodecs.u64(input).sat, + fundingRate = FundingRate.read(input), + paymentDetails = PaymentDetails.read(input), + ) + } } - fun validateLease( - request: RequestRemoteFunding?, + fun validateRemoteFunding( + request: RequestFunding?, remoteNodeId: PublicKey, channelId: ByteVector32, fundingScript: ByteVector, remoteFundingAmount: Satoshi, fundingFeerate: FeeratePerKw, - willFund: ChannelTlv.WillFund?, - ): Either { + isChannelCreation: Boolean, + willFund: WillFund?, + ): Either { return when (request) { null -> Either.Right(null) - else -> request.validateLease(remoteNodeId, channelId, fundingScript, remoteFundingAmount, fundingFeerate, willFund) + else -> request.validateRemoteFunding(remoteNodeId, channelId, fundingScript, remoteFundingAmount, fundingFeerate, isChannelCreation, willFund) } } - /** - * Once a liquidity ads has been paid, we should keep track of the lease, and check that our peer doesn't raise their - * routing fees above the values they signed up for. - */ - data class Lease(val amount: Satoshi, val fees: LeaseFees, val sellerSig: ByteVector64, val witness: LeaseWitness) { - val start: Int = witness.leaseEnd - witness.leaseDuration - val expiry: Int = witness.leaseEnd - } - - /** The seller signs the lease parameters: if they cheat, the buyer can use that signature to prove they cheated. */ - data class LeaseWitness(val fundingScript: ByteVector, val leaseDuration: Int, val leaseEnd: Int, val maxRelayFeeProportional: Int, val maxRelayFeeBase: MilliSatoshi) { - fun sign(nodeKey: PrivateKey): ByteVector64 = Crypto.sign(Crypto.sha256(encode()), nodeKey) - - fun verify(nodeId: PublicKey, sig: ByteVector64): Boolean = Crypto.verifySignature(Crypto.sha256(encode()), sig, nodeId) + /** Once a liquidity ads has been purchased, we keep track of the fees paid and the payment details. */ + sealed class Purchase { + abstract val amount: Satoshi + abstract val fees: Fees + abstract val paymentDetails: PaymentDetails - fun encode(): ByteArray { - val out = ByteArrayOutput() - LightningCodecs.writeBytes("option_will_fund".encodeToByteArray(), out) - LightningCodecs.writeU16(fundingScript.size(), out) - LightningCodecs.writeBytes(fundingScript, out) - LightningCodecs.writeU16(leaseDuration, out) - LightningCodecs.writeU32(leaseEnd, out) - LightningCodecs.writeU16(maxRelayFeeProportional, out) - LightningCodecs.writeU32(maxRelayFeeBase.msat.toInt(), out) - return out.toByteArray() - } + data class Standard(override val amount: Satoshi, override val fees: Fees, override val paymentDetails: PaymentDetails) : Purchase() } + data class WillFundPurchase(val willFund: WillFund, val purchase: Purchase) + } \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/RecommendedFeeratesTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/RecommendedFeeratesTlv.kt new file mode 100644 index 000000000..303f3ea04 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/RecommendedFeeratesTlv.kt @@ -0,0 +1,48 @@ +package fr.acinq.lightning.wire + +import fr.acinq.bitcoin.io.Input +import fr.acinq.bitcoin.io.Output +import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.utils.sat + +sealed class RecommendedFeeratesTlv : Tlv { + + /** Detailed range of values that will be accepted until the next [RecommendedFeerates] message is received. */ + data class FundingFeerateRange(val min: FeeratePerKw, val max: FeeratePerKw) : RecommendedFeeratesTlv() { + override val tag: Long get() = FundingFeerateRange.tag + + override fun write(out: Output) { + LightningCodecs.writeU32(min.toLong().toInt(), out) + LightningCodecs.writeU32(max.toLong().toInt(), out) + } + + companion object : TlvValueReader { + const val tag: Long = 1 + + override fun read(input: Input): FundingFeerateRange = FundingFeerateRange( + min = FeeratePerKw(LightningCodecs.u32(input).sat), + max = FeeratePerKw(LightningCodecs.u32(input).sat), + ) + } + } + + /** Detailed range of values that will be accepted until the next [RecommendedFeerates] message is received. */ + data class CommitmentFeerateRange(val min: FeeratePerKw, val max: FeeratePerKw) : RecommendedFeeratesTlv() { + override val tag: Long get() = CommitmentFeerateRange.tag + + override fun write(out: Output) { + LightningCodecs.writeU32(min.toLong().toInt(), out) + LightningCodecs.writeU32(max.toLong().toInt(), out) + } + + companion object : TlvValueReader { + const val tag: Long = 3 + + override fun read(input: Input): CommitmentFeerateRange = CommitmentFeerateRange( + min = FeeratePerKw(LightningCodecs.u32(input).sat), + max = FeeratePerKw(LightningCodecs.u32(input).sat), + ) + } + } + +} \ No newline at end of file diff --git a/src/commonTest/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManagerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManagerTestsCommon.kt index f9d6ab615..30aac396a 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManagerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManagerTestsCommon.kt @@ -12,7 +12,7 @@ import fr.acinq.lightning.channel.TestsHelper import fr.acinq.lightning.channel.states.Normal import fr.acinq.lightning.channel.states.SpliceTestsCommon import fr.acinq.lightning.channel.states.WaitForFundingSignedTestsCommon -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.utils.sat import fr.acinq.lightning.wire.SpliceLocked @@ -42,7 +42,7 @@ class SwapInManagerTestsCommon : LightningTestSuite() { val addressState = WalletState.AddressState(WalletState.AddressMeta.Single, alreadyUsed = true, utxos) WalletState(mapOf(dummyAddress to addressState)) } - val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 150, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 3, maxConfirmations = 720, refundDelay = 900), trustedTxs = emptySet()) + val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 150, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 3, maxConfirmations = 720, refundDelay = 900)) mgr.process(cmd).also { result -> assertNotNull(result) assertEquals(result.walletInputs.map { it.amount }.toSet(), setOf(50_000.sat, 75_000.sat)) @@ -64,7 +64,7 @@ class SwapInManagerTestsCommon : LightningTestSuite() { val addressState = WalletState.AddressState(WalletState.AddressMeta.Single, alreadyUsed = true, utxos) WalletState(mapOf(dummyAddress to addressState)) } - val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 101, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 3, maxConfirmations = 720, refundDelay = 900), trustedTxs = emptySet()) + val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 101, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 3, maxConfirmations = 720, refundDelay = 900)) mgr.process(cmd).also { assertNull(it) } } @@ -83,34 +83,10 @@ class SwapInManagerTestsCommon : LightningTestSuite() { val addressState = WalletState.AddressState(WalletState.AddressMeta.Single, alreadyUsed = true, utxos) WalletState(mapOf(dummyAddress to addressState)) } - val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 130, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 3, maxConfirmations = 10, refundDelay = 15), trustedTxs = emptySet()) + val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 130, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 3, maxConfirmations = 10, refundDelay = 15)) mgr.process(cmd).also { assertNull(it) } } - @Test - fun `swap funds -- allow unconfirmed in migration`() { - val mgr = SwapInManager(listOf(), logger) - val parentTxs = listOf( - Transaction(2, listOf(TxIn(OutPoint(TxId(randomBytes32()), 1), 0)), listOf(TxOut(75_000.sat, dummyScript)), 0), - Transaction(2, listOf(TxIn(OutPoint(TxId(randomBytes32()), 2), 0)), listOf(TxOut(50_000.sat, dummyScript)), 0), - Transaction(2, listOf(TxIn(OutPoint(TxId(randomBytes32()), 0), 0)), listOf(TxOut(25_000.sat, dummyScript)), 0) - ) - val wallet = run { - val utxos = listOf( - WalletState.Utxo(parentTxs[0].txid, 0, 100, parentTxs[0], WalletState.AddressMeta.Single), // deeply confirmed - WalletState.Utxo(parentTxs[1].txid, 0, 150, parentTxs[1], WalletState.AddressMeta.Single), // recently confirmed - WalletState.Utxo(parentTxs[2].txid, 0, 0, parentTxs[2], WalletState.AddressMeta.Single), // unconfirmed - ) - val addressState = WalletState.AddressState(WalletState.AddressMeta.Single, alreadyUsed = true, utxos) - WalletState(mapOf(dummyAddress to addressState)) - } - val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 150, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 5, maxConfirmations = 720, refundDelay = 900), trustedTxs = parentTxs.map { it.txid }.toSet()) - mgr.process(cmd).also { result -> - assertNotNull(result) - assertEquals(result.walletInputs.map { it.amount }.toSet(), setOf(25_000.sat, 50_000.sat, 75_000.sat)) - } - } - @Test fun `swap funds -- previously used inputs`() { val mgr = SwapInManager(listOf(), logger) @@ -120,7 +96,7 @@ class SwapInManagerTestsCommon : LightningTestSuite() { val addressState = WalletState.AddressState(WalletState.AddressMeta.Single, alreadyUsed = true, utxos) WalletState(mapOf(dummyAddress to addressState)) } - val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 150, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 5, maxConfirmations = 720, refundDelay = 900), trustedTxs = emptySet()) + val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 150, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 5, maxConfirmations = 720, refundDelay = 900)) mgr.process(cmd).also { assertNotNull(it) } // We cannot reuse the same inputs. @@ -143,7 +119,7 @@ class SwapInManagerTestsCommon : LightningTestSuite() { WalletState(mapOf(dummyAddress to addressState)) } val mgr = SwapInManager(listOf(waitForFundingSigned.state), logger) - val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 150, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 5, maxConfirmations = 720, refundDelay = 900), trustedTxs = emptySet()) + val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 150, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 5, maxConfirmations = 720, refundDelay = 900)) mgr.process(cmd).also { assertNull(it) } // The pending channel is aborted: we can reuse those inputs. @@ -164,7 +140,7 @@ class SwapInManagerTestsCommon : LightningTestSuite() { WalletState(mapOf(dummyAddress to addressState)) } val mgr = SwapInManager(listOf(alice1.state), logger) - val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 150, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 5, maxConfirmations = 720, refundDelay = 900), trustedTxs = emptySet()) + val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 150, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 5, maxConfirmations = 720, refundDelay = 900)) mgr.process(cmd).also { assertNull(it) } // The channel is aborted: we can reuse those inputs. @@ -195,7 +171,7 @@ class SwapInManagerTestsCommon : LightningTestSuite() { WalletState(mapOf(dummyAddress to addressState)) } val mgr = SwapInManager(listOf(alice3.state), logger) - val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 150, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 5, maxConfirmations = 720, refundDelay = 900), trustedTxs = emptySet()) + val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 150, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 5, maxConfirmations = 720, refundDelay = 900)) mgr.process(cmd).also { assertNull(it) } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/CommitmentsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/CommitmentsTestsCommon.kt index c775d5937..c0035f0a7 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/CommitmentsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/CommitmentsTestsCommon.kt @@ -19,14 +19,17 @@ import fr.acinq.lightning.channel.TestsHelper.htlcTimeoutTxs import fr.acinq.lightning.channel.TestsHelper.reachNormal import fr.acinq.lightning.channel.states.Closing import fr.acinq.lightning.crypto.ShaChain -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.LoggingContext +import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.tests.utils.testLoggerFactory import fr.acinq.lightning.transactions.CommitmentSpec import fr.acinq.lightning.transactions.Scripts import fr.acinq.lightning.transactions.Transactions -import fr.acinq.lightning.utils.* +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat import fr.acinq.lightning.wire.IncorrectOrUnknownPaymentDetails import fr.acinq.lightning.wire.TxSignatures import fr.acinq.lightning.wire.UpdateAddHtlc @@ -482,9 +485,9 @@ class CommitmentsTestsCommon : LightningTestSuite(), LoggingContext { } companion object { - fun makeCommitments(toLocal: MilliSatoshi, toRemote: MilliSatoshi, feeRatePerKw: FeeratePerKw = FeeratePerKw(0.sat), dustLimit: Satoshi = 0.sat, isInitiator: Boolean = true, announceChannel: Boolean = true): Commitments { + fun makeCommitments(toLocal: MilliSatoshi, toRemote: MilliSatoshi, feeRatePerKw: FeeratePerKw = FeeratePerKw(0.sat), dustLimit: Satoshi = 0.sat, isInitiator: Boolean = true): Commitments { val localParams = LocalParams( - randomKey().publicKey(), KeyPath("42"), dustLimit, Long.MAX_VALUE, 1.msat, CltvExpiryDelta(144), 50, isInitiator, ByteVector.empty, Features.empty + randomKey().publicKey(), KeyPath("42"), dustLimit, Long.MAX_VALUE, 1.msat, CltvExpiryDelta(144), 50, isInitiator, isInitiator, ByteVector.empty, Features.empty ) val remoteParams = RemoteParams( randomKey().publicKey(), dustLimit, Long.MAX_VALUE, 1.msat, CltvExpiryDelta(144), 50, @@ -503,7 +506,7 @@ class CommitmentsTestsCommon : LightningTestSuite(), LoggingContext { channelFeatures = ChannelFeatures(ChannelType.SupportedChannelType.AnchorOutputs.features), localParams = localParams, remoteParams = remoteParams, - channelFlags = if (announceChannel) ChannelFlags.AnnounceChannel else ChannelFlags.Empty, + channelFlags = ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = false), ), CommitmentChanges( LocalChanges(listOf(), listOf(), listOf()), @@ -529,9 +532,9 @@ class CommitmentsTestsCommon : LightningTestSuite(), LoggingContext { ) } - fun makeCommitments(toLocal: MilliSatoshi, toRemote: MilliSatoshi, localNodeId: PublicKey, remoteNodeId: PublicKey, announceChannel: Boolean): Commitments { + fun makeCommitments(toLocal: MilliSatoshi, toRemote: MilliSatoshi, localNodeId: PublicKey, remoteNodeId: PublicKey): Commitments { val localParams = LocalParams( - localNodeId, KeyPath("42"), 0.sat, Long.MAX_VALUE, 1.msat, CltvExpiryDelta(144), 50, isInitiator = true, ByteVector.empty, Features.empty + localNodeId, KeyPath("42"), 0.sat, Long.MAX_VALUE, 1.msat, CltvExpiryDelta(144), 50, isChannelOpener = true, paysCommitTxFees = true, ByteVector.empty, Features.empty ) val remoteParams = RemoteParams( remoteNodeId, 0.sat, Long.MAX_VALUE, 1.msat, CltvExpiryDelta(144), 50, randomKey().publicKey(), randomKey().publicKey(), randomKey().publicKey(), randomKey().publicKey(), Features.empty @@ -548,7 +551,7 @@ class CommitmentsTestsCommon : LightningTestSuite(), LoggingContext { channelFeatures = ChannelFeatures(ChannelType.SupportedChannelType.AnchorOutputs.features), localParams = localParams, remoteParams = remoteParams, - channelFlags = if (announceChannel) ChannelFlags.AnnounceChannel else ChannelFlags.Empty, + channelFlags = ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = false), ), CommitmentChanges( LocalChanges(listOf(), listOf(), listOf()), diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt index 955293a92..0f8ae24d4 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt @@ -36,11 +36,11 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val alice0 = InteractiveTxSession(f.nodeIdB, f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) val bob0 = InteractiveTxSession(f.nodeIdA, f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) - + // 3 swap-in inputs, 2 legacy swap-in inputs, and 2 outputs from Alice // 2 swap-in inputs, 2 legacy swap-in inputs, and 1 output from Bob - // Alice --- tx_add_input --> Bob + // Alice --- tx_add_input --> Bob val (alice1, inputA1) = sendMessage(alice0) assertEquals(0xfffffffdU, inputA1.sequence) // Alice <-- tx_add_input --- Bob @@ -93,7 +93,6 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertEquals(signedTxB.localSigs.swapInServerSigs.size, 2) assertEquals(signedTxB.localSigs.swapInServerPartialSigs.size, 3) - // Alice detects invalid signatures from Bob. val sigsInvalidTxId = signedTxB.localSigs.copy(txId = TxId(randomBytes32())) assertNull(sharedTxA.sharedTx.sign(alice7, f.keyManagerA, f.fundingParamsA, f.localParamsA, f.nodeIdB).addRemoteSigs(f.channelKeysA, f.fundingParamsA, sigsInvalidTxId)) @@ -348,6 +347,49 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertTrue(targetFeerate <= feerate && feerate <= targetFeerate * 1.25, "unexpected feerate (target=$targetFeerate actual=$feerate)") } + @Test + fun `initiator does not contribute -- on-the-fly funding`() { + // When on-the-fly funding is used, the initiator may not contribute to the funding transaction. + // It will receive HTLCs later that use the purchased inbound liquidity, and liquidity fees will be deduced from those HTLCs. + val targetFeerate = FeeratePerKw(5000.sat) + val fundingB = 150_000.sat + val utxosB = listOf(200_000.sat) + val f = createFixture(0.sat, listOf(), listOf(), fundingB, utxosB, listOf(), targetFeerate, 330.sat, 0, nonInitiatorPaysCommitFees = true) + assertEquals(f.fundingParamsA.fundingAmount, fundingB) + + val alice0 = InteractiveTxSession(f.nodeIdB, f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.nodeIdA, f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) + // Alice --- tx_add_output --> Bob + val (alice1, sharedOutput) = sendMessage(alice0) + // Alice <-- tx_add_input --- Bob + val (bob1, inputB) = receiveMessage(bob0, sharedOutput) + // Alice --- tx_complete --> Bob + val (alice2, txCompleteA1) = receiveMessage(alice1, inputB) + // Alice <-- tx_add_output --- Bob + val (bob2, outputB) = receiveMessage(bob1, txCompleteA1) + // Alice --- tx_complete --> Bob + val (alice3, txCompleteA2) = receiveMessage(alice2, outputB) + // Alice <-- tx_complete --- Bob + val (bob3, sharedTxB) = receiveFinalMessage(bob2, txCompleteA2) + assertNotNull(sharedTxB.txComplete) + val (alice4, sharedTxA) = receiveFinalMessage(alice3, sharedTxB.txComplete!!) + assertNull(sharedTxA.txComplete) + + // Alice cannot pay on-chain fees because she doesn't have inputs to contribute. + // She will pay liquidity fees instead that will be taken from the future relayed HTLCs. + assertEquals(0.msat, sharedTxA.sharedTx.localFees) + assertEquals(0.msat, sharedTxB.sharedTx.remoteFees) + + // Alice signs first since she didn't contribute. + val signedTxA = sharedTxA.sharedTx.sign(alice4, f.keyManagerA, f.fundingParamsA, f.localParamsA, f.nodeIdB) + val signedTxB = sharedTxB.sharedTx.sign(bob3, f.keyManagerB, f.fundingParamsB, f.localParamsB, f.nodeIdA).addRemoteSigs(f.channelKeysB, f.fundingParamsB, signedTxA.localSigs) + assertNotNull(signedTxB) + Transaction.correctlySpends(signedTxB.signedTx, sharedTxB.sharedTx.localInputs.map { it.previousTx }, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + // The feerate is lower than expected since Alice didn't contribute. + val feerate = Transactions.fee2rate(signedTxA.tx.fees, signedTxB.signedTx.weight()) + assertTrue(targetFeerate * 0.5 <= feerate && feerate <= targetFeerate, "unexpected feerate (target=$targetFeerate actual=$feerate)") + } + @Test fun `initiator and non-initiator splice-in`() { val targetFeerate = FeeratePerKw(1000.sat) @@ -655,6 +697,45 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertTrue(targetFeerate <= feerate && feerate <= targetFeerate * 1.25, "unexpected feerate (target=$targetFeerate actual=$feerate)") } + @Test + fun `initiator does not contribute -- on-the-fly splicing`() { + // When on-the-fly funding is used, the initiator may not contribute to the funding transaction. + // It will receive HTLCs later that use the purchased inbound liquidity, and liquidity fees will be deduced from those HTLCs. + val targetFeerate = FeeratePerKw(5000.sat) + val balanceA = 0.msat + val balanceB = 75_000_000.msat + val additionalFundingB = 50_000.sat + val utxosB = listOf(90_000.sat) + val f = createSpliceFixture(balanceA, 0.sat, listOf(), listOf(), balanceB, additionalFundingB, utxosB, listOf(), targetFeerate, 330.sat, 0, nonInitiatorPaysCommitFees = true) + assertEquals(f.fundingParamsA.fundingAmount, 125_000.sat) + + val alice0 = InteractiveTxSession(f.nodeIdB, f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, balanceA, balanceB, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.nodeIdA, f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, balanceB, balanceA, emptySet(), f.fundingContributionsB) + // Alice --- tx_add_input --> Bob + val (alice1, sharedInput) = sendMessage(alice0) + // Alice <-- tx_add_input --- Bob + val (bob1, inputB) = receiveMessage(bob0, sharedInput) + // Alice --- tx_add_output --> Bob + val (alice2, sharedOutput) = receiveMessage(alice1, inputB) + // Alice <-- tx_add_output --- Bob + val (bob2, outputB) = receiveMessage(bob1, sharedOutput) + // Alice --- tx_complete --> Bob + val (alice3, txCompleteA) = receiveMessage(alice2, outputB) + // Alice <-- tx_complete --- Bob + val (bob3, sharedTxB) = receiveFinalMessage(bob2, txCompleteA) + assertNotNull(sharedTxB.txComplete) + val (alice4, sharedTxA) = receiveFinalMessage(alice3, sharedTxB.txComplete!!) + + // Alice signs first since she didn't contribute. + val signedTxA = sharedTxA.sharedTx.sign(alice4, f.keyManagerA, f.fundingParamsA, f.localParamsA, f.nodeIdB) + val signedTxB = sharedTxB.sharedTx.sign(bob3, f.keyManagerB, f.fundingParamsB, f.localParamsB, f.nodeIdA).addRemoteSigs(f.channelKeysB, f.fundingParamsB, signedTxA.localSigs) + assertNotNull(signedTxB) + Transaction.correctlySpends(signedTxB.signedTx, previousOutputs(f.fundingParamsA, sharedTxB.sharedTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + // The feerate is lower than expected since Alice didn't contribute. + val feerate = Transactions.fee2rate(signedTxA.tx.fees, signedTxB.signedTx.weight()) + assertTrue(targetFeerate * 0.25 <= feerate && feerate <= targetFeerate, "unexpected feerate (target=$targetFeerate actual=$feerate)") + } + @Test fun `remove input - output`() { val f = createFixture(100_000.sat, listOf(150_000.sat), listOf(), 0.sat, listOf(), listOf(), FeeratePerKw(2500.sat), 330.sat, 0) @@ -662,7 +743,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val alice0 = InteractiveTxSession(f.nodeIdB, f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) val bob0 = InteractiveTxSession(f.nodeIdA, f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), FundingContributions(listOf(), listOf())) - // Alice --- tx_add_input --> Bob + // Alice --- tx_add_input --> Bob val (alice1, inputA) = sendMessage(alice0) val (_, txCompleteB) = receiveMessage(bob0, inputA) @@ -722,12 +803,6 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertNotNull(result) assertIs(result) } - run { - val previousTx = Transaction(2, listOf(), listOf(TxOut(80_000.sat, Script.pay2wpkh(pubKey)), TxOut(70_001.sat, Script.pay2wpkh(pubKey))), 0) - val result = FundingContributions.create(channelKeys, swapInKeys, fundingParams, listOf(WalletState.Utxo(previousTx.txid, 0, 0, previousTx, WalletState.AddressMeta.Single), WalletState.Utxo(previousTx.txid, 1, 0, previousTx, WalletState.AddressMeta.Single))).left - assertNotNull(result) - assertIs(result) - } } @Test @@ -1184,12 +1259,13 @@ class InteractiveTxTestsCommon : LightningTestSuite() { legacyUtxosB: List, targetFeerate: FeeratePerKw, dustLimit: Satoshi, - lockTime: Long + lockTime: Long, + nonInitiatorPaysCommitFees: Boolean = false, ): Fixture { val channelId = randomBytes32() val fundingTxIndex = 0L - val localParamsA = TestConstants.Alice.channelParams() - val localParamsB = TestConstants.Bob.channelParams() + 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() - val localParamsB = TestConstants.Bob.channelParams() + 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() - val localParamsB = TestConstants.Bob.channelParams() + 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/TestsHelper.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt index a533f9a8e..90e38efc1 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt @@ -149,6 +149,7 @@ object TestsHelper { bobFundingAmount: Satoshi = TestConstants.bobFundingAmount, alicePushAmount: MilliSatoshi = TestConstants.alicePushAmount, bobPushAmount: MilliSatoshi = TestConstants.bobPushAmount, + requestRemoteFunding: Satoshi? = null, zeroConf: Boolean = false, channelOrigin: Origin? = null ): Triple, LNChannel, OpenDualFundedChannel> { @@ -181,9 +182,9 @@ object TestsHelper { WaitForInit ) - val channelFlags = 0.toByte() - val aliceChannelParams = TestConstants.Alice.channelParams().copy(features = aliceFeatures.initFeatures()) - val bobChannelParams = TestConstants.Bob.channelParams().copy(features = bobFeatures.initFeatures()) + val channelFlags = ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = requestRemoteFunding != null) + val aliceChannelParams = TestConstants.Alice.channelParams(payCommitTxFees = !channelFlags.nonInitiatorPaysCommitFees).copy(features = aliceFeatures.initFeatures()) + val bobChannelParams = TestConstants.Bob.channelParams(payCommitTxFees = channelFlags.nonInitiatorPaysCommitFees).copy(features = bobFeatures.initFeatures()) val aliceInit = Init(aliceFeatures) val bobInit = Init(bobFeatures) val (alice1, actionsAlice1) = alice.process( @@ -198,12 +199,14 @@ object TestsHelper { channelFlags, ChannelConfig.standard, channelType, - channelOrigin + requestRemoteFunding?.let { LiquidityAds.RequestFunding(it, TestConstants.fundingRates.findRate(it)!!, LiquidityAds.PaymentDetails.FromChannelBalance) }, + channelOrigin, ) ) assertIs>(alice1) + val temporaryChannelId = aliceChannelParams.channelKeys(alice.ctx.keyManager).temporaryChannelId val bobWallet = if (bobFundingAmount > 0.sat) createWallet(bobNodeParams.keyManager, bobFundingAmount + 1500.sat).second else listOf() - val (bob1, _) = bob.process(ChannelCommand.Init.NonInitiator(aliceChannelParams.channelKeys(alice.ctx.keyManager).temporaryChannelId, bobFundingAmount, bobPushAmount, bobWallet, bobChannelParams, ChannelConfig.standard, aliceInit)) + val (bob1, _) = bob.process(ChannelCommand.Init.NonInitiator(temporaryChannelId, bobFundingAmount, bobPushAmount, bobWallet, bobChannelParams, ChannelConfig.standard, aliceInit, TestConstants.fundingRates)) assertIs>(bob1) val open = actionsAlice1.findOutgoingMessage() return Triple(alice1, bob1, open) @@ -218,9 +221,10 @@ object TestsHelper { bobFundingAmount: Satoshi = TestConstants.bobFundingAmount, alicePushAmount: MilliSatoshi = TestConstants.alicePushAmount, bobPushAmount: MilliSatoshi = TestConstants.bobPushAmount, + requestRemoteFunding: Satoshi? = null, zeroConf: Boolean = false, ): Triple, LNChannel, Transaction> { - val (alice, channelReadyAlice, bob, channelReadyBob) = WaitForChannelReadyTestsCommon.init(channelType, aliceFeatures, bobFeatures, currentHeight, aliceFundingAmount, bobFundingAmount, alicePushAmount, bobPushAmount, zeroConf) + val (alice, channelReadyAlice, bob, channelReadyBob) = WaitForChannelReadyTestsCommon.init(channelType, aliceFeatures, bobFeatures, currentHeight, aliceFundingAmount, bobFundingAmount, alicePushAmount, bobPushAmount, requestRemoteFunding, zeroConf) val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(channelReadyBob)) assertIs>(alice1) actionsAlice1.has() diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt index 29b7f026e..8abcc3a5b 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt @@ -268,6 +268,21 @@ class ClosingTestsCommon : LightningTestSuite() { assertContains(actions, ChannelAction.Storage.SetLocked(localCommitPublished.commitTx.txid)) } + @Test + fun `recv BITCOIN_TX_CONFIRMED -- local commit -- non-initiator pays commit fees`() { + val (alice0, bob0) = reachNormal(requestRemoteFunding = TestConstants.bobFundingAmount) + assertFalse(alice0.commitments.params.localParams.paysCommitTxFees) + assertTrue(bob0.commitments.params.localParams.paysCommitTxFees) + val (alice1, localCommitPublished) = localClose(alice0) + val (alice2, _) = alice1.process(ChannelCommand.WatchReceived(WatchEventConfirmed(alice0.channelId, BITCOIN_TX_CONFIRMED(localCommitPublished.commitTx), 42, 7, localCommitPublished.commitTx))) + val claimMain = localCommitPublished.claimMainDelayedOutputTx!!.tx + val (alice3, actions3) = alice2.process(ChannelCommand.WatchReceived(WatchEventConfirmed(alice0.state.channelId, BITCOIN_TX_CONFIRMED(claimMain), 43, 3, claimMain))) + assertIs(alice3.state) + assertEquals(2, actions3.size) + actions3.has() + actions3.find().also { assertEquals(localCommitPublished.commitTx.txid, it.txId) } + } + @Test fun `recv BITCOIN_TX_CONFIRMED -- local commit with multiple htlcs for the same payment`() { val (alice0, bob0) = reachNormal() @@ -607,6 +622,22 @@ class ClosingTestsCommon : LightningTestSuite() { assertEquals(3, actions.size) } + @Test + fun `recv BITCOIN_TX_CONFIRMED -- remote commit -- non-initiator pays commit fees`() { + val (alice0, bob0) = reachNormal(requestRemoteFunding = TestConstants.bobFundingAmount) + assertFalse(alice0.commitments.params.localParams.paysCommitTxFees) + assertTrue(bob0.commitments.params.localParams.paysCommitTxFees) + val remoteCommitTx = bob0.commitments.latest.localCommit.publishableTxs.commitTx.tx + val (alice1, remoteCommitPublished) = remoteClose(remoteCommitTx, alice0) + val (alice2, _) = alice1.process(ChannelCommand.WatchReceived(WatchEventConfirmed(alice0.channelId, BITCOIN_TX_CONFIRMED(remoteCommitTx), 42, 7, remoteCommitTx))) + val claimMain = remoteCommitPublished.claimMainOutputTx!!.tx + val (alice3, actions3) = alice2.process(ChannelCommand.WatchReceived(WatchEventConfirmed(alice0.state.channelId, BITCOIN_TX_CONFIRMED(claimMain), 43, 3, claimMain))) + assertIs(alice3.state) + assertEquals(2, actions3.size) + actions3.has() + actions3.find().also { assertEquals(remoteCommitTx.txid, it.txId) } + } + @Test fun `recv BITCOIN_TX_CONFIRMED -- remote commit with multiple htlcs for the same payment`() { val (alice0, bob0) = reachNormal() diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NegotiatingTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NegotiatingTestsCommon.kt index ac2d1f30c..114fe4286 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NegotiatingTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NegotiatingTestsCommon.kt @@ -146,6 +146,42 @@ class NegotiatingTestsCommon : LightningTestSuite() { testClosingSignedSameFees(alice, bob, bobInitiates = true) } + @Test + fun `recv ClosingSigned -- theirCloseFee == ourCloseFee -- non-initiator pays commit fees`() { + val (alice, bob) = reachNormal(channelType = ChannelType.SupportedChannelType.AnchorOutputsZeroReserve, aliceFundingAmount = 200_000.sat, alicePushAmount = 0.msat, requestRemoteFunding = TestConstants.bobFundingAmount) + assertFalse(alice.commitments.params.localParams.paysCommitTxFees) + assertTrue(bob.commitments.params.localParams.paysCommitTxFees) + // Alice sends all of her balance to Bob. + val (nodes1, r, htlc) = TestsHelper.addHtlc(alice.commitments.availableBalanceForSend(), alice, bob) + val (alice1, bob1) = TestsHelper.crossSign(nodes1.first, nodes1.second) + val (alice2, bob2) = TestsHelper.fulfillHtlc(htlc.id, r, alice1, bob1) + val (bob3, alice3) = TestsHelper.crossSign(bob2, alice2) + assertEquals(0.msat, alice3.commitments.latest.localCommit.spec.toLocal) + // Alice and Bob agree on the current feerate. + val alice4 = alice3.updateFeerate(FeeratePerKw(3_000.sat)) + val bob4 = bob3.updateFeerate(FeeratePerKw(3_000.sat)) + // Bob initiates the mutual close. + val (bob5, actionsBob5) = bob4.process(ChannelCommand.Close.MutualClose(null, null)) + assertIs>(bob5) + val shutdownBob = actionsBob5.findOutgoingMessage() + assertNull(actionsBob5.findOutgoingMessageOpt()) + val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(shutdownBob)) + assertIs>(alice5) + val shutdownAlice = actionsAlice5.findOutgoingMessage() + assertNull(actionsAlice5.findOutgoingMessageOpt()) + val (bob6, actionsBob6) = bob5.process(ChannelCommand.MessageReceived(shutdownAlice)) + assertIs>(bob6) + val closingSignedBob = actionsBob6.findOutgoingMessage() + val (alice6, actionsAlice6) = alice5.process(ChannelCommand.MessageReceived(closingSignedBob)) + assertIs(alice6.state) + val closingSignedAlice = actionsAlice6.findOutgoingMessage() + val mutualCloseTx = actionsAlice6.findPublishTxs().first() + assertEquals(1, mutualCloseTx.txOut.size) + val (bob7, actionsBob7) = bob6.process(ChannelCommand.MessageReceived(closingSignedAlice)) + assertIs(bob7.state) + actionsBob7.hasPublishTx(mutualCloseTx) + } + @Test fun `override on-chain fee estimator -- initiator`() { val (alice, bob) = reachNormal() diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt index 4b5335731..2296c53cf 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt @@ -2,8 +2,11 @@ package fr.acinq.lightning.channel.states import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either -import fr.acinq.lightning.* +import fr.acinq.lightning.CltvExpiry +import fr.acinq.lightning.CltvExpiryDelta +import fr.acinq.lightning.Feature import fr.acinq.lightning.Lightning.randomBytes32 +import fr.acinq.lightning.ShortChannelId import fr.acinq.lightning.blockchain.* import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.* @@ -819,6 +822,27 @@ class NormalTestsCommon : LightningTestSuite() { assertEquals(3, alice9.commitments.latest.localCommit.publishableTxs.htlcTxsAndSigs.size) } + @Test + fun `recv CommitSig -- multiple htlcs in both directions -- non-initiator pays commit fees`() { + val (alice0, bob0) = reachNormal(requestRemoteFunding = TestConstants.bobFundingAmount) + assertFalse(alice0.commitments.params.localParams.paysCommitTxFees) + assertTrue(bob0.commitments.params.localParams.paysCommitTxFees) + val (nodes1, _, _) = addHtlc(75_000_000.msat, alice0, bob0) + val (alice1, bob1) = nodes1 + val (nodes2, _, _) = addHtlc(500_000.msat, alice1, bob1) + val (alice2, bob2) = nodes2 + val (nodes3, _, _) = addHtlc(10_000_000.msat, bob2, alice2) + val (bob3, alice3) = nodes3 + val (nodes4, _, _) = addHtlc(100_000.msat, bob3, alice3) + val (bob4, alice4) = nodes4 + val (alice5, bob5) = crossSign(alice4, bob4) + assertEquals(2, alice5.commitments.latest.localCommit.publishableTxs.htlcTxsAndSigs.size) + assertEquals(2, bob5.commitments.latest.localCommit.publishableTxs.htlcTxsAndSigs.size) + // Alice opened the channel, but Bob is paying the commitment fees. + assertEquals(alice5.commitments.latest.localCommit.spec.toLocal - alice5.commitments.latest.localChannelReserve.toMilliSatoshi(), alice5.commitments.availableBalanceForSend()) + assertTrue(bob5.commitments.availableBalanceForSend() < bob5.commitments.latest.localCommit.spec.toLocal - bob5.commitments.latest.localChannelReserve.toMilliSatoshi()) + } + @Test fun `recv CommitSig -- only fee update`() { val (alice0, bob0) = reachNormal() @@ -1422,6 +1446,22 @@ class NormalTestsCommon : LightningTestSuite() { assertEquals(bob.commitments.copy(changes = bob.commitments.changes.copy(remoteChanges = bob.commitments.changes.remoteChanges.copy(proposed = bob.commitments.changes.remoteChanges.proposed + fee))), bob1.commitments) } + @Test + fun `recv UpdateFee -- non-initiator pays commit fees`() { + val (alice, bob) = reachNormal(requestRemoteFunding = TestConstants.bobFundingAmount) + val fee = UpdateFee(ByteVector32.Zeroes, FeeratePerKw(7_500.sat)) + run { + val (alice1, _) = alice.process(ChannelCommand.MessageReceived(fee)) + assertIs>(alice1) + assertTrue(alice1.commitments.changes.remoteChanges.proposed.contains(fee)) + } + run { + val (bob1, actions1) = bob.process(ChannelCommand.MessageReceived(fee)) + assertIs>(bob1) + actions1.findOutgoingMessage().also { assertEquals(NonInitiatorCannotSendUpdateFee(alice.channelId).message, it.toAscii()) } + } + } + @Test fun `recv UpdateFee -- 2 in a row`() { val (_, bob) = reachNormal() @@ -1527,7 +1567,7 @@ class NormalTestsCommon : LightningTestSuite() { @Test fun `recv ChannelCommand_Close_MutualClose -- with unsupported native segwit script`() { - val (alice, _) = reachNormal() + val (alice, _) = reachNormal(aliceFeatures = TestConstants.Alice.nodeParams.features.remove(Feature.ShutdownAnySegwit)) assertNull(alice.state.localShutdown) val (alice1, actions1) = alice.process(ChannelCommand.Close.MutualClose(ByteVector("51050102030405"), null)) assertIs>(alice1) @@ -1536,10 +1576,7 @@ class NormalTestsCommon : LightningTestSuite() { @Test fun `recv ChannelCommand_Close_MutualClose -- with native segwit script`() { - val (alice, _) = reachNormal( - aliceFeatures = TestConstants.Alice.nodeParams.features.copy(TestConstants.Alice.nodeParams.features.activated + (Feature.ShutdownAnySegwit to FeatureSupport.Optional)), - bobFeatures = TestConstants.Bob.nodeParams.features.copy(TestConstants.Bob.nodeParams.features.activated + (Feature.ShutdownAnySegwit to FeatureSupport.Optional)), - ) + val (alice, _) = reachNormal() assertNull(alice.state.localShutdown) val (alice1, actions1) = alice.process(ChannelCommand.Close.MutualClose(ByteVector("51050102030405"), null)) actions1.hasOutgoingMessage() @@ -1687,7 +1724,7 @@ class NormalTestsCommon : LightningTestSuite() { @Test fun `recv Shutdown -- with unsupported native segwit script`() { - val (_, bob) = reachNormal() + val (_, bob) = reachNormal(bobFeatures = TestConstants.Bob.nodeParams.features.remove(Feature.ShutdownAnySegwit)) val (bob1, actions1) = bob.process(ChannelCommand.MessageReceived(Shutdown(bob.channelId, ByteVector("51050102030405")))) assertIs>(bob1) actions1.hasOutgoingMessage() @@ -1697,10 +1734,7 @@ class NormalTestsCommon : LightningTestSuite() { @Test fun `recv Shutdown -- with native segwit script`() { - val (_, bob) = reachNormal( - aliceFeatures = TestConstants.Alice.nodeParams.features.copy(TestConstants.Alice.nodeParams.features.activated + (Feature.ShutdownAnySegwit to FeatureSupport.Optional)), - bobFeatures = TestConstants.Bob.nodeParams.features.copy(TestConstants.Bob.nodeParams.features.activated + (Feature.ShutdownAnySegwit to FeatureSupport.Optional)), - ) + val (_, bob) = reachNormal() val (bob1, actions1) = bob.process(ChannelCommand.MessageReceived(Shutdown(bob.channelId, ByteVector("51050102030405")))) assertIs>(bob1) actions1.hasOutgoingMessage() diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt index 430b971de..53737c775 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt @@ -519,7 +519,8 @@ class QuiescenceTestsCommon : LightningTestSuite() { spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(sender.staticParams.nodeParams.keyManager, spliceIn)), spliceOut = spliceOut?.let { ChannelCommand.Commitment.Splice.Request.SpliceOut(it, Script.write(Script.pay2wpkh(Lightning.randomKey().publicKey())).byteVector()) }, feerate = FeeratePerKw(253.sat), - requestRemoteFunding = null + requestRemoteFunding = null, + origins = listOf(), ) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ShutdownTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ShutdownTestsCommon.kt index 407147edb..661444daf 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ShutdownTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ShutdownTestsCommon.kt @@ -367,6 +367,25 @@ class ShutdownTestsCommon : LightningTestSuite() { assertEquals(blob, shutdown.channelData) } + @Test + fun `recv Shutdown with non-initiator paying commit fees`() { + val (alice, bob) = reachNormal(requestRemoteFunding = TestConstants.bobFundingAmount) + assertFalse(alice.commitments.params.localParams.paysCommitTxFees) + assertTrue(bob.commitments.params.localParams.paysCommitTxFees) + // Alice can initiate a mutual close, even though she's not paying the commitment fees. + // Bob will send closing_signed first since he's paying the commitment fees. + val (alice1, actionsAlice1) = alice.process(ChannelCommand.Close.MutualClose(null, null)) + assertIs>(alice1) + val shutdownAlice = actionsAlice1.findOutgoingMessage() + val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(shutdownAlice)) + assertIs>(bob1) + val shutdownBob = actionsBob1.findOutgoingMessage() + actionsBob1.findOutgoingMessage() + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(shutdownBob)) + assertIs>(alice2) + assertNull(actionsAlice2.findOutgoingMessageOpt()) + } + @Test fun `recv CheckHtlcTimeout -- no htlc timed out`() { val (alice, _) = init() 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 f8d7eb039..b8f735bdd 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt @@ -1,7 +1,7 @@ package fr.acinq.lightning.channel.states import fr.acinq.bitcoin.* -import fr.acinq.lightning.Lightning +import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.blockchain.* import fr.acinq.lightning.blockchain.electrum.WalletState @@ -189,11 +189,14 @@ class SpliceTestsCommon : LightningTestSuite() { @Test fun `splice to purchase inbound liquidity`() { val (alice, bob) = reachNormal() - val leaseRate = LiquidityAds.LeaseRate(0, 250, 250 /* 2.5% */, 10.sat, 200, 100.msat) - val liquidityRequest = LiquidityAds.RequestRemoteFunding(200_000.sat, alice.currentBlockHeight, leaseRate) - val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat)) + val fundingRates = LiquidityAds.WillFundRates( + fundingRates = listOf(LiquidityAds.FundingRate(100_000.sat, 500_000.sat, 0, 250 /* 2.5% */, 0.sat, 1000.sat)), + paymentTypes = setOf(LiquidityAds.PaymentType.FromChannelBalance), + ) + val liquidityRequest = LiquidityAds.RequestFunding(200_000.sat, fundingRates.findRate(200_000.sat)!!, LiquidityAds.PaymentDetails.FromChannelBalance) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf()) val (alice1, bob1, spliceInit) = reachQuiescent(cmd, alice, bob) - assertEquals(spliceInit.requestFunds, liquidityRequest.requestFunds) + assertEquals(spliceInit.requestFunding, liquidityRequest) // Alice's contribution is negative: she needs to pay on-chain fees for the splice. assertTrue(spliceInit.fundingContribution < 0.sat) // We haven't implemented the seller side, so we mock it. @@ -202,18 +205,19 @@ class SpliceTestsCommon : LightningTestSuite() { assertNull(defaultSpliceAck.willFund) val fundingScript = Helpers.Funding.makeFundingPubKeyScript(spliceInit.fundingPubkey, defaultSpliceAck.fundingPubkey) run { - val willFund = leaseRate.signLease(bob.staticParams.nodeParams.nodePrivateKey, fundingScript, spliceInit.requestFunds!!) - val spliceAck = SpliceAck(alice.channelId, liquidityRequest.fundingAmount, 0.msat, defaultSpliceAck.fundingPubkey, willFund) + val willFund = fundingRates.validateRequest(bob.staticParams.nodeParams.nodePrivateKey, fundingScript, cmd.feerate, spliceInit.requestFunding!!, isChannelCreation = false)?.willFund + assertNotNull(willFund) + val spliceAck = SpliceAck(alice.channelId, liquidityRequest.requestedAmount, 0.msat, defaultSpliceAck.fundingPubkey, willFund) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceAck)) assertIs(alice2.state) assertIs(alice2.state.spliceStatus) actionsAlice2.hasOutgoingMessage() } run { - // Bob proposes different fees from what Alice expects. - val bobLiquidityRates = leaseRate.copy(leaseFeeProportional = 500 /* 5% */) - val willFund = bobLiquidityRates.signLease(bob.staticParams.nodeParams.nodePrivateKey, fundingScript, spliceInit.requestFunds!!) - val spliceAck = SpliceAck(alice.channelId, liquidityRequest.fundingAmount, 0.msat, defaultSpliceAck.fundingPubkey, willFund) + // Bob uses a different funding script than what Alice expects. + val willFund = fundingRates.validateRequest(bob.staticParams.nodeParams.nodePrivateKey, ByteVector("deadbeef"), cmd.feerate, spliceInit.requestFunding!!, isChannelCreation = false)?.willFund + assertNotNull(willFund) + val spliceAck = SpliceAck(alice.channelId, liquidityRequest.requestedAmount, 0.msat, defaultSpliceAck.fundingPubkey, willFund) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceAck)) assertIs(alice2.state) assertIs(alice2.state.spliceStatus) @@ -221,7 +225,7 @@ class SpliceTestsCommon : LightningTestSuite() { } run { // Bob doesn't fund the splice. - val spliceAck = SpliceAck(alice.channelId, liquidityRequest.fundingAmount, 0.msat, defaultSpliceAck.fundingPubkey, willFund = null) + val spliceAck = SpliceAck(alice.channelId, liquidityRequest.requestedAmount, 0.msat, defaultSpliceAck.fundingPubkey, willFund = null) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceAck)) assertIs(alice2.state) assertIs(alice2.state.spliceStatus) @@ -233,30 +237,82 @@ class SpliceTestsCommon : LightningTestSuite() { @OptIn(ExperimentalCoroutinesApi::class) fun `splice to purchase inbound liquidity -- not enough funds`() { val (alice, bob) = reachNormal(aliceFundingAmount = 100_000.sat, bobFundingAmount = 10_000.sat, alicePushAmount = 0.msat, bobPushAmount = 0.msat) - val leaseRate = LiquidityAds.LeaseRate(0, 0, 100 /* 5% */, 1.sat, 200, 100.msat) + val fundingRate = LiquidityAds.FundingRate(100_000.sat, 10_000_000.sat, 0, 100 /* 1% */, 1.sat, 1000.sat) run { - val liquidityRequest = LiquidityAds.RequestRemoteFunding(1_000_000.sat, bob.currentBlockHeight, leaseRate) - assertEquals(10_001.sat, liquidityRequest.rate.fees(FeeratePerKw(1000.sat), liquidityRequest.fundingAmount, liquidityRequest.fundingAmount).total) - val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat)) + val liquidityRequest = LiquidityAds.RequestFunding(1_000_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance) + assertEquals(10_001.sat, liquidityRequest.fees(FeeratePerKw(1000.sat), isChannelCreation = false).total) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf()) val (bob1, actionsBob1) = bob.process(cmd) val bobStfu = actionsBob1.findOutgoingMessage() val (_, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) val aliceStfu = actionsAlice1.findOutgoingMessage() val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) - assertTrue(actionsBob2.isEmpty()) + assertEquals(2, actionsBob2.size) + actionsBob2.hasOutgoingMessage() + actionsBob2.has() assertTrue(cmd.replyTo.isCompleted) assertEquals(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds, cmd.replyTo.getCompleted()) } run { - val liquidityRequest = LiquidityAds.RequestRemoteFunding(1_000_000.sat, bob.currentBlockHeight, leaseRate.copy(leaseFeeBase = 0.sat)) - assertEquals(10_000.sat, liquidityRequest.rate.fees(FeeratePerKw(1000.sat), liquidityRequest.fundingAmount, liquidityRequest.fundingAmount).total) - val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat)) + val liquidityRequest = LiquidityAds.RequestFunding(1_000_000.sat, fundingRate.copy(feeBase = 0.sat), LiquidityAds.PaymentDetails.FromChannelBalance) + assertEquals(10_000.sat, liquidityRequest.fees(FeeratePerKw(1000.sat), isChannelCreation = false).total) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf()) val (bob1, actionsBob1) = bob.process(cmd) val bobStfu = actionsBob1.findOutgoingMessage() val (_, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) val aliceStfu = actionsAlice1.findOutgoingMessage() val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) - actionsBob2.hasOutgoingMessage() + actionsBob2.hasOutgoingMessage().also { assertEquals(liquidityRequest, it.requestFunding) } + } + run { + // When we don't have enough funds in our channel balance, fees can be paid via future HTLCs. + val liquidityRequest = LiquidityAds.RequestFunding(1_000_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(randomBytes32()))) + assertEquals(10_001.sat, liquidityRequest.fees(FeeratePerKw(1000.sat), isChannelCreation = false).total) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf()) + val (bob1, actionsBob1) = bob.process(cmd) + val bobStfu = actionsBob1.findOutgoingMessage() + val (_, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) + val aliceStfu = actionsAlice1.findOutgoingMessage() + val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) + actionsBob2.hasOutgoingMessage().also { assertEquals(liquidityRequest, it.requestFunding) } + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `splice to purchase inbound liquidity -- not enough funds but on-the-fly funding`() { + val (alice, bob) = reachNormal(channelType = ChannelType.SupportedChannelType.AnchorOutputsZeroReserve, bobFundingAmount = 0.sat, alicePushAmount = 0.msat, bobPushAmount = 0.msat) + val fundingRate = LiquidityAds.FundingRate(0.sat, 500_000.sat, 0, 50, 0.sat, 1000.sat) + val origin = Origin.OffChainPayment(randomBytes32(), 25_000_000.msat, ChannelManagementFees(0.sat, 500.sat)) + run { + // We don't have enough funds to pay fees from our channel balance. + val fundingRequest = LiquidityAds.RequestFunding(100_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(listOf(origin.paymentHash))) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, fundingRequest, FeeratePerKw(1000.sat), listOf(origin)) + val (bob1, actionsBob1) = bob.process(cmd) + val bobStfu = actionsBob1.findOutgoingMessage() + val (_, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) + val aliceStfu = actionsAlice1.findOutgoingMessage() + val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) + assertEquals(2, actionsBob2.size) + actionsBob2.hasOutgoingMessage() + actionsBob2.has() + assertTrue(cmd.replyTo.isCompleted) + assertEquals(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds, cmd.replyTo.getCompleted()) + } + run { + // We can use future HTLCs to pay fees for the liquidity we're purchasing. + val fundingRequest = LiquidityAds.RequestFunding(100_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(origin.paymentHash))) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, fundingRequest, FeeratePerKw(1000.sat), listOf(origin)) + val (bob1, actionsBob1) = bob.process(cmd) + val bobStfu = actionsBob1.findOutgoingMessage() + val (_, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) + val aliceStfu = actionsAlice1.findOutgoingMessage() + val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) + assertEquals(actionsBob2.size, 1) + actionsBob2.findOutgoingMessage().also { + assertEquals(0.sat, it.fundingContribution) + assertEquals(fundingRequest, it.requestFunding) + } } } @@ -274,6 +330,17 @@ class SpliceTestsCommon : LightningTestSuite() { actionsAlice2.hasOutgoingMessage() } + @Test + fun `reject splice_init -- cancel on-the-fly funding`() { + val cmd = createSpliceOutRequest(50_000.sat) + val (alice, bob) = reachNormal() + val (alice1, _, _) = reachQuiescent(cmd, alice, bob) + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(CancelOnTheFlyFunding(alice.channelId, listOf(randomBytes32()), "cancelling on-the-fly funding"))) + assertIs(alice2.state) + assertEquals(alice2.state.spliceStatus, SpliceStatus.None) + assertTrue(actionsAlice2.isEmpty()) + } + @Test fun `reject splice_ack`() { val cmd = createSpliceOutRequest(25_000.sat) @@ -1284,7 +1351,8 @@ class SpliceTestsCommon : LightningTestSuite() { spliceIn = null, spliceOut = ChannelCommand.Commitment.Splice.Request.SpliceOut(amount, Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector()), requestRemoteFunding = null, - feerate = spliceFeerate + feerate = spliceFeerate, + origins = listOf(), ) private fun spliceOut(alice: LNChannel, bob: LNChannel, amount: Satoshi): Pair, LNChannel> { @@ -1329,7 +1397,8 @@ class SpliceTestsCommon : LightningTestSuite() { spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(alice.staticParams.nodeParams.keyManager, amounts)), spliceOut = null, requestRemoteFunding = null, - feerate = spliceFeerate + feerate = spliceFeerate, + origins = listOf(), ) // Negotiate a splice transaction where Alice is the only contributor. val (alice1, bob1, spliceInit) = reachQuiescent(cmd, alice, bob) @@ -1366,7 +1435,8 @@ class SpliceTestsCommon : LightningTestSuite() { spliceIn = null, spliceOut = null, requestRemoteFunding = null, - feerate = spliceFeerate + feerate = spliceFeerate, + origins = listOf(), ) // Negotiate a splice transaction with no contribution. val (alice1, bob1, spliceInit) = reachQuiescent(cmd, alice, bob) @@ -1399,7 +1469,8 @@ class SpliceTestsCommon : LightningTestSuite() { spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(alice.staticParams.nodeParams.keyManager, inAmounts)), spliceOut = ChannelCommand.Commitment.Splice.Request.SpliceOut(outAmount, Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector()), feerate = spliceFeerate, - requestRemoteFunding = null + requestRemoteFunding = null, + origins = listOf(), ) val (alice1, bob1, spliceInit) = reachQuiescent(cmd, alice, bob) val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(spliceInit)) @@ -1509,7 +1580,7 @@ class SpliceTestsCommon : LightningTestSuite() { private fun createWalletWithFunds(keyManager: KeyManager, amounts: List): List { val script = keyManager.swapInOnChainWallet.legacySwapInProtocol.pubkeyScript return amounts.map { amount -> - val txIn = listOf(TxIn(OutPoint(TxId(Lightning.randomBytes32()), 2), 0)) + val txIn = listOf(TxIn(OutPoint(TxId(randomBytes32()), 2), 0)) val txOut = listOf(TxOut(amount, script), TxOut(150.sat, Script.pay2wpkh(randomKey().publicKey()))) val parentTx = Transaction(2, txIn, txOut, 0) WalletState.Utxo(parentTx.txid, 0, 42, parentTx, WalletState.AddressMeta.Single) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannelTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannelTestsCommon.kt index 074ff3328..5b5fe0a36 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannelTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannelTestsCommon.kt @@ -4,6 +4,7 @@ import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.* +import fr.acinq.lightning.Lightning.randomBytes64 import fr.acinq.lightning.channel.* import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.tests.utils.LightningTestSuite @@ -29,6 +30,17 @@ class WaitForAcceptChannelTestsCommon : LightningTestSuite() { assertEquals(alice1.state.channelFeatures, ChannelFeatures(setOf(Feature.StaticRemoteKey, Feature.AnchorOutputs, Feature.DualFunding))) } + @Test + fun `recv AcceptChannel -- liquidity ads`() { + val (alice, _, accept) = init(requestRemoteFunding = TestConstants.bobFundingAmount) + val (alice1, actions1) = alice.process(ChannelCommand.MessageReceived(accept)) + assertIs>(alice1) + val purchase = alice1.state.liquidityPurchase + assertNotNull(purchase) + assertTrue(purchase.fees.total > 0.sat) + actions1.hasOutgoingMessage() + } + @Test fun `recv AcceptChannel -- without non-initiator contribution`() { val (alice, _, accept) = init(bobFundingAmount = 0.sat) @@ -81,6 +93,36 @@ class WaitForAcceptChannelTestsCommon : LightningTestSuite() { assertIs>(alice1) } + @Test + fun `recv AcceptChannel -- missing liquidity ads`() { + val (alice, _, accept) = init(requestRemoteFunding = TestConstants.bobFundingAmount) + val accept1 = accept.copy(tlvStream = accept.tlvStream.copy(records = accept.tlvStream.records.filterNot { it is ChannelTlv.ProvideFundingTlv }.toSet())) + val (alice1, actions1) = alice.process(ChannelCommand.MessageReceived(accept1)) + assertIs>(alice1) + val error = actions1.hasOutgoingMessage() + assertEquals(error, Error(accept.temporaryChannelId, MissingLiquidityAds(accept.temporaryChannelId).message)) + } + + @Test + fun `recv AcceptChannel -- invalid liquidity ads amount`() { + val (alice, _, accept) = init(requestRemoteFunding = TestConstants.bobFundingAmount) + val (alice1, actions1) = alice.process(ChannelCommand.MessageReceived(accept.copy(fundingAmount = TestConstants.bobFundingAmount - 100.sat))) + assertIs>(alice1) + val error = actions1.hasOutgoingMessage() + assertEquals(error, Error(accept.temporaryChannelId, InvalidLiquidityAdsAmount(accept.temporaryChannelId, TestConstants.bobFundingAmount - 100.sat, TestConstants.bobFundingAmount).message)) + } + + @Test + fun `recv AcceptChannel -- invalid liquidity ads signature`() { + val (alice, _, accept) = init(requestRemoteFunding = TestConstants.bobFundingAmount) + val willFund = ChannelTlv.ProvideFundingTlv(accept.willFund!!.copy(signature = randomBytes64())) + val accept1 = accept.copy(tlvStream = accept.tlvStream.copy(records = accept.tlvStream.records.filterNot { it is ChannelTlv.ProvideFundingTlv }.toSet() + willFund)) + val (alice1, actions1) = alice.process(ChannelCommand.MessageReceived(accept1)) + assertIs>(alice1) + val error = actions1.hasOutgoingMessage() + assertEquals(error, Error(accept.temporaryChannelId, InvalidLiquidityAdsSig(accept.temporaryChannelId).message)) + } + @Test fun `recv AcceptChannel -- invalid max accepted htlcs`() { val (alice, _, accept) = init() @@ -154,19 +196,24 @@ class WaitForAcceptChannelTestsCommon : LightningTestSuite() { bobFundingAmount: Satoshi = TestConstants.bobFundingAmount, alicePushAmount: MilliSatoshi = TestConstants.alicePushAmount, bobPushAmount: MilliSatoshi = TestConstants.bobPushAmount, + requestRemoteFunding: Satoshi? = null, zeroConf: Boolean = false, ): Triple, LNChannel, AcceptDualFundedChannel> { - val (alice, bob, open) = TestsHelper.init(channelType, aliceFeatures, bobFeatures, currentHeight, aliceFundingAmount, bobFundingAmount, alicePushAmount, bobPushAmount, zeroConf) + val (alice, bob, open) = TestsHelper.init(channelType, aliceFeatures, bobFeatures, currentHeight, aliceFundingAmount, bobFundingAmount, alicePushAmount, bobPushAmount, requestRemoteFunding, zeroConf) assertEquals(open.fundingAmount, aliceFundingAmount) assertEquals(open.pushAmount, alicePushAmount) - assertEquals(open.tlvStream.get(), ChannelTlv.ChannelTypeTlv(channelType)) + assertEquals(open.channelType, channelType) + requestRemoteFunding?.let { + assertTrue(open.channelFlags.nonInitiatorPaysCommitFees) + assertNotNull(open.requestFunding) + } val (bob1, actions) = bob.process(ChannelCommand.MessageReceived(open)) assertIs>(bob1) val accept = actions.hasOutgoingMessage() assertEquals(open.temporaryChannelId, accept.temporaryChannelId) assertEquals(accept.fundingAmount, bobFundingAmount) assertEquals(accept.pushAmount, bobPushAmount) - assertEquals(accept.tlvStream.get(), ChannelTlv.ChannelTypeTlv(channelType)) + assertEquals(accept.channelType, channelType) when (zeroConf) { true -> assertEquals(0, accept.minimumDepth) false -> assertEquals(3, accept.minimumDepth) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForChannelReadyTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForChannelReadyTestsCommon.kt index a26995701..c5f5f8375 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForChannelReadyTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForChannelReadyTestsCommon.kt @@ -201,10 +201,11 @@ class WaitForChannelReadyTestsCommon : LightningTestSuite() { bobFundingAmount: Satoshi = TestConstants.bobFundingAmount, alicePushAmount: MilliSatoshi = TestConstants.alicePushAmount, bobPushAmount: MilliSatoshi = TestConstants.bobPushAmount, + requestRemoteFunding: Satoshi? = null, zeroConf: Boolean = false, ): Fixture { return if (zeroConf) { - val (alice, commitAlice, bob, commitBob) = WaitForFundingSignedTestsCommon.init(channelType, aliceFeatures, bobFeatures, currentHeight, aliceFundingAmount, bobFundingAmount, alicePushAmount, bobPushAmount, zeroConf) + val (alice, commitAlice, bob, commitBob) = WaitForFundingSignedTestsCommon.init(channelType, aliceFeatures, bobFeatures, currentHeight, aliceFundingAmount, bobFundingAmount, alicePushAmount, bobPushAmount, requestRemoteFunding, zeroConf) val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(commitBob)) assertIs>(alice1) assertTrue(actionsAlice1.isEmpty()) @@ -221,7 +222,7 @@ class WaitForChannelReadyTestsCommon : LightningTestSuite() { actionsAlice2.has() Fixture(alice2, channelReadyAlice, bob1, channelReadyBob) } else { - val (alice, bob, fundingTx) = WaitForFundingConfirmedTestsCommon.init(channelType, aliceFeatures, bobFeatures, currentHeight, aliceFundingAmount, bobFundingAmount, alicePushAmount, bobPushAmount) + val (alice, bob, fundingTx) = WaitForFundingConfirmedTestsCommon.init(channelType, aliceFeatures, bobFeatures, currentHeight, aliceFundingAmount, bobFundingAmount, alicePushAmount, bobPushAmount, requestRemoteFunding) val (alice1, actionsAlice1) = alice.process(ChannelCommand.WatchReceived(WatchEventConfirmed(alice.channelId, BITCOIN_FUNDING_DEPTHOK, 42, 0, fundingTx))) assertIs>(alice1) val channelReadyAlice = actionsAlice1.findOutgoingMessage() diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmedTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmedTestsCommon.kt index ea7881698..4d0324fee 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmedTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmedTestsCommon.kt @@ -398,6 +398,7 @@ class WaitForFundingConfirmedTestsCommon : LightningTestSuite() { bobFundingAmount: Satoshi = TestConstants.bobFundingAmount, alicePushAmount: MilliSatoshi = TestConstants.alicePushAmount, bobPushAmount: MilliSatoshi = TestConstants.bobPushAmount, + requestRemoteFunding: Satoshi? = null, ): Fixture { val (alice, commitAlice, bob, commitBob, walletAlice) = WaitForFundingSignedTestsCommon.init( channelType, @@ -408,6 +409,7 @@ class WaitForFundingConfirmedTestsCommon : LightningTestSuite() { bobFundingAmount, alicePushAmount, bobPushAmount, + requestRemoteFunding, zeroConf = false ) val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(commitBob)) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreatedTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreatedTestsCommon.kt index 2ae04aa62..1160524d9 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreatedTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreatedTestsCommon.kt @@ -302,10 +302,11 @@ class WaitForFundingCreatedTestsCommon : LightningTestSuite() { bobFundingAmount: Satoshi = TestConstants.bobFundingAmount, alicePushAmount: MilliSatoshi = TestConstants.alicePushAmount, bobPushAmount: MilliSatoshi = TestConstants.bobPushAmount, + requestRemoteFunding: Satoshi? = null, zeroConf: Boolean = false, channelOrigin: Origin? = null ): Fixture { - val (a, b, open) = TestsHelper.init(channelType, aliceFeatures, bobFeatures, currentHeight, aliceFundingAmount, bobFundingAmount, alicePushAmount, bobPushAmount, zeroConf, channelOrigin) + val (a, b, open) = TestsHelper.init(channelType, aliceFeatures, bobFeatures, currentHeight, aliceFundingAmount, bobFundingAmount, alicePushAmount, bobPushAmount, requestRemoteFunding, zeroConf, channelOrigin) val (b1, actions) = b.process(ChannelCommand.MessageReceived(open)) val accept = actions.findOutgoingMessage() assertIs>(b1) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSignedTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSignedTestsCommon.kt index faf65e15e..8e1faa7a5 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSignedTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSignedTestsCommon.kt @@ -25,23 +25,21 @@ class WaitForFundingSignedTestsCommon : LightningTestSuite() { val (alice, commitSigAlice, bob, commitSigBob) = init() val commitInput = alice.state.signingSession.commitInput run { - val (_, _) = alice.process(ChannelCommand.MessageReceived(commitSigBob)) - .also { (state, actions) -> - assertIs(state.state) - assertTrue(actions.isEmpty()) - } + alice.process(ChannelCommand.MessageReceived(commitSigBob)).also { (state, actions) -> + assertIs(state.state) + assertTrue(actions.isEmpty()) + } } run { - val (_, _) = bob.process(ChannelCommand.MessageReceived(commitSigAlice)) - .also { (state, actions) -> - assertIs(state.state) - assertEquals(actions.size, 5) - actions.hasOutgoingMessage().also { assertFalse(it.channelData.isEmpty()) } - actions.findWatch().also { assertEquals(WatchConfirmed(state.channelId, commitInput.outPoint.txid, commitInput.txOut.publicKeyScript, 3, BITCOIN_FUNDING_DEPTHOK), it) } - actions.find().also { assertEquals(TestConstants.bobFundingAmount.toMilliSatoshi() + TestConstants.alicePushAmount - TestConstants.bobPushAmount, it.amount) } - actions.has() - actions.find().also { assertEquals(ChannelEvents.Created(state.state), it.event) } - } + bob.process(ChannelCommand.MessageReceived(commitSigAlice)).also { (state, actions) -> + assertIs(state.state) + assertEquals(actions.size, 5) + actions.hasOutgoingMessage().also { assertFalse(it.channelData.isEmpty()) } + actions.findWatch().also { assertEquals(WatchConfirmed(state.channelId, commitInput.outPoint.txid, commitInput.txOut.publicKeyScript, 3, BITCOIN_FUNDING_DEPTHOK), it) } + actions.find().also { assertEquals(TestConstants.bobFundingAmount.toMilliSatoshi() + TestConstants.alicePushAmount - TestConstants.bobPushAmount, it.amountReceived) } + actions.has() + actions.find().also { assertEquals(ChannelEvents.Created(state.state), it.event) } + } } } @@ -49,62 +47,90 @@ class WaitForFundingSignedTestsCommon : LightningTestSuite() { fun `recv CommitSig -- zero conf`() { val (alice, commitSigAlice, bob, commitSigBob) = init(ChannelType.SupportedChannelType.AnchorOutputsZeroReserve, zeroConf = true) run { - val (_, _) = alice.process(ChannelCommand.MessageReceived(commitSigBob)) - .also { (state, actions) -> - assertIs(state.state) - assertTrue(actions.isEmpty()) - } + alice.process(ChannelCommand.MessageReceived(commitSigBob)).also { (state, actions) -> + assertIs(state.state) + assertTrue(actions.isEmpty()) + } } run { - val (_, _) = bob.process(ChannelCommand.MessageReceived(commitSigAlice)) - .also { (state, actions) -> - assertIs(state.state) - assertEquals(actions.size, 6) - actions.hasOutgoingMessage().also { assertFalse(it.channelData.isEmpty()) } - actions.hasOutgoingMessage().also { assertEquals(ShortChannelId.peerId(bob.staticParams.nodeParams.nodeId), it.alias) } - actions.findWatch().also { assertEquals(state.commitments.latest.fundingTxId, it.txId) } - actions.find().also { assertEquals(TestConstants.bobFundingAmount.toMilliSatoshi() + TestConstants.alicePushAmount - TestConstants.bobPushAmount, it.amount) } - actions.has() - actions.find().also { assertEquals(ChannelEvents.Created(state.state), it.event) } - } + bob.process(ChannelCommand.MessageReceived(commitSigAlice)).also { (state, actions) -> + assertIs(state.state) + assertEquals(actions.size, 6) + actions.hasOutgoingMessage().also { assertFalse(it.channelData.isEmpty()) } + actions.hasOutgoingMessage().also { assertEquals(ShortChannelId.peerId(bob.staticParams.nodeParams.nodeId), it.alias) } + actions.findWatch().also { assertEquals(state.commitments.latest.fundingTxId, it.txId) } + actions.find().also { assertEquals(TestConstants.bobFundingAmount.toMilliSatoshi() + TestConstants.alicePushAmount - TestConstants.bobPushAmount, it.amountReceived) } + actions.has() + actions.find().also { assertEquals(ChannelEvents.Created(state.state), it.event) } + } + } + } + + @Test + fun `recv CommitSig -- liquidity ads`() { + val (alice, commitSigAlice, bob, commitSigBob) = init(requestRemoteFunding = TestConstants.bobFundingAmount, alicePushAmount = 0.msat, bobPushAmount = 0.msat) + val purchase = alice.process(ChannelCommand.MessageReceived(commitSigBob)).let { (state, actions) -> + assertIs(state.state) + assertTrue(actions.isEmpty()) + val purchase = state.state.liquidityPurchase + assertNotNull(purchase) + assertEquals(TestConstants.bobFundingAmount / 100, purchase.fees.serviceFee) + val localCommit = state.state.signingSession.localCommit.right!! + assertEquals(TestConstants.aliceFundingAmount - purchase.fees.total, localCommit.spec.toLocal.truncateToSatoshi()) + assertEquals(TestConstants.bobFundingAmount + purchase.fees.total, localCommit.spec.toRemote.truncateToSatoshi()) + purchase + } + bob.process(ChannelCommand.MessageReceived(commitSigAlice)).also { (state, actions) -> + assertIs(state.state) + assertEquals(actions.size, 5) + assertEquals(TestConstants.bobFundingAmount + purchase.fees.total, state.commitments.latest.localCommit.spec.toLocal.truncateToSatoshi()) + assertEquals(TestConstants.aliceFundingAmount - purchase.fees.total, state.commitments.latest.localCommit.spec.toRemote.truncateToSatoshi()) + actions.hasOutgoingMessage().also { assertFalse(it.channelData.isEmpty()) } + actions.findWatch().also { assertEquals(BITCOIN_FUNDING_DEPTHOK, it.event) } + actions.find().also { assertEquals((TestConstants.bobFundingAmount + purchase.fees.total).toMilliSatoshi(), it.amountReceived) } + actions.has() + actions.find().also { assertEquals(ChannelEvents.Created(state.state), it.event) } } } @Test - fun `recv CommitSig -- with channel origin -- pay-to-open`() { - val channelOrigin = Origin.PayToOpenOrigin(randomBytes32(), 1_000_000.msat, 500.sat, TestConstants.alicePushAmount) - val (_, commitSigAlice, bob, _) = init(bobFundingAmount = 0.sat, alicePushAmount = TestConstants.alicePushAmount, bobPushAmount = 0.msat, channelOrigin = channelOrigin) - val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(commitSigAlice)) - assertIs(bob1.state) - assertEquals(actionsBob1.size, 5) - assertFalse(actionsBob1.hasOutgoingMessage().channelData.isEmpty()) - actionsBob1.has() - actionsBob1.find().also { - assertEquals(TestConstants.alicePushAmount, it.amount) + fun `recv CommitSig -- with channel origin -- off-chain payment`() { + val channelOrigin = Origin.OffChainPayment(randomBytes32(), 50_000_000.msat, ChannelManagementFees(500.sat, 1_000.sat)) + val (alice, _, _, commitSigBob) = init(aliceFundingAmount = 0.sat, alicePushAmount = 0.msat, bobPushAmount = 50_000_000.msat, channelOrigin = channelOrigin) + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(commitSigBob)) + assertIs(alice1.state) + assertEquals(actionsAlice1.size, 6) + actionsAlice1.hasOutgoingMessage() + actionsAlice1.has() + actionsAlice1.find().also { + assertEquals(50_000_000.msat, it.amountReceived) assertEquals(channelOrigin, it.origin) - assertEquals(bob1.commitments.latest.fundingTxId, it.txId) - assertTrue(it.localInputs.isEmpty()) + assertEquals(alice1.commitments.latest.fundingTxId, it.txId) } - actionsBob1.hasWatch() - actionsBob1.has() + actionsAlice1.hasWatch() + val events = actionsAlice1.filterIsInstance().map { it.event } + assertTrue(events.any { it is ChannelEvents.Created }) + assertTrue(events.any { it is LiquidityEvents.Accepted }) } @Test fun `recv CommitSig -- with channel origin -- dual-swap-in`() { - val channelOrigin = Origin.PleaseOpenChannelOrigin(randomBytes32(), 2500.msat, 0.sat, TestConstants.bobFundingAmount.toMilliSatoshi() - TestConstants.bobPushAmount) - val (_, commitSigAlice, bob, _) = init(alicePushAmount = 0.msat, channelOrigin = channelOrigin) - val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(commitSigAlice)) - assertIs(bob1.state) - assertEquals(actionsBob1.size, 5) - assertFalse(actionsBob1.hasOutgoingMessage().channelData.isEmpty()) - actionsBob1.has() - actionsBob1.find().also { - assertEquals(it.amount, TestConstants.bobFundingAmount.toMilliSatoshi() - TestConstants.bobPushAmount) + val channelOrigin = Origin.OnChainWallet(setOf(), 200_000_000.msat, ChannelManagementFees(750.sat, 0.sat)) + val (alice, _, _, commitSigBob) = init(aliceFundingAmount = 200_000.sat, alicePushAmount = 0.msat, bobFundingAmount = 500_000.sat, channelOrigin = channelOrigin) + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(commitSigBob)) + assertIs(alice1.state) + assertEquals(actionsAlice1.size, 6) + actionsAlice1.hasOutgoingMessage() + actionsAlice1.has() + actionsAlice1.find().also { + assertEquals(it.amountReceived, 200_000_000.msat) assertEquals(it.origin, channelOrigin) assertTrue(it.localInputs.isNotEmpty()) } - actionsBob1.hasWatch() - actionsBob1.has() + actionsAlice1.hasWatch() + val events = actionsAlice1.filterIsInstance().map { it.event } + assertTrue(events.any { it is ChannelEvents.Created }) + assertTrue(events.any { it is SwapInEvents.Accepted }) } @Test @@ -153,6 +179,36 @@ class WaitForFundingSignedTestsCommon : LightningTestSuite() { } } + @Test + fun `recv TxSignatures -- liquidity ads`() { + val (alice, commitSigAlice, bob, commitSigBob) = init(requestRemoteFunding = TestConstants.bobFundingAmount, alicePushAmount = 0.msat, bobPushAmount = 0.msat) + val commitInput = alice.state.signingSession.commitInput + val txSigsBob = run { + val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(commitSigAlice)) + assertIs(bob1.state) + actionsBob1.hasOutgoingMessage() + } + run { + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(commitSigBob)) + assertIs(alice1.state) + assertTrue(actionsAlice1.isEmpty()) + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(txSigsBob)) + assertIs(alice2.state) + assertEquals(7, actionsAlice2.size) + assertTrue(actionsAlice2.hasOutgoingMessage().channelData.isEmpty()) + actionsAlice2.has() + val watchConfirmedAlice = actionsAlice2.findWatch() + assertEquals(WatchConfirmed(alice2.channelId, commitInput.outPoint.txid, commitInput.txOut.publicKeyScript, 3, BITCOIN_FUNDING_DEPTHOK), watchConfirmedAlice) + val liquidityPurchase = actionsAlice2.find() + assertEquals(liquidityPurchase.txId, txSigsBob.txId) + assertIs(liquidityPurchase.purchase.paymentDetails) + assertEquals(ChannelEvents.Created(alice2.state), actionsAlice2.find().event) + val fundingTx = actionsAlice2.find().tx + assertEquals(fundingTx.txid, txSigsBob.txId) + assertEquals(commitInput.outPoint.txid, fundingTx.txid) + } + } + @Test fun `recv TxSignatures -- zero-conf`() { val (alice, commitSigAlice, bob, commitSigBob) = init(ChannelType.SupportedChannelType.AnchorOutputsZeroReserve, zeroConf = true) @@ -295,6 +351,7 @@ class WaitForFundingSignedTestsCommon : LightningTestSuite() { bobFundingAmount: Satoshi = TestConstants.bobFundingAmount, alicePushAmount: MilliSatoshi = TestConstants.alicePushAmount, bobPushAmount: MilliSatoshi = TestConstants.bobPushAmount, + requestRemoteFunding: Satoshi? = null, zeroConf: Boolean = false, channelOrigin: Origin? = null ): Fixture { @@ -307,6 +364,7 @@ class WaitForFundingSignedTestsCommon : LightningTestSuite() { bobFundingAmount, alicePushAmount, bobPushAmount, + requestRemoteFunding, zeroConf, channelOrigin ) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/crypto/LocalKeyManagerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/crypto/LocalKeyManagerTestsCommon.kt index 0af52f8cd..0a519fb34 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/crypto/LocalKeyManagerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/crypto/LocalKeyManagerTestsCommon.kt @@ -87,7 +87,7 @@ class LocalKeyManagerTestsCommon : LightningTestSuite() { val keyManager = LocalKeyManager(seed, Chain.Regtest, TestConstants.aliceSwapInServerXpub) val fundingKeyPath = makeFundingKeyPath(ByteVector("be4fa97c62b9f88437a3be577b31eb48f2165c7bc252194a15ff92d995778cfb"), isInitiator = true) - val localParams = TestConstants.Alice.channelParams().copy(fundingKeyPath = fundingKeyPath) + val localParams = TestConstants.Alice.channelParams(payCommitTxFees = true).copy(fundingKeyPath = fundingKeyPath) val channelKeys = keyManager.channelKeys(localParams.fundingKeyPath) assertEquals(channelKeys.fundingPubKey(0), PrivateKey.fromHex("730c0f99408dbfbff00146acf84183ce539fabeeb22c143212f459d71374f715").publicKey()) @@ -104,7 +104,7 @@ class LocalKeyManagerTestsCommon : LightningTestSuite() { val keyManager = LocalKeyManager(seed, Chain.Regtest, TestConstants.aliceSwapInServerXpub) val fundingKeyPath = makeFundingKeyPath(ByteVector("06535806c1aa73971ec4877a5e2e684fa636136c073810f190b63eefc58ca488"), isInitiator = false) - val localParams = TestConstants.Alice.channelParams().copy(fundingKeyPath = fundingKeyPath) + val localParams = TestConstants.Alice.channelParams(payCommitTxFees = true).copy(fundingKeyPath = fundingKeyPath) val channelKeys = keyManager.channelKeys(localParams.fundingKeyPath) assertEquals(channelKeys.fundingPubKey(0), PrivateKey.fromHex("cd85f39fad742e5c742eeab16f5f1acaa9d9c48977767c7daa4708a47b7222ec").publicKey()) @@ -121,7 +121,7 @@ class LocalKeyManagerTestsCommon : LightningTestSuite() { val keyManager = LocalKeyManager(seed, Chain.Mainnet, DeterministicWallet.encode(dummyExtendedPubkey, testnet = false)) val fundingKeyPath = makeFundingKeyPath(ByteVector("ec1c41cd6be2b6e4ef46c1107f6c51fbb2066d7e1f7720bde4715af233ae1322"), isInitiator = true) - val localParams = TestConstants.Alice.channelParams().copy(fundingKeyPath = fundingKeyPath) + val localParams = TestConstants.Alice.channelParams(payCommitTxFees = true).copy(fundingKeyPath = fundingKeyPath) val channelKeys = keyManager.channelKeys(localParams.fundingKeyPath) assertEquals(channelKeys.fundingPubKey(0), PrivateKey.fromHex("b3b3f1af2ef961ee7aa62451a93a1fd57ea126c81008e5d95ced822cca30da6e").publicKey()) @@ -138,7 +138,7 @@ class LocalKeyManagerTestsCommon : LightningTestSuite() { val keyManager = LocalKeyManager(seed, Chain.Mainnet, DeterministicWallet.encode(dummyExtendedPubkey, testnet = false)) val fundingKeyPath = makeFundingKeyPath(ByteVector("2b4f045be5303d53f9d3a84a1e70c12251168dc29f300cf9cece0ec85cd8182b"), isInitiator = false) - val localParams = TestConstants.Alice.channelParams().copy(fundingKeyPath = fundingKeyPath) + val localParams = TestConstants.Alice.channelParams(payCommitTxFees = true).copy(fundingKeyPath = fundingKeyPath) val channelKeys = keyManager.channelKeys(localParams.fundingKeyPath) assertEquals(channelKeys.fundingPubKey(0), PrivateKey.fromHex("033880995016c275e725da625e4a78ea8c3215ab8ea54145fa3124bbb2e4a3d4").publicKey()) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt b/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt index b6760d2ed..f20ab9337 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt @@ -10,6 +10,7 @@ import fr.acinq.lightning.utils.toByteVector32 class InMemoryPaymentsDb : PaymentsDb { private val incoming = mutableMapOf() private val outgoing = mutableMapOf() + private val onChainOutgoing = mutableMapOf() private val outgoingParts = mutableMapOf>() override suspend fun setLocked(txId: TxId) {} @@ -70,7 +71,7 @@ class InMemoryPaymentsDb : PaymentsDb { outgoing[outgoingPayment.id] = outgoingPayment.copy(parts = listOf()) outgoingPayment.parts.forEach { outgoingParts[it.id] = Pair(outgoingPayment.id, it) } } - is OnChainOutgoingPayment -> {} // we don't persist on-chain payments + is OnChainOutgoingPayment -> onChainOutgoing[outgoingPayment.txId] = outgoingPayment } } @@ -84,6 +85,13 @@ class InMemoryPaymentsDb : PaymentsDb { } } + override suspend fun getInboundLiquidityPurchase(fundingTxId: TxId): InboundLiquidityOutgoingPayment? { + return when (val onChainPayment = onChainOutgoing[fundingTxId]) { + is InboundLiquidityOutgoingPayment -> onChainPayment + else -> null + } + } + override suspend fun completeOutgoingPaymentOffchain(id: UUID, preimage: ByteVector32, completedAt: Long) { require(outgoing.contains(id)) { "outgoing payment with id=$id doesn't exist" } val payment = outgoing[id]!! diff --git a/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt index f895d10cf..356cadace 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt @@ -1,9 +1,6 @@ package fr.acinq.lightning.db -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.Chain -import fr.acinq.bitcoin.Crypto -import fr.acinq.bitcoin.TxId +import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 @@ -13,12 +10,13 @@ import fr.acinq.lightning.payment.FinalFailure import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.tests.utils.runSuspendTest import fr.acinq.lightning.utils.* +import fr.acinq.lightning.wire.LiquidityAds import kotlin.test.* class PaymentsDbTestsCommon : LightningTestSuite() { @Test - fun `receive incoming payment with 1 htlc`() = runSuspendTest { + fun `receive incoming lightning payment with 1 htlc`() = runSuspendTest { val (db, preimage, pr) = createFixture() assertNull(db.getIncomingPayment(pr.paymentHash)) @@ -29,39 +27,19 @@ class PaymentsDbTestsCommon : LightningTestSuite() { assertNotNull(pending) assertEquals(incoming, pending) - db.receivePayment( - pr.paymentHash, - listOf( - IncomingPayment.ReceivedWith.LightningPayment( - amount = 200_000.msat, - channelId = channelId, - htlcId = 1L - ) - ), 110 - ) + val receivedWith = IncomingPayment.ReceivedWith.LightningPayment(200_000.msat, channelId, 1, fundingFee = null) + db.receivePayment(pr.paymentHash, listOf(receivedWith), 110) val received = db.getIncomingPayment(pr.paymentHash) assertNotNull(received) - assertEquals( - pending.copy( - received = IncomingPayment.Received( - listOf( - IncomingPayment.ReceivedWith.LightningPayment( - amount = 200_000.msat, - channelId = channelId, - htlcId = 1L - ) - ), 110 - ) - ), received - ) + assertEquals(pending.copy(received = IncomingPayment.Received(listOf(receivedWith), 110)), received) } @Test - fun `receive incoming payment with several parts`() = runSuspendTest { + fun `receive incoming lightning payment with several parts`() = runSuspendTest { val (db, preimage, pr) = createFixture() assertNull(db.getIncomingPayment(pr.paymentHash)) - val (channelId1, channelId2, channelId3) = listOf(randomBytes32(), randomBytes32(), randomBytes32()) + val (channelId1, channelId2) = listOf(randomBytes32(), randomBytes32()) val incoming = IncomingPayment(preimage, IncomingPayment.Origin.Invoice(pr), null, 200) db.addIncomingPayment(preimage, IncomingPayment.Origin.Invoice(pr), 200) val pending = db.getIncomingPayment(pr.paymentHash) @@ -70,94 +48,80 @@ class PaymentsDbTestsCommon : LightningTestSuite() { db.receivePayment( pr.paymentHash, listOf( - IncomingPayment.ReceivedWith.LightningPayment(amount = 57_000.msat, channelId = channelId1, htlcId = 1L), - IncomingPayment.ReceivedWith.LightningPayment(amount = 43_000.msat, channelId = channelId2, htlcId = 54L), - IncomingPayment.ReceivedWith.NewChannel(amount = 99_000.msat, channelId = channelId3, serviceFee = 1_000.msat, miningFee = 0.sat, txId = TxId(randomBytes32()), confirmedAt = null, lockedAt = null) + IncomingPayment.ReceivedWith.LightningPayment(57_000.msat, channelId1, 1, fundingFee = null), + IncomingPayment.ReceivedWith.LightningPayment(43_000.msat, channelId2, 54, fundingFee = null), ), 110 ) val received = db.getIncomingPayment(pr.paymentHash) assertNotNull(received) - assertEquals(199_000.msat, received.amount) - assertEquals(1_000.msat, received.fees) - assertEquals(3, received.received!!.receivedWith.size) - assertEquals(57_000.msat, received.received!!.receivedWith.elementAt(0).amount) + assertEquals(100_000.msat, received.amount) + assertEquals(0.msat, received.fees) + assertEquals(2, received.received!!.receivedWith.size) + assertEquals(57_000.msat, received.received!!.receivedWith.elementAt(0).amountReceived) assertEquals(0.msat, received.received!!.receivedWith.elementAt(0).fees) assertEquals(channelId1, (received.received!!.receivedWith.elementAt(0) as IncomingPayment.ReceivedWith.LightningPayment).channelId) - assertEquals(54L, (received.received!!.receivedWith.elementAt(1) as IncomingPayment.ReceivedWith.LightningPayment).htlcId) - assertEquals(channelId3, (received.received!!.receivedWith.elementAt(2) as IncomingPayment.ReceivedWith.NewChannel).channelId) + assertEquals(54, (received.received!!.receivedWith.elementAt(1) as IncomingPayment.ReceivedWith.LightningPayment).htlcId) } @Test - fun `receiving several payments on the same payment hash is additive`() = runSuspendTest { + fun `receive several incoming lightning payments with the same payment hash`() = runSuspendTest { val (db, preimage, pr) = createFixture() val channelId = randomBytes32() db.addIncomingPayment(preimage, IncomingPayment.Origin.Invoice(pr), 200) - db.receivePayment( - pr.paymentHash, listOf( - IncomingPayment.ReceivedWith.LightningPayment( - amount = 200_000.msat, - channelId = channelId, - htlcId = 1L - ) - ), 110 + val receivedWith = listOf( + IncomingPayment.ReceivedWith.LightningPayment(200_000.msat, channelId, 1, fundingFee = null), + IncomingPayment.ReceivedWith.LightningPayment(100_000.msat, channelId, 2, fundingFee = null) ) + db.receivePayment(pr.paymentHash, listOf(receivedWith.first()), 110) val received1 = db.getIncomingPayment(pr.paymentHash) assertNotNull(received1) assertNotNull(received1.received) assertEquals(200_000.msat, received1.amount) - db.receivePayment( - pr.paymentHash, listOf( - IncomingPayment.ReceivedWith.LightningPayment( - amount = 100_000.msat, - channelId = channelId, - htlcId = 2L - ) - ), 150 - ) + db.receivePayment(pr.paymentHash, listOf(receivedWith.last()), 150) val received2 = db.getIncomingPayment(pr.paymentHash) assertNotNull(received2) assertNotNull(received2.received) assertEquals(300_000.msat, received2.amount) assertEquals(150, received2.received!!.receivedAt) - assertEquals( - listOf( - IncomingPayment.ReceivedWith.LightningPayment( - amount = 200_000.msat, - channelId = channelId, - htlcId = 1L - ), - IncomingPayment.ReceivedWith.LightningPayment( - amount = 100_000.msat, - channelId = channelId, - htlcId = 2L - ) - ), received2.received!!.receivedWith - ) + assertEquals(receivedWith, received2.received!!.receivedWith) } @Test - fun `received total amount accounts for the fee`() = runSuspendTest { + fun `receive lightning payment with funding fee`() = runSuspendTest { val (db, preimage, pr) = createFixture() db.addIncomingPayment(preimage, IncomingPayment.Origin.Invoice(pr), 200) - db.receivePayment( - pr.paymentHash, listOf( - IncomingPayment.ReceivedWith.NewChannel( - amount = 500_000.msat, - serviceFee = 15_000.msat, - miningFee = 0.sat, - channelId = randomBytes32(), - txId = TxId(randomBytes32()), - confirmedAt = null, - lockedAt = null - ) - ), 110 - ) - val received1 = db.getIncomingPayment(pr.paymentHash) - assertNotNull(received1?.received) - assertEquals(500_000.msat, received1!!.amount) - assertEquals(15_000.msat, received1.fees) + val receivedWith = IncomingPayment.ReceivedWith.LightningPayment(40_000_000.msat, randomBytes32(), 3, LiquidityAds.FundingFee(10_000_000.msat, TxId(randomBytes32()))) + db.receivePayment(pr.paymentHash, listOf(receivedWith), 110) + val received = db.getIncomingPayment(pr.paymentHash) + assertNotNull(received?.received) + assertEquals(40_000_000.msat, received!!.amount) + assertEquals(10_000_000.msat, received.fees) + } + + @Test + fun `receive incoming on-chain payments`() = runSuspendTest { + val (db, _, _) = createFixture() + val origin = IncomingPayment.Origin.OnChain(TxId(randomBytes32()), setOf(OutPoint(TxId(randomBytes32()), 7))) + run { + val incomingPayment = db.addIncomingPayment(randomBytes32(), origin) + val receivedWith = IncomingPayment.ReceivedWith.NewChannel(100_000_000.msat, 5_000_000.msat, 2_500.sat, randomBytes32(), origin.txId, null, null) + db.receivePayment(incomingPayment.paymentHash, listOf(receivedWith)) + val received = db.getIncomingPayment(incomingPayment.paymentHash) + assertNotNull(received?.received) + assertEquals(100_000_000.msat, received!!.amount) + assertEquals(7_500_000.msat, received.fees) + } + run { + val incomingPayment = db.addIncomingPayment(randomBytes32(), origin) + val receivedWith = IncomingPayment.ReceivedWith.SpliceIn(100_000_000.msat, 5_000_000.msat, 2_500.sat, randomBytes32(), origin.txId, null, null) + db.receivePayment(incomingPayment.paymentHash, listOf(receivedWith)) + val received = db.getIncomingPayment(incomingPayment.paymentHash) + assertNotNull(received?.received) + assertEquals(100_000_000.msat, received!!.amount) + assertEquals(7_500_000.msat, received.fees) + } } @Test diff --git a/src/commonTest/kotlin/fr/acinq/lightning/io/peer/PeerTest.kt b/src/commonTest/kotlin/fr/acinq/lightning/io/peer/PeerTest.kt index 9a00fa7ed..56032a7c6 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/io/peer/PeerTest.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/io/peer/PeerTest.kt @@ -4,25 +4,22 @@ import fr.acinq.bitcoin.Block import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.utils.Either -import fr.acinq.lightning.CltvExpiryDelta -import fr.acinq.lightning.InvoiceDefaultRoutingFees +import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey -import fr.acinq.lightning.NodeUri -import fr.acinq.lightning.PeerConnected import fr.acinq.lightning.blockchain.BITCOIN_FUNDING_DEPTHOK import fr.acinq.lightning.blockchain.WatchEventConfirmed -import fr.acinq.lightning.blockchain.electrum.balance import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.channel.ChannelFlags import fr.acinq.lightning.channel.ChannelType import fr.acinq.lightning.channel.LNChannel -import fr.acinq.lightning.channel.Origin import fr.acinq.lightning.channel.TestsHelper import fr.acinq.lightning.channel.TestsHelper.createWallet import fr.acinq.lightning.channel.states.* import fr.acinq.lightning.db.InMemoryDatabases import fr.acinq.lightning.db.LightningOutgoingPayment import fr.acinq.lightning.io.* +import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.lightning.router.Announcements import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.tests.io.peer.* @@ -31,8 +28,8 @@ import fr.acinq.lightning.tests.utils.runSuspendTest import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat -import fr.acinq.lightning.utils.toMilliSatoshi import fr.acinq.lightning.wire.* +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlin.test.* @@ -59,7 +56,7 @@ class PeerTest : LightningTestSuite() { randomKey().publicKey(), randomKey().publicKey(), randomKey().publicKey(), - 0.toByte(), + ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = false), TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputsZeroReserve)) ) @@ -123,7 +120,7 @@ class PeerTest : LightningTestSuite() { val (alice, bob, alice2bob, bob2alice) = newPeers(this, nodeParams, walletParams, automateMessaging = false) val wallet = createWallet(nodeParams.first.keyManager, 300_000.sat).second - alice.send(OpenChannel(250_000.sat, 50_000_000.msat, wallet, FeeratePerKw(3000.sat), FeeratePerKw(2500.sat), 0, ChannelType.SupportedChannelType.AnchorOutputsZeroReserve)) + alice.send(OpenChannel(250_000.sat, 50_000_000.msat, wallet, FeeratePerKw(3000.sat), FeeratePerKw(2500.sat), ChannelType.SupportedChannelType.AnchorOutputsZeroReserve)) val open = alice2bob.expect() bob.forward(open) @@ -183,7 +180,7 @@ class PeerTest : LightningTestSuite() { val (alice, bob, alice2bob, bob2alice) = newPeers(this, nodeParams, walletParams, automateMessaging = false) val wallet = createWallet(nodeParams.first.keyManager, 300_000.sat).second - alice.send(OpenChannel(250_000.sat, 50_000_000.msat, wallet, FeeratePerKw(3000.sat), FeeratePerKw(2500.sat), 0, ChannelType.SupportedChannelType.AnchorOutputsZeroReserve)) + alice.send(OpenChannel(250_000.sat, 50_000_000.msat, wallet, FeeratePerKw(3000.sat), FeeratePerKw(2500.sat), ChannelType.SupportedChannelType.AnchorOutputsZeroReserve)) val open = alice2bob.expect() bob.forward(open) @@ -225,106 +222,42 @@ class PeerTest : LightningTestSuite() { @Test fun `swap funds into a channel`() = runSuspendTest { val nodeParams = Pair(TestConstants.Alice.nodeParams, TestConstants.Bob.nodeParams) + nodeParams.second.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = 100_000.sat, maxAbsoluteFee = 20_000.sat, maxRelativeFeeBasisPoints = 1000, skipAbsoluteFeeCheck = false)) val walletParams = Pair(TestConstants.Alice.walletParams, TestConstants.Bob.walletParams) - val (alice, bob, alice2bob, bob2alice) = newPeers(this, nodeParams, walletParams, automateMessaging = false) + val (_, bob, _, bob2alice) = newPeers(this, nodeParams, walletParams, automateMessaging = false) - val requestId = randomBytes32() - val walletBob = createWallet(nodeParams.second.keyManager, 260_000.sat).second - val internalRequestBob = RequestChannelOpen(requestId, walletBob) - bob.send(internalRequestBob) - val request = bob2alice.expect() - assertEquals(request.localFundingAmount, 260_000.sat) - - val miningFee = 500.sat - val serviceFee = 1_000.sat.toMilliSatoshi() - val walletAlice = createWallet(nodeParams.first.keyManager, 50_000.sat).second - val openAlice = OpenChannel(40_000.sat, 0.msat, walletAlice, FeeratePerKw(3500.sat), FeeratePerKw(2500.sat), 0, ChannelType.SupportedChannelType.AnchorOutputsZeroReserve) - alice.send(openAlice) - val open = alice2bob.expect().copy( - tlvStream = TlvStream( - ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputsZeroReserve), - ChannelTlv.OriginTlv(Origin.PleaseOpenChannelOrigin(requestId, serviceFee, miningFee, openAlice.pushAmount)) - ) - ) - bob.forward(open) - val accept = bob2alice.expect() - assertEquals(open.temporaryChannelId, accept.temporaryChannelId) - val fundingFee = walletBob.balance - accept.fundingAmount - assertEquals(accept.pushAmount, serviceFee + miningFee.toMilliSatoshi() - fundingFee.toMilliSatoshi()) - alice.forward(accept) + val walletBob = createWallet(nodeParams.second.keyManager, 500_000.sat).second + bob.send(AddWalletInputsToChannel(walletBob)) - val txAddInputAlice = alice2bob.expect() - bob.forward(txAddInputAlice) - val txAddInputBob = bob2alice.expect() - alice.forward(txAddInputBob) - val txAddOutput = alice2bob.expect() - bob.forward(txAddOutput) - val txCompleteBob = bob2alice.expect() - alice.forward(txCompleteBob) - val txCompleteAlice = alice2bob.expect() - bob.forward(txCompleteAlice) - val commitSigBob = bob2alice.expect() - alice.forward(commitSigBob) - val commitSigAlice = alice2bob.expect() - bob.forward(commitSigAlice) - val txSigsAlice = alice2bob.expect() - bob.forward(txSigsAlice) - val txSigsBob = bob2alice.expect() - alice.forward(txSigsBob) - val (_, aliceState) = alice.expectState() - assertEquals(aliceState.commitments.latest.localCommit.spec.toLocal, openAlice.fundingAmount.toMilliSatoshi() + serviceFee + miningFee.toMilliSatoshi() - fundingFee.toMilliSatoshi()) - val (_, bobState) = bob.expectState() - // Bob has to deduce from its balance: - // - the fees for the channel open (10 000 sat) - // - the miner fees for his input(s) in the funding transaction - assertEquals(bobState.commitments.latest.localCommit.spec.toLocal, walletBob.balance.toMilliSatoshi() - serviceFee - miningFee.toMilliSatoshi()) + val open = bob2alice.expect() + assertTrue(open.fundingAmount < 500_000.sat) // we pay the mining fees + assertTrue(open.channelFlags.nonInitiatorPaysCommitFees) + assertEquals(open.requestFunding?.requestedAmount, 100_000.sat) // we always request funds from the remote, because we ask them to pay the commit tx fees + assertEquals(open.channelType, ChannelType.SupportedChannelType.AnchorOutputsZeroReserve) + // We cannot test the rest of the flow as lightning-kmp doesn't implement the LSP side that responds to the liquidity ads request. } @Test fun `reject swap-in -- fee too high`() = runSuspendTest { val nodeParams = Pair(TestConstants.Alice.nodeParams, TestConstants.Bob.nodeParams) val walletParams = Pair(TestConstants.Alice.walletParams, TestConstants.Bob.walletParams) - val (alice, bob, alice2bob, bob2alice) = newPeers(this, nodeParams, walletParams, automateMessaging = false) - - val requestId = randomBytes32() - val walletBob = createWallet(nodeParams.second.keyManager, 260_000.sat).second - val internalRequestBob = RequestChannelOpen(requestId, walletBob) - bob.send(internalRequestBob) - val request = bob2alice.expect() - assertEquals(request.localFundingAmount, 260_000.sat) - val fundingFee = 100.sat - val serviceFee = request.localFundingAmount.toMilliSatoshi() * 0.02 // 2% fee is too high - val walletAlice = createWallet(nodeParams.first.keyManager, 50_000.sat).second - val openAlice = OpenChannel(40_000.sat, 0.msat, walletAlice, FeeratePerKw(3500.sat), FeeratePerKw(2500.sat), 0, ChannelType.SupportedChannelType.AnchorOutputsZeroReserve) - alice.send(openAlice) - val open = alice2bob.expect().copy( - tlvStream = TlvStream( - ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputsZeroReserve), - ChannelTlv.OriginTlv(Origin.PleaseOpenChannelOrigin(requestId, serviceFee, fundingFee, openAlice.pushAmount)) - ) - ) - bob.forward(open) - bob2alice.expect() - } - - @Test - fun `reject swap-in -- no associated channel request`() = runSuspendTest { - val nodeParams = Pair(TestConstants.Alice.nodeParams, TestConstants.Bob.nodeParams) - val walletParams = Pair(TestConstants.Alice.walletParams, TestConstants.Bob.walletParams) - val (alice, bob, alice2bob, bob2alice) = newPeers(this, nodeParams, walletParams, automateMessaging = false) - - val requestId = randomBytes32() - val walletAlice = createWallet(nodeParams.first.keyManager, 50_000.sat).second - val openAlice = OpenChannel(40_000.sat, 0.msat, walletAlice, FeeratePerKw(3500.sat), FeeratePerKw(2500.sat), 0, ChannelType.SupportedChannelType.AnchorOutputsZeroReserve) - alice.send(openAlice) - val open = alice2bob.expect().copy( - tlvStream = TlvStream( - ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputsZeroReserve), - ChannelTlv.OriginTlv(Origin.PleaseOpenChannelOrigin(requestId, 50.sat.toMilliSatoshi(), 100.sat, openAlice.pushAmount)) - ) + val (_, bob) = newPeers(this, nodeParams, walletParams, automateMessaging = false) + + // Bob's liquidity policy is too restrictive. + val bobPolicy = LiquidityPolicy.Auto( + inboundLiquidityTarget = 500_000.sat, + maxAbsoluteFee = 100.sat, + maxRelativeFeeBasisPoints = 10, + skipAbsoluteFeeCheck = false ) - bob.forward(open) - bob2alice.expect() + nodeParams.second.liquidityPolicy.emit(bobPolicy) + val walletBob = createWallet(nodeParams.second.keyManager, 1_000_000.sat).second + bob.send(AddWalletInputsToChannel(walletBob)) + + val rejected = bob.nodeParams.nodeEvents.filterIsInstance().first() + assertEquals(1_500_000_000.msat, rejected.amount) + assertEquals(LiquidityEvents.Source.OnChainWallet, rejected.source) + assertEquals(LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(maxRelativeFeeBasisPoints = 10), rejected.reason) } @Test diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt index e3d323917..594eab8ed 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt @@ -3,15 +3,18 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* +import fr.acinq.lightning.Lightning.randomBytes import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.channel.* import fr.acinq.lightning.crypto.RouteBlinding import fr.acinq.lightning.crypto.sphinx.Sphinx.hash import fr.acinq.lightning.db.InMemoryPaymentsDb +import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.IncomingPaymentsDb -import fr.acinq.lightning.io.PayToOpenResponseCommand +import fr.acinq.lightning.io.AddLiquidityForIncomingPayment +import fr.acinq.lightning.io.SendOnTheFlyFundingMessage import fr.acinq.lightning.io.WrappedChannelCommand import fr.acinq.lightning.router.ChannelHop import fr.acinq.lightning.router.NodeHop @@ -20,7 +23,9 @@ import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.tests.utils.runSuspendTest import fr.acinq.lightning.utils.* import fr.acinq.lightning.wire.* +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlin.test.* import kotlin.time.Duration.Companion.milliseconds @@ -135,7 +140,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { checkDbPayment(incomingPayment, paymentHandler.db) val channelId = randomBytes32() val add = makeUpdateAddHtlc(12, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true) @@ -143,253 +148,363 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { assertEquals(result.incomingPayment.received, result.received) assertEquals(defaultAmount, result.received.amount) - assertEquals(listOf(IncomingPayment.ReceivedWith.LightningPayment(amount = defaultAmount, channelId = channelId, htlcId = 12)), result.received.receivedWith) - + assertEquals(listOf(IncomingPayment.ReceivedWith.LightningPayment(defaultAmount, channelId, 12, null)), result.received.receivedWith) checkDbPayment(result.incomingPayment, paymentHandler.db) } @Test - fun `receive pay-to-open payment with single HTLC`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) - checkDbPayment(incomingPayment, paymentHandler.db) - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) + fun `receive multipart payment with multiple HTLCs`() = runSuspendTest { + val channelId = randomBytes32() + val (amount1, amount2) = Pair(100_000_000.msat, 50_000_000.msat) + val totalAmount = amount1 + amount2 + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) - assertIs(result) - val expected = PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest.chainHash, payToOpenRequest.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage))) - assertEquals(setOf(expected), result.actions.toSet()) + // Step 1 of 2: + // - Alice sends first multipart htlc to Bob + // - Bob doesn't accept the MPP set yet + run { + val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) + assertIs(result) + assertTrue(result.actions.isEmpty()) + } - // the pay-to-open part is not yet inserted in db - assertTrue(result.received.receivedWith.isEmpty()) - assertEquals(0.msat, result.received.amount) - assertEquals(0.msat, result.received.fees) + // Step 2 of 2: + // - Alice sends second multipart htlc to Bob + // - Bob now accepts the MPP set + run { + val add = makeUpdateAddHtlc(5, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) + assertIs(result) + val (expectedActions, expectedReceivedWith) = setOf( + // @formatter:off + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0, fundingFee = null), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(5, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 5, fundingFee = null), + // @formatter:on + ).unzip() + assertEquals(expectedActions.toSet(), result.actions.toSet()) + assertEquals(totalAmount, result.received.amount) + assertEquals(expectedReceivedWith, result.received.receivedWith) + checkDbPayment(result.incomingPayment, paymentHandler.db) + } + } - // later on, a channel is created + @Test + fun `receive multipart payment after disconnection`() = runSuspendTest { val channelId = randomBytes32() - val amountOrigin = ChannelAction.Storage.StoreIncomingPayment.ViaNewChannel( - amount = payToOpenRequest.amountMsat, - serviceFee = payToOpenRequest.payToOpenFeeSatoshis.toMilliSatoshi(), - miningFee = 0.sat, - localInputs = emptySet(), - txId = TxId(randomBytes32()), - origin = Origin.PayToOpenOrigin(amount = payToOpenRequest.amountMsat, paymentHash = payToOpenRequest.paymentHash, serviceFee = 0.msat, miningFee = payToOpenRequest.payToOpenFeeSatoshis) - ) - paymentHandler.process(channelId, amountOrigin) - paymentHandler.db.getIncomingPayment(payToOpenRequest.paymentHash).also { dbPayment -> - assertNotNull(dbPayment) - assertIs(dbPayment.origin) - assertNotNull(dbPayment.received) - assertEquals(1, dbPayment.received!!.receivedWith.size) - dbPayment.received!!.receivedWith.first().also { part -> - assertIs(part) - assertEquals(amountOrigin.amount, part.amount) - assertEquals(amountOrigin.serviceFee, part.serviceFee) - assertEquals(amountOrigin.miningFee, part.miningFee) - assertEquals(channelId, part.channelId) - assertNull(part.confirmedAt) - } - assertEquals(amountOrigin.amount, dbPayment.received?.amount) - assertEquals(amountOrigin.serviceFee, dbPayment.received?.fees) + val (amount1, amount2) = Pair(75_000.msat, 75_000.msat) + val totalAmount = amount1 + amount2 + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + + // Step 1: Alice sends first multipart htlc to Bob. + val add1 = run { + val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) + assertIs(result) + assertNull(result.incomingPayment.received) + assertTrue(result.actions.isEmpty()) + add + } + + // Step 2: Bob disconnects, and cleans up pending HTLCs. + paymentHandler.purgePendingPayments() + + // Step 3: on reconnection, the HTLC from step 1 is processed again. + run { + val result = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) + assertIs(result) + assertNull(result.incomingPayment.received) + assertTrue(result.actions.isEmpty()) } + // Step 4: Alice sends second multipart htlc to Bob. + run { + val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) + assertIs(result) + val (expectedActions, expectedReceivedWith) = setOf( + // @formatter:off + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0, fundingFee = null), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 1, fundingFee = null), + // @formatter:on + ).unzip() + assertEquals(expectedActions.toSet(), result.actions.toSet()) + assertEquals(totalAmount, result.received.amount) + assertEquals(expectedReceivedWith, result.received.receivedWith) + checkDbPayment(result.incomingPayment, paymentHandler.db) + } } @Test - fun `receive pay-to-open payment with two evenly-split HTLCs`() = runSuspendTest { + fun `receive will_add_htlc`() = runSuspendTest { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) checkDbPayment(incomingPayment, paymentHandler.db) - val payToOpenRequest1 = makePayToOpenRequest(incomingPayment, makeMppPayload(50_000.msat, defaultAmount, paymentSecret)) - val payToOpenRequest2 = makePayToOpenRequest(incomingPayment, makeMppPayload(50_000.msat, defaultAmount, paymentSecret)) + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() + assertIs(addLiquidity) + assertEquals(incomingPayment.preimage, addLiquidity.preimage) + assertEquals(defaultAmount, addLiquidity.paymentAmount) + assertEquals(defaultAmount, addLiquidity.requestedAmount.toMilliSatoshi()) + assertEquals(TestConstants.fundingRates.fundingRates.first(), addLiquidity.fundingRate) + assertEquals(listOf(willAddHtlc), addLiquidity.willAddHtlcs) + // We don't update the payments DB: we're waiting to receive HTLCs after the open/splice. + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } - val result1 = paymentHandler.process(payToOpenRequest1, TestConstants.defaultBlockHeight) - assertIs(result1) - val result2 = paymentHandler.process(payToOpenRequest2, TestConstants.defaultBlockHeight) - assertIs(result2) + @Test + fun `receive will_add_htlc -- rounding without liquidity purchase`() = runSuspendTest { + val paymentAmount = 555_555_555.msat + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(paymentAmount) + paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = 0.sat, maxAbsoluteFee = 10_000.sat, maxRelativeFeeBasisPoints = 500, skipAbsoluteFeeCheck = false)) + checkDbPayment(incomingPayment, paymentHandler.db) + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(paymentAmount, paymentAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() + assertIs(addLiquidity) + assertEquals(555_556.sat, addLiquidity.requestedAmount) + // We don't update the payments DB: we're waiting to receive HTLCs after the open/splice. + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } + + @Test + fun `receive two evenly-split will_add_htlc`() = runSuspendTest { + val amount = 50_000_000.msat + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(amount * 2) + checkDbPayment(incomingPayment, paymentHandler.db) - val expected = PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest1.chainHash, payToOpenRequest1.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage))) - assertEquals(setOf(expected), (result1.actions + result2.actions).toSet()) + // Step 1 of 2: + // - Alice sends first will_add_htlc to Bob + // - Bob doesn't trigger the open/splice yet + run { + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount, amount * 2, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + assertTrue(result.actions.isEmpty()) + } - // pay-to-open parts are not yet inserted in db - assertTrue(result2.received.receivedWith.isEmpty()) + // Step 2 of 2: + // - Alice sends second will_add_htlc to Bob + // - Bob trigger an open/splice + run { + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount, amount * 2, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() as AddLiquidityForIncomingPayment + assertEquals(incomingPayment.preimage, addLiquidity.preimage) + assertEquals(amount * 2, addLiquidity.paymentAmount) + assertEquals(2, addLiquidity.willAddHtlcs.size) + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } } @Test - fun `receive pay-to-open payment with two unevenly-split HTLCs`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + fun `receive two unevenly-split will_add_htlc`() = runSuspendTest { + val (amount1, amount2) = Pair(50_000_000.msat, 75_000_000.msat) + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(amount1 + amount2) checkDbPayment(incomingPayment, paymentHandler.db) - val payToOpenRequest1 = makePayToOpenRequest(incomingPayment, makeMppPayload(40_000.msat, defaultAmount, paymentSecret)) - val payToOpenRequest2 = makePayToOpenRequest(incomingPayment, makeMppPayload(60_000.msat, defaultAmount, paymentSecret)) - - val result1 = paymentHandler.process(payToOpenRequest1, TestConstants.defaultBlockHeight) - assertIs(result1) - assertEquals(emptyList(), result1.actions) - val result2 = paymentHandler.process(payToOpenRequest2, TestConstants.defaultBlockHeight) - assertIs(result2) - val payToOpenResponse = PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest1.chainHash, payToOpenRequest1.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage))) - assertEquals(listOf(payToOpenResponse), result2.actions) + // The sender overpays the total_amount, which is ok. + val totalAmount = amount1 + amount2 + 10_000_000.msat - assertEquals(0.msat, result2.received.amount) - assertEquals(0.msat, result2.received.fees) + // Step 1 of 2: + // - Alice sends first will_add_htlc to Bob + // - Bob doesn't trigger the open/splice yet + run { + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + assertTrue(result.actions.isEmpty()) + } - checkDbPayment(result2.incomingPayment, paymentHandler.db) + // Step 2 of 2: + // - Alice sends second will_add_htlc to Bob + // - Bob trigger an open/splice + run { + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2 + 10_000_000.msat, totalAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() as AddLiquidityForIncomingPayment + assertEquals(incomingPayment.preimage, addLiquidity.preimage) + assertEquals(totalAmount, addLiquidity.paymentAmount) + assertEquals(2, addLiquidity.willAddHtlcs.size) + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } } @Test - fun `receive pay-to-open payment with an unknown payment hash`() = runSuspendTest { - val (paymentHandler, _, _) = createFixture(defaultAmount) - val payToOpenRequest = PayToOpenRequest( - chainHash = BlockHash(ByteVector32.Zeroes), - amountMsat = defaultAmount, - payToOpenFeeSatoshis = 100.sat, - paymentHash = ByteVector32.One, // <-- not associated to a pending invoice - expireAt = Long.MAX_VALUE, - finalPacket = OutgoingPaymentPacket.buildPacket( - paymentHash = ByteVector32.One, // <-- has to be the same as the one above otherwise encryption fails - hops = channelHops(paymentHandler.nodeParams.nodeId), - finalPayload = makeMppPayload(defaultAmount, defaultAmount, randomBytes32()), - payloadLength = OnionRoutingPacket.PaymentPacketLength - ).third.packet - ) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) + fun `receive trampoline will_add_htlc`() = runSuspendTest { + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) + val willAddHtlc = run { + // We simulate a trampoline-relay with a dummy channel hop between the liquidity provider and the wallet. + val (amount, expiry, trampolineOnion) = OutgoingPaymentPacket.buildPacket( + incomingPayment.paymentHash, + listOf(NodeHop(TestConstants.Alice.nodeParams.nodeId, TestConstants.Bob.nodeParams.nodeId, CltvExpiryDelta(144), 0.msat)), + makeMppPayload(defaultAmount, defaultAmount, paymentSecret), + null + ) + assertTrue(trampolineOnion.packet.payload.size() < 500) + makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, PaymentOnion.FinalPayload.Standard.createTrampolinePayload(amount, amount, expiry, randomBytes32(), trampolineOnion.packet)) + } + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() + assertIs(addLiquidity) + assertEquals(incomingPayment.preimage, addLiquidity.preimage) + assertEquals(defaultAmount, addLiquidity.paymentAmount) + assertEquals(defaultAmount, addLiquidity.requestedAmount.toMilliSatoshi()) + assertEquals(TestConstants.fundingRates.fundingRates.first(), addLiquidity.fundingRate) + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } + @Test + fun `receive will_add_htlc with an unknown payment hash`() = runSuspendTest { + val (paymentHandler, _, paymentSecret) = createFixture(defaultAmount) + val willAddHtlc = makeWillAddHtlc(paymentHandler, randomBytes32(), makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertNull(result.incomingPayment) - val expected = PayToOpenResponseCommand( - PayToOpenResponse( - payToOpenRequest.chainHash, - payToOpenRequest.paymentHash, - PayToOpenResponse.Result.Failure( - OutgoingPaymentPacket.buildHtlcFailure( - paymentHandler.nodeParams.nodePrivateKey, - payToOpenRequest.paymentHash, - payToOpenRequest.finalPacket, - ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(payToOpenRequest.amountMsat, TestConstants.defaultBlockHeight.toLong())) - ).right!! - ) - ) - ) - assertEquals(setOf(expected), result.actions.toSet()) + val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(paymentHandler.nodeParams.nodePrivateKey, willAddHtlc, IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())) + assertIs(failure) + assertEquals(listOf(SendOnTheFlyFundingMessage(failure)), result.actions) } @Test - fun `receive pay-to-open payment with an incorrect payment secret`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(defaultAmount, defaultAmount, paymentSecret.reversed())) // <--- wrong secret - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - + fun `receive will_add_htlc with an incorrect payment secret`() = runSuspendTest { + val (paymentHandler, incomingPayment, _) = createFixture(defaultAmount) + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, randomBytes32())) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertEquals(incomingPayment, result.incomingPayment) - val expected = PayToOpenResponseCommand( - PayToOpenResponse( - payToOpenRequest.chainHash, - payToOpenRequest.paymentHash, - PayToOpenResponse.Result.Failure( - OutgoingPaymentPacket.buildHtlcFailure( - paymentHandler.nodeParams.nodePrivateKey, - payToOpenRequest.paymentHash, - payToOpenRequest.finalPacket, - ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(payToOpenRequest.amountMsat, TestConstants.defaultBlockHeight.toLong())) - ).right!! - ) - ) - ) - assertEquals(setOf(expected), result.actions.toSet()) + val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(paymentHandler.nodeParams.nodePrivateKey, willAddHtlc, IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())) + assertIs(failure) + assertEquals(listOf(SendOnTheFlyFundingMessage(failure)), result.actions) } @Test - fun `receive pay-to-open payment with a fee too high`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)).copy(payToOpenFeeSatoshis = 2_000.sat) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - + fun `receive trampoline will_add_htlc with an incorrect payment secret`() = runSuspendTest { + val (paymentHandler, incomingPayment, _) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) + val willAddHtlc = run { + // We simulate a trampoline-relay with a dummy channel hop between the liquidity provider and the wallet. + val (amount, expiry, trampolineOnion) = OutgoingPaymentPacket.buildPacket( + incomingPayment.paymentHash, + listOf(NodeHop(TestConstants.Alice.nodeParams.nodeId, TestConstants.Bob.nodeParams.nodeId, CltvExpiryDelta(144), 0.msat)), + makeMppPayload(defaultAmount, defaultAmount, randomBytes32()), + null + ) + assertTrue(trampolineOnion.packet.payload.size() < 500) + makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, PaymentOnion.FinalPayload.Standard.createTrampolinePayload(amount, amount, expiry, randomBytes32(), trampolineOnion.packet)) + } + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertEquals(incomingPayment, result.incomingPayment) - val expected = PayToOpenResponseCommand( - PayToOpenResponse( - payToOpenRequest.chainHash, - payToOpenRequest.paymentHash, - PayToOpenResponse.Result.Failure( - OutgoingPaymentPacket.buildHtlcFailure( - paymentHandler.nodeParams.nodePrivateKey, - payToOpenRequest.paymentHash, - payToOpenRequest.finalPacket, - ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure) - ).right!! - ) - ) - ) - assertEquals(setOf(expected), result.actions.toSet()) + val failure = OutgoingPaymentPacket.buildWillAddHtlcFailure(paymentHandler.nodeParams.nodePrivateKey, willAddHtlc, IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())) + assertIs(failure) + assertEquals(listOf(SendOnTheFlyFundingMessage(failure)), result.actions) } @Test - fun `receive pay-to-open trampoline payment with an incorrect payment secret`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) - val trampolineHops = listOf( - NodeHop(TestConstants.Alice.nodeParams.nodeId, TestConstants.Bob.nodeParams.nodeId, CltvExpiryDelta(144), 0.msat) - ) - val payToOpenRequest = PayToOpenRequest( - chainHash = BlockHash(ByteVector32.Zeroes), - amountMsat = defaultAmount, - payToOpenFeeSatoshis = 100.sat, - paymentHash = incomingPayment.paymentHash, - expireAt = Long.MAX_VALUE, - finalPacket = OutgoingPaymentPacket.buildPacket( - paymentHash = incomingPayment.paymentHash, - hops = trampolineHops, - finalPayload = makeMppPayload(defaultAmount, defaultAmount, paymentSecret.reversed()), // <-- wrong secret - payloadLength = 400 - ).third.packet + @OptIn(ExperimentalCoroutinesApi::class) + fun `receive will_add_htlc with a fee too high`() = runSuspendTest { + val fundingRates = LiquidityAds.WillFundRates( + // Note that we use a fixed liquidity fees to make testing easier. + fundingRates = listOf(LiquidityAds.FundingRate(0.sat, 250_000.sat, 0, 0, 5_000.sat, 0.sat)), + paymentTypes = setOf(LiquidityAds.PaymentType.FromChannelBalance, LiquidityAds.PaymentType.FromChannelBalanceForFutureHtlc, LiquidityAds.PaymentType.FromFutureHtlc), ) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - - assertIs(result) - assertEquals(incomingPayment, result.incomingPayment) - val expected = PayToOpenResponseCommand( - PayToOpenResponse( - payToOpenRequest.chainHash, - payToOpenRequest.paymentHash, - PayToOpenResponse.Result.Failure( - OutgoingPaymentPacket.buildHtlcFailure( - paymentHandler.nodeParams.nodePrivateKey, - payToOpenRequest.paymentHash, - payToOpenRequest.finalPacket, - ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(payToOpenRequest.amountMsat, TestConstants.defaultBlockHeight.toLong())) - ).right!! - ) - ) + val inboundLiquidityTarget = 100_000.sat + assertEquals(5_000.sat, fundingRates.fundingRates.first().fees(TestConstants.feeratePerKw, inboundLiquidityTarget, inboundLiquidityTarget, isChannelCreation = false).total) + val defaultPolicy = LiquidityPolicy.Auto(inboundLiquidityTarget, maxAbsoluteFee = 5_000.sat, maxRelativeFeeBasisPoints = 500, skipAbsoluteFeeCheck = false) + val testCases = listOf( + // If payment amount is at least twice the fees, we accept the payment. + Triple(defaultPolicy, 10_000_000.msat, null), + // If payment is too close to the fee, we reject the payment. + Triple(defaultPolicy, 9_999_999.msat, LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow(9_999_999.msat)), + // If our peer doesn't advertise funding rates for the payment amount, we reject the payment. + Triple(defaultPolicy, 200_000_000.msat, LiquidityEvents.Rejected.Reason.NoMatchingFundingRate), + // If fee is above our liquidity policy maximum fee, we reject the payment. + Triple(defaultPolicy.copy(maxAbsoluteFee = 4999.sat), 10_000_000.msat, LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(4999.sat)), + // If fee is above our liquidity policy relative fee, we reject the payment. + Triple(defaultPolicy.copy(maxRelativeFeeBasisPoints = 249), 100_000_000.msat, LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(249)), + // If we disabled automatic liquidity management, we reject the payment. + Triple(LiquidityPolicy.Disable, 10_000_000.msat, LiquidityEvents.Rejected.Reason.PolicySetToDisabled), ) - assertEquals(setOf(expected), result.actions.toSet()) + testCases.forEach { (policy, paymentAmount, failure) -> + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(paymentAmount) + paymentHandler.nodeParams.liquidityPolicy.emit(policy) + paymentHandler.nodeParams._nodeEvents.resetReplayCache() + val add = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(paymentAmount, paymentAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, fundingRates) + when (failure) { + null -> { + assertIs(result) + assertEquals(incomingPayment, result.incomingPayment) + assertTrue(result.actions.filterIsInstance().isNotEmpty()) + } + else -> { + assertIs(result) + val expected = OutgoingPaymentPacket.buildWillAddHtlcFailure(paymentHandler.nodeParams.nodePrivateKey, add, TemporaryNodeFailure) + assertIs(expected) + assertEquals(listOf(SendOnTheFlyFundingMessage(expected)), result.actions) + val event = paymentHandler.nodeParams.nodeEvents.first() + assertIs(event) + assertEquals(event.reason, failure) + } + } + } } @Test - fun `receive multipart payment with multiple HTLCs via same channel`() = runSuspendTest { + fun `receive multipart payment with a mix of HTLC and will_add_htlc`() = runSuspendTest { val channelId = randomBytes32() - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) + val (amount1, amount2) = listOf(50_000_000.msat, 100_000_000.msat) val totalAmount = amount1 + amount2 val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + checkDbPayment(incomingPayment, paymentHandler.db) // Step 1 of 2: - // - Alice sends first multipart htlc to Bob - // - Bob doesn't accept the MPP set yet + // - Alice sends a normal HTLC to Bob first + // - Bob doesn't accept the MPP set yet run { - val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val htlc = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) + val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) - assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) } - // Step 2 of 2: - // - Alice sends second multipart htlc to Bob - // - Bob now accepts the MPP set + // Step 2 of 3: + // - Alice sends will_add_htlc to Bob + // - Bob triggers an open/splice run { - val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() as AddLiquidityForIncomingPayment + assertEquals(incomingPayment.preimage, addLiquidity.preimage) + assertEquals(amount2.truncateToSatoshi(), addLiquidity.requestedAmount) + assertEquals(totalAmount, addLiquidity.paymentAmount) + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } + + // Step 3 of 3: + // - After the splice completes, Alice sends a second HTLC to Bob + // - Bob accepts the MPP set + run { + val htlc = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off - WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0), - WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 1), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, incomingPayment.preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0, fundingFee = null), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, incomingPayment.preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 1, fundingFee = null), // @formatter:on ).unzip() assertEquals(expectedActions.toSet(), result.actions.toSet()) @@ -400,33 +515,60 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } @Test - fun `receive multipart payment with multiple HTLCs via different channels`() = runSuspendTest { - val (channelId1, channelId2) = Pair(randomBytes32(), randomBytes32()) - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) + fun `receive multipart payment with a mix of HTLC and will_add_htlc -- fee too high`() = runSuspendTest { + val channelId = randomBytes32() + val (amount1, amount2) = listOf(50_000_000.msat, 50_000_000.msat) val totalAmount = amount1 + amount2 val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + checkDbPayment(incomingPayment, paymentHandler.db) // Step 1 of 2: - // - Alice sends first multipart htlc to Bob - // - Bob doesn't accept the MPP set yet + // - Alice sends a normal HTLC to Bob first + // - Bob doesn't accept the MPP set yet run { - val add = makeUpdateAddHtlc(7, channelId1, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val htlc = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) + val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) assertTrue(result.actions.isEmpty()) } - // Step 2 of 2: - // - Alice sends second multipart htlc to Bob - // - Bob now accepts the MPP set + // Step 2 of 4: + // - Alice sends will_add_htlc to Bob + // - Bob fails everything because the funding fee is too high run { - val add = makeUpdateAddHtlc(5, channelId2, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(null, 100.sat, 100, skipAbsoluteFeeCheck = false)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + assertEquals(2, result.actions.size) + val willFailHtlc = result.actions.filterIsInstance().firstOrNull()?.message + assertIs(willFailHtlc).also { assertEquals(willAddHtlc.id, it.id) } + val failHtlc = ChannelCommand.Htlc.Settlement.Fail(0, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure), commit = true) + assertTrue(result.actions.contains(WrappedChannelCommand(channelId, failHtlc))) + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } + + // Step 3 of 4: + // - Alice sends the first HTLC to Bob again + // - Bob doesn't accept the MPP set yet + run { + val htlc = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) + val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + assertTrue(result.actions.isEmpty()) + } + + // Step 4 of 4: + // - Alice sends the second HTLC to Bob + // - Bob accepts the MPP payment + run { + val htlc = makeUpdateAddHtlc(2, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off - WrappedChannelCommand(channelId1, ChannelCommand.Htlc.Settlement.Fulfill(7, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId1, 7), - WrappedChannelCommand(channelId2, ChannelCommand.Htlc.Settlement.Fulfill(5, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId2, 5), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, incomingPayment.preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 1, fundingFee = null), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(2, incomingPayment.preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 2, fundingFee = null), // @formatter:on ).unzip() assertEquals(expectedActions.toSet(), result.actions.toSet()) @@ -437,183 +579,240 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } @Test - fun `receive multipart payment after disconnection`() = runSuspendTest { - // Write exactly the scenario that happened in the witnessed issue. - // Modify purgePayToOpenRequests to purge all pending HTLCs *for the given disconnected node* (to support future multi-node) + fun `receive multipart payment with a mix of HTLC and will_add_htlc -- too many parts`() = runSuspendTest { val channelId = randomBytes32() - val (amount1, amount2) = Pair(75_000.msat, 75_000.msat) - val totalAmount = amount1 + amount2 - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams.copy(maxAcceptedHtlcs = 5), InMemoryPaymentsDb()) + paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = null, maxAbsoluteFee = 10_000.sat, maxRelativeFeeBasisPoints = 1000, skipAbsoluteFeeCheck = false)) + val partialAmount = 25_000_000.msat + val totalAmount = partialAmount * 6 + val (incomingPayment, paymentSecret) = makeIncomingPayment(paymentHandler, totalAmount) + + // Alice sends a normal HTLC to Bob first. + val htlc = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(partialAmount, totalAmount, paymentSecret)) + paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates).also { result -> + assertIs(result) + assertTrue(result.actions.isEmpty()) + } - // Step 1: Alice sends first multipart htlc to Bob. - val add1 = run { - val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + // Alice then sends some partial will_add_htlc. + val willAddHtlcs = (0 until 5).map { makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(partialAmount, totalAmount, paymentSecret)) } + willAddHtlcs.take(4).forEach { + val result = paymentHandler.process(it, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) - assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) - add } - // Step 2: Bob disconnects, and cleans up pending HTLCs. - paymentHandler.purgePendingPayments() + // Alice sends the last will_add_htlc: there are too many parts, so Bob rejects the payment. + val result = paymentHandler.process(willAddHtlcs.last(), TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + assertEquals(6, result.actions.size) + val willFailHtlcs = result.actions.filterIsInstance().map { it.message }.filterIsInstance() + assertEquals(5, willFailHtlcs.size) + assertEquals(willAddHtlcs.map { it.id }.toSet(), willFailHtlcs.map { it.id }.toSet()) + val failHtlc = ChannelCommand.Htlc.Settlement.Fail(htlc.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure), commit = true) + assertTrue(result.actions.contains(WrappedChannelCommand(channelId, failHtlc))) + assertNull(paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash)?.received) + } - // Step 3: on reconnection, the HTLC from step 1 is processed again. + @Test + fun `receive multipart payment with funding fee`() = runSuspendTest { + val channelId = randomBytes32() + val (amount1, amount2) = listOf(50_000_000.msat, 100_000_000.msat) + val totalAmount = amount1 + amount2 + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + checkDbPayment(incomingPayment, paymentHandler.db) + + // Step 1 of 2: + // - Alice sends a normal HTLC to Bob first + // - Bob doesn't accept the MPP set yet run { - val result = paymentHandler.process(add1, TestConstants.defaultBlockHeight) + val htlc = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) + val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) - assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) } - // Step 4: Alice sends second multipart htlc to Bob. + // Step 2 of 3: + // - Alice sends will_add_htlc to Bob + // - Bob triggers an open/splice + val purchase = run { + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + assertEquals(1, result.actions.size) + val splice = result.actions.first() as AddLiquidityForIncomingPayment + // The splice transaction is successfully signed and stored in the DB. + val purchase = LiquidityAds.Purchase.Standard( + splice.requestedAmount, + splice.fees(TestConstants.feeratePerKw, isChannelCreation = false), + LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(incomingPayment.paymentHash)), + ) + val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null) + paymentHandler.db.addOutgoingPayment(payment) + payment + } + + // Step 3 of 3: + // - After the splice completes, Alice sends a second HTLC to Bob with the funding fee deduced + // - Bob accepts the MPP set run { - val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val htlc = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret), fundingFee = purchase.fundingFee) + assertTrue(htlc.amountMsat < amount2) + val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off - WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0), - WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 1), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, incomingPayment.preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0, fundingFee = null), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, incomingPayment.preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2 - purchase.fundingFee.amount, channelId, 1, purchase.fundingFee), // @formatter:on ).unzip() assertEquals(expectedActions.toSet(), result.actions.toSet()) - assertEquals(totalAmount, result.received.amount) + assertEquals(totalAmount - purchase.fundingFee.amount, result.received.amount) assertEquals(expectedReceivedWith, result.received.receivedWith) checkDbPayment(result.incomingPayment, paymentHandler.db) } } @Test - fun `receive multipart payment via pay-to-open`() = runSuspendTest { - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) - val totalAmount = amount1 + amount2 - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + fun `receive payment with funding fee -- from channel balance`() = runSuspendTest { + val channelId = randomBytes32() + val amount = 100_000_000.msat + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(amount) + checkDbPayment(incomingPayment, paymentHandler.db) // Step 1 of 2: - // - Alice sends first multipart htlc to Bob - // - Bob doesn't accept the MPP set yet - run { - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) + // - Alice sends will_add_htlc to Bob + // - Bob triggers an open/splice + val purchase = run { + val willAddHtlc = makeWillAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount, amount, paymentSecret)) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) - assertTrue(result.actions.isEmpty()) + assertEquals(1, result.actions.size) + val splice = result.actions.first() as AddLiquidityForIncomingPayment + // The splice transaction is successfully signed and stored in the DB. + val purchase = LiquidityAds.Purchase.Standard( + splice.requestedAmount, + splice.fees(TestConstants.feeratePerKw, isChannelCreation = false), + LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(listOf(incomingPayment.paymentHash)), + ) + val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null) + paymentHandler.db.addOutgoingPayment(payment) + payment } // Step 2 of 2: - // - Alice sends second multipart htlc to Bob - // - Bob now accepts the MPP set + // - After the splice completes, Alice sends a second HTLC to Bob without deducting the funding fee (it was paid from the channel balance) + // - Bob accepts the MPP set run { - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) + val fundingFee = purchase.fundingFee.copy(amount = 0.msat) + val htlc = makeUpdateAddHtlc(7, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount, amount, paymentSecret), fundingFee = fundingFee) + assertEquals(htlc.amountMsat, amount) + val result = paymentHandler.process(htlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) 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) - + val (expectedActions, expectedReceivedWith) = setOf( + // @formatter:off + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(7, incomingPayment.preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount, channelId, 7, fundingFee), + // @formatter:on + ).unzip() + assertEquals(expectedActions.toSet(), result.actions.toSet()) + assertEquals(amount, result.received.amount) + assertEquals(expectedReceivedWith, result.received.receivedWith) checkDbPayment(result.incomingPayment, paymentHandler.db) } } @Test - fun `receive multipart payment with a mix of HTLC and pay-to-open`() = runSuspendTest { + fun `receive payment with funding fee -- unknown transaction`() = runSuspendTest { val channelId = randomBytes32() - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) - val totalAmount = amount1 + amount2 - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) + + val fundingFee = LiquidityAds.FundingFee(3_000_000.msat, TxId(randomBytes32())) + val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret), fundingFee = fundingFee) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) + assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) + } + + @Test + fun `receive payment with funding fee -- fee too high`() = runSuspendTest { + val channelId = randomBytes32() + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) + + // We have a matching transaction in our DB. + val purchase = LiquidityAds.Purchase.Standard( + defaultAmount.truncateToSatoshi(), + LiquidityAds.Fees(2000.sat, 3000.sat), + LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(incomingPayment.paymentHash)), + ) + val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null) + paymentHandler.db.addOutgoingPayment(payment) - // Step 1 of 2: - // - Alice sends first multipart htlc to Bob - // - Bob doesn't accept the MPP set yet run { - val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) - assertIs(result) - assertTrue { result.actions.isEmpty() } + // If the funding fee is higher than what was agreed upon, we reject the payment. + val fundingFeeTooHigh = payment.fundingFee.copy(amount = payment.fundingFee.amount + 1.msat) + val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret), fundingFee = fundingFeeTooHigh) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) + assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) } - - // Step 2 of 2: - // - Alice sends second multipart htlc to Bob - // - Bob now accepts the MPP set run { - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) + // If our peer retries with the right funding fee, we accept it. + val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret), fundingFee = payment.fundingFee) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) assertIs(result) - - assertEquals(2, result.actions.size) - assertContains(result.actions, WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, incomingPayment.preimage, commit = true))) - assertContains(result.actions, PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest.chainHash, payToOpenRequest.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage)))) - - // the pay-to-open part is not yet provided - assertEquals(1, result.received.receivedWith.size) - assertContains(result.received.receivedWith, IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0)) - assertEquals(0.msat, result.received.fees) - + assertEquals(listOf(WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, incomingPayment.preimage, commit = true))), result.actions) + assertEquals(defaultAmount - payment.fundingFee.amount, result.received.amount) + assertEquals(listOf(IncomingPayment.ReceivedWith.LightningPayment(defaultAmount - payment.fundingFee.amount, channelId, 1, payment.fundingFee)), result.received.receivedWith) checkDbPayment(result.incomingPayment, paymentHandler.db) } } @Test - fun `receive multipart payment with a mix of HTLC and pay-to-open -- fee too high`() = runSuspendTest { + fun `receive payment with funding fee -- invalid payment type`() = runSuspendTest { val channelId = randomBytes32() - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) - val totalAmount = amount1 + amount2 - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) - // Step 1 of 2: - // - Alice sends first multipart htlc to Bob - // - Bob doesn't accept the MPP set yet - run { - val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) - assertIs(result) - assertTrue(result.actions.isEmpty()) - } + // We have a matching transaction in our DB, but we paid the fees from our channel balance already. + val purchase = LiquidityAds.Purchase.Standard( + defaultAmount.truncateToSatoshi(), + LiquidityAds.Fees(2000.sat, 3000.sat), + LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(listOf(incomingPayment.paymentHash)), + ) + val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null) + paymentHandler.db.addOutgoingPayment(payment) - // Step 2 of 2: - // - Alice sends second multipart htlc to Bob - // - Bob has received the complete MPP set - run { - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(amount2, totalAmount, paymentSecret)).copy(payToOpenFeeSatoshis = 2_000.sat) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - assertIs(result) - val expected = setOf( - WrappedChannelCommand( - channelId, - ChannelCommand.Htlc.Settlement.Fail(0, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure), commit = true) - ), - PayToOpenResponseCommand( - PayToOpenResponse( - payToOpenRequest.chainHash, - payToOpenRequest.paymentHash, - PayToOpenResponse.Result.Failure( - OutgoingPaymentPacket.buildHtlcFailure( - paymentHandler.nodeParams.nodePrivateKey, - payToOpenRequest.paymentHash, - payToOpenRequest.finalPacket, - ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure) - ).right!! - ) - ) - ) - ) - assertEquals(expected, result.actions.toSet()) - } + val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret), fundingFee = payment.fundingFee) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) + assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) } @Test - fun `receive normal single HTLC with amount-less invoice`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(invoiceAmount = null) - val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + fun `receive payment with funding fee -- invalid payment_hash`() = runSuspendTest { + val channelId = randomBytes32() + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) - assertIs(result) - val expected = WrappedChannelCommand(add.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true)) - assertEquals(setOf(expected), result.actions.toSet()) + // We have a matching transaction in our DB, but the fees must be paid with a different payment_hash. + val purchase = LiquidityAds.Purchase.Standard( + defaultAmount.truncateToSatoshi(), + LiquidityAds.Fees(2000.sat, 3000.sat), + LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(randomBytes32())), + ) + val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null) + paymentHandler.db.addOutgoingPayment(payment) + + val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret), fundingFee = payment.fundingFee) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) + assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) } @Test @@ -628,7 +827,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, remoteFundingRates = null) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -638,7 +837,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, remoteFundingRates = null) assertIs(result) val expected = setOf( WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(7, incomingPayment.preimage, commit = true)), @@ -667,7 +866,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, remoteFundingRates = null) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -676,7 +875,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, remoteFundingRates = null) assertIs(result) val expected = setOf( WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(3, incomingPayment.preimage, commit = true)), @@ -688,21 +887,11 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } @Test - fun `receive normal single HTLC over-payment`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(150_000.msat) - val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeSinglePartPayload(170_000.msat, paymentSecret)).copy(amountMsat = 175_000.msat) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) - assertIs(result) - val expected = WrappedChannelCommand(add.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true)) - assertEquals(setOf(expected), result.actions.toSet()) - } - - @Test - fun `receive normal single HTLC with greater expiry`() = runSuspendTest { + fun `receive multipart payment with greater expiry`() = runSuspendTest { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) - val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeSinglePartPayload(defaultAmount, paymentSecret)) + val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) val addGreaterExpiry = add.copy(cltvExpiry = add.cltvExpiry + CltvExpiryDelta(6)) - val result = paymentHandler.process(addGreaterExpiry, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(addGreaterExpiry, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expected = WrappedChannelCommand(add.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true)) assertEquals(setOf(expected), result.actions.toSet()) @@ -714,18 +903,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, remoteFundingRates = null) assertIs(result1) assertTrue(result1.actions.isEmpty()) // This htlc is reprocessed (e.g. because the wallet restarted). - val result1b = paymentHandler.process(add1, TestConstants.defaultBlockHeight) + val result1b = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result1b) assertTrue(result1b.actions.isEmpty()) // We receive the second multipart htlc. val add2 = makeUpdateAddHtlc(5, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount / 2, defaultAmount, paymentSecret)) - val result2 = paymentHandler.process(add2, TestConstants.defaultBlockHeight) + val result2 = paymentHandler.process(add2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result2) assertEquals(defaultAmount, result2.received.amount) val expected = setOf( @@ -735,7 +924,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, remoteFundingRates = null) assertIs(result2b) assertEquals(defaultAmount, result2b.received.amount) assertEquals(listOf(WrappedChannelCommand(add2.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add2.id, incomingPayment.preimage, commit = true))), result2b.actions) @@ -747,7 +936,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, remoteFundingRates = null) assertIs(result1) assertTrue(result1.actions.isEmpty()) @@ -757,7 +946,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, remoteFundingRates = null) assertIs(result2) assertTrue(result2.actions.isEmpty()) @@ -767,7 +956,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, remoteFundingRates = null) assertIs(result3) val addExpired = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, currentBlockHeight.toLong())), commit = true) assertEquals(listOf(WrappedChannelCommand(add.channelId, addExpired)), result3.actions) @@ -783,7 +972,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, remoteFundingRates = null) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) @@ -794,7 +983,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, remoteFundingRates = null) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) @@ -805,9 +994,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, remoteFundingRates = null) assertIs(result) // The current flow of error checking within the codebase would be: @@ -824,7 +1013,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, remoteFundingRates = null) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) @@ -842,7 +1031,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, remoteFundingRates = null) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(payload.totalAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) @@ -861,7 +1050,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, remoteFundingRates = null) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -873,16 +1062,17 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val payload = makeMppPayload(amount2, totalAmount + MilliSatoshi(1), paymentSecret) val add = makeUpdateAddHtlc(2, channelId, paymentHandler, incomingPayment.paymentHash, payload) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) + val failure = IncorrectOrUnknownPaymentDetails(totalAmount, TestConstants.defaultBlockHeight.toLong()) val expected = setOf( WrappedChannelCommand( channelId, - ChannelCommand.Htlc.Settlement.Fail(1, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(totalAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) + ChannelCommand.Htlc.Settlement.Fail(1, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(failure), commit = true) ), WrappedChannelCommand( channelId, - ChannelCommand.Htlc.Settlement.Fail(2, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(totalAmount + 1.msat, TestConstants.defaultBlockHeight.toLong())), commit = true) + ChannelCommand.Htlc.Settlement.Fail(2, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(failure), commit = true) ), ) assertEquals(expected, result.actions.toSet()) @@ -900,7 +1090,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, remoteFundingRates = null) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -911,7 +1101,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, remoteFundingRates = null) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(totalAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) @@ -929,7 +1119,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, remoteFundingRates = null) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -968,7 +1158,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, remoteFundingRates = null) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -986,7 +1176,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, remoteFundingRates = null) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -996,7 +1186,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, remoteFundingRates = null) assertIs(result) val expected = setOf( WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(3, incomingPayment.preimage, commit = true)), @@ -1020,11 +1210,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, remoteFundingRates = null) assertIs(result1) assertTrue(result1.actions.isEmpty()) - val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight) + val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result2) val expected = setOf( @@ -1037,7 +1227,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, remoteFundingRates = null) assertIs(result) val expected = WrappedChannelCommand(channelId1, ChannelCommand.Htlc.Settlement.Fulfill(htlc1.id, incomingPayment.preimage, commit = true)) assertEquals(setOf(expected), result.actions.toSet()) @@ -1058,11 +1248,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, remoteFundingRates = null) assertIs(result1) assertTrue(result1.actions.isEmpty()) - val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight) + val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result2) val expected = setOf( @@ -1076,7 +1266,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, remoteFundingRates = null) assertIs(result) val expected = WrappedChannelCommand( channelId1, ChannelCommand.Htlc.Settlement.Fail( @@ -1092,7 +1282,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, remoteFundingRates = null) assertIs(result) val expected = WrappedChannelCommand( channelId3, ChannelCommand.Htlc.Settlement.Fail( @@ -1123,7 +1313,17 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { ) paymentHandler.db.receivePayment( paidInvoice.paymentHash, - receivedWith = listOf(IncomingPayment.ReceivedWith.NewChannel(amount = 15_000_000.msat, serviceFee = 1_000_000.msat, miningFee = 0.sat, channelId = randomBytes32(), txId = TxId(randomBytes32()), confirmedAt = null, lockedAt = null)), + receivedWith = listOf( + IncomingPayment.ReceivedWith.NewChannel( + amountReceived = 15_000_000.msat, + serviceFee = 1_000_000.msat, + miningFee = 0.sat, + channelId = randomBytes32(), + txId = TxId(randomBytes32()), + confirmedAt = null, + lockedAt = null + ) + ), receivedAt = 101 // simulate incoming payment being paid before it expired ) @@ -1152,7 +1352,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val cltvExpiry = TestConstants.Bob.nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, defaultAmount, defaultAmount, cltvExpiry, preimage = preimage) val add = makeUpdateAddHtlc(8, randomBytes32(), paymentHandler, paymentHash, finalPayload, route.blindingKey) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fulfill(add.id, preimage, commit = true) @@ -1160,7 +1360,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { assertEquals(result.incomingPayment.received, result.received) assertEquals(defaultAmount, result.received.amount) - assertEquals(listOf(IncomingPayment.ReceivedWith.LightningPayment(amount = defaultAmount, channelId = add.channelId, htlcId = 8)), result.received.receivedWith) + assertEquals(listOf(IncomingPayment.ReceivedWith.LightningPayment(defaultAmount, add.channelId, 8, null)), result.received.receivedWith) checkDbPayment(result.incomingPayment, paymentHandler.db) } @@ -1181,7 +1381,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, amount1, totalAmount, cltvExpiry, preimage = preimage) val add = makeUpdateAddHtlc(0, channelId, paymentHandler, paymentHash, finalPayload, route.blindingKey) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) @@ -1193,12 +1393,12 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, amount2, totalAmount, cltvExpiry, preimage = preimage) val add = makeUpdateAddHtlc(1, channelId, paymentHandler, paymentHash, finalPayload, route.blindingKey) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off - WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0), - WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 1), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0, null), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, preimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 1, null), // @formatter:on ).unzip() assertEquals(expectedActions.toSet(), result.actions.toSet()) @@ -1208,13 +1408,62 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } } + @Test + fun `receive blinded will_add_htlc`() = runSuspendTest { + val (paymentHandler, _, _) = createFixture(defaultAmount) + val preimage = randomBytes32() + val paymentHash = Crypto.sha256(preimage).toByteVector32() + val cltvExpiry = TestConstants.Bob.nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) + val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, defaultAmount, defaultAmount, cltvExpiry, preimage = preimage) + val willAddHtlc = makeWillAddHtlc(paymentHandler, paymentHash, finalPayload, route.blindingKey) + val result = paymentHandler.process(willAddHtlc, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + assertEquals(1, result.actions.size) + val addLiquidity = result.actions.first() + assertIs(addLiquidity) + assertEquals(preimage, addLiquidity.preimage) + assertEquals(defaultAmount, addLiquidity.paymentAmount) + // We don't update the payments DB: we're waiting to receive HTLCs after the open/splice. + assertNull(paymentHandler.db.getIncomingPayment(paymentHash)?.received) + } + + @Test + fun `receive blinded payment with funding fee`() = runSuspendTest { + val (paymentHandler, _, _) = createFixture(defaultAmount) + val channelId = randomBytes32() + val preimage = randomBytes32() + val paymentHash = Crypto.sha256(preimage).toByteVector32() + + // We have a matching transaction in our DB, but the fees must be paid with a different payment_hash. + val purchase = LiquidityAds.Purchase.Standard( + defaultAmount.truncateToSatoshi(), + LiquidityAds.Fees(2000.sat, 3000.sat), + LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(listOf(preimage)), + ) + val payment = InboundLiquidityOutgoingPayment(UUID.randomUUID(), channelId, TxId(randomBytes32()), 500.sat, purchase, 0, null, null) + paymentHandler.db.addOutgoingPayment(payment) + + val cltvExpiry = TestConstants.Bob.nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) + val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, defaultAmount, defaultAmount, cltvExpiry, preimage = preimage) + val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, paymentHash, finalPayload, route.blindingKey, payment.fundingFee) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, TestConstants.fundingRates) + assertIs(result) + val fulfill = ChannelCommand.Htlc.Settlement.Fulfill(add.id, preimage, commit = true) + assertEquals(setOf(WrappedChannelCommand(add.channelId, fulfill)), result.actions.toSet()) + assertEquals(result.incomingPayment.received, result.received) + assertEquals(defaultAmount - payment.fundingFee.amount, result.received.amount) + val receivedWith = IncomingPayment.ReceivedWith.LightningPayment(defaultAmount - payment.fundingFee.amount, add.channelId, 0, payment.fundingFee) + assertEquals(listOf(receivedWith), result.received.receivedWith) + checkDbPayment(result.incomingPayment, paymentHandler.db) + } + @Test fun `reject blinded payment for Bolt11 invoice`() = runSuspendTest { val (paymentHandler, incomingPayment, _) = createFixture(defaultAmount) val cltvExpiry = TestConstants.Bob.nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) val (blindedPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, defaultAmount, defaultAmount, cltvExpiry, preimage = incomingPayment.preimage) val add = makeUpdateAddHtlc(8, randomBytes32(), paymentHandler, incomingPayment.paymentHash, blindedPayload, route.blindingKey) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket)) @@ -1238,7 +1487,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, amount1, totalAmount, cltvExpiry, preimage = preimage) val add = makeUpdateAddHtlc(0, channelId, paymentHandler, paymentHash, finalPayload, route.blindingKey) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) @@ -1249,7 +1498,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob rejects that htlc (the first htlc will be rejected after the MPP timeout) run { val add = makeUpdateAddHtlc(1, channelId, paymentHandler, paymentHash, makeMppPayload(amount2, totalAmount, randomBytes32())) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(totalAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) @@ -1265,7 +1514,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val amountTooLow = metadata.amount - 10_000_000.msat val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, amountTooLow, amountTooLow, cltvExpiry, pathId) val add = makeUpdateAddHtlc(8, randomBytes32(), paymentHandler, metadata.paymentHash, finalPayload, route.blindingKey) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket)) @@ -1281,7 +1530,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val pathId = metadata.toPathId(TestConstants.Bob.nodeParams.nodePrivateKey) val (finalPayload, route) = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, metadata.amount, metadata.amount, cltvExpiry, pathId) val add = makeUpdateAddHtlc(8, randomBytes32(), paymentHandler, metadata.paymentHash.reversed(), finalPayload, route.blindingKey) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertIs(result) val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket)) @@ -1292,7 +1541,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() @@ -1317,23 +1566,31 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { return OutgoingPaymentPacket.buildCommand(UUID.randomUUID(), paymentHash, channelHops(destination), finalPayload).first.copy(commit = true) } - private fun makeUpdateAddHtlc(id: Long, channelId: ByteVector32, destination: IncomingPaymentHandler, paymentHash: ByteVector32, finalPayload: PaymentOnion.FinalPayload, blinding: PublicKey? = null): UpdateAddHtlc { + private fun makeUpdateAddHtlc( + id: Long, + channelId: ByteVector32, + destination: IncomingPaymentHandler, + paymentHash: ByteVector32, + finalPayload: PaymentOnion.FinalPayload, + blinding: PublicKey? = null, + fundingFee: LiquidityAds.FundingFee? = null + ): UpdateAddHtlc { val destinationNodeId = when (blinding) { null -> destination.nodeParams.nodeId else -> RouteBlinding.derivePrivateKey(destination.nodeParams.nodePrivateKey, blinding).publicKey() } val (_, _, packetAndSecrets) = OutgoingPaymentPacket.buildPacket(paymentHash, channelHops(destinationNodeId), finalPayload, OnionRoutingPacket.PaymentPacketLength) - return UpdateAddHtlc(channelId, id, finalPayload.amount, paymentHash, finalPayload.expiry, packetAndSecrets.packet, blinding) + val amount = finalPayload.amount - (fundingFee?.amount ?: 0.msat) + return UpdateAddHtlc(channelId, id, amount, paymentHash, finalPayload.expiry, packetAndSecrets.packet, blinding, fundingFee) } - private fun makeSinglePartPayload( - amount: MilliSatoshi, - paymentSecret: ByteVector32, - cltvExpiryDelta: CltvExpiryDelta = CltvExpiryDelta(144), - currentBlockHeight: Int = TestConstants.defaultBlockHeight - ): PaymentOnion.FinalPayload.Standard { - val expiry = cltvExpiryDelta.toCltvExpiry(currentBlockHeight.toLong()) - return PaymentOnion.FinalPayload.Standard.createSinglePartPayload(amount, expiry, paymentSecret, null) + private fun makeWillAddHtlc(destination: IncomingPaymentHandler, paymentHash: ByteVector32, finalPayload: PaymentOnion.FinalPayload, blinding: PublicKey? = null): WillAddHtlc { + val destinationNodeId = when (blinding) { + null -> destination.nodeParams.nodeId + else -> RouteBlinding.derivePrivateKey(destination.nodeParams.nodePrivateKey, blinding).publicKey() + } + val (_, _, packetAndSecrets) = OutgoingPaymentPacket.buildPacket(paymentHash, channelHops(destinationNodeId), finalPayload, OnionRoutingPacket.PaymentPacketLength) + return WillAddHtlc(destination.nodeParams.chainHash, randomBytes32(), finalPayload.amount, paymentHash, finalPayload.expiry, packetAndSecrets.packet, blinding) } private fun makeMppPayload( @@ -1391,24 +1648,6 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { return Pair(payload, route) } - const val payToOpenFeerate = 0.1 - - private fun makePayToOpenRequest(incomingPayment: IncomingPayment, finalPayload: PaymentOnion.FinalPayload): PayToOpenRequest { - return PayToOpenRequest( - chainHash = Block.RegtestGenesisBlock.hash, - amountMsat = finalPayload.amount, - payToOpenFeeSatoshis = finalPayload.amount.truncateToSatoshi() * payToOpenFeerate, // 10% - paymentHash = incomingPayment.paymentHash, - expireAt = Long.MAX_VALUE, - finalPacket = OutgoingPaymentPacket.buildPacket( - paymentHash = incomingPayment.paymentHash, - hops = channelHops(TestConstants.Bob.nodeParams.nodeId), - finalPayload = finalPayload, - payloadLength = OnionRoutingPacket.PaymentPacketLength - ).third.packet - ) - } - private suspend fun makeIncomingPayment(payee: IncomingPaymentHandler, amount: MilliSatoshi?, expirySeconds: Long? = null, timestamp: Long = currentTimestampSeconds()): Pair { val paymentRequest = payee.createInvoice(defaultPreimage, amount, Either.Left("unit test"), listOf(), expirySeconds, timestamp) assertNotNull(paymentRequest.paymentMetadata) @@ -1426,6 +1665,8 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { private suspend fun createFixture(invoiceAmount: MilliSatoshi?): Triple { val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + // We use a liquidity policy that accepts payment values used by default in this test file. + paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = null, maxAbsoluteFee = 5_000.sat, maxRelativeFeeBasisPoints = 500, skipAbsoluteFeeCheck = false)) val (incomingPayment, paymentSecret) = makeIncomingPayment(paymentHandler, invoiceAmount) return Triple(paymentHandler, incomingPayment, paymentSecret) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt index 718d09770..0a7b14a91 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt @@ -1,7 +1,7 @@ package fr.acinq.lightning.payment import fr.acinq.lightning.LiquidityEvents -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat @@ -15,44 +15,34 @@ class LiquidityPolicyTestsCommon : LightningTestSuite() { @Test fun `policy rejection`() { - - val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false) - + val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false, inboundLiquidityTarget = null) // fee over both absolute and relative assertEquals( expected = LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(policy.maxRelativeFeeBasisPoints), actual = policy.maybeReject(amount = 4_000_000.msat, fee = 3_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)?.reason ) - // fee over absolute assertEquals( expected = LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(policy.maxAbsoluteFee), actual = policy.maybeReject(amount = 15_000_000.msat, fee = 3_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)?.reason ) - // fee over relative assertEquals( expected = LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(policy.maxRelativeFeeBasisPoints), actual = policy.maybeReject(amount = 4_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)?.reason ) - assertNull(policy.maybeReject(amount = 10_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)) - } @Test fun `policy rejection skip absolute check`() { - - val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 1_000.sat, maxRelativeFeeBasisPoints = 5_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = true) - + val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 1_000.sat, maxRelativeFeeBasisPoints = 5_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = true, inboundLiquidityTarget = null) // fee is over absolute, and it's an offchain payment so the check passes assertNull(policy.maybeReject(amount = 4_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)) - // fee is over absolute, but it's an on-chain payment so the check fails assertEquals( expected = LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(policy.maxAbsoluteFee), actual = policy.maybeReject(amount = 4_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OnChainWallet, logger)?.reason ) - } } \ No newline at end of file diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt index f634260f8..0f5b897aa 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt @@ -26,7 +26,12 @@ import kotlin.test.* class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { - private val defaultWalletParams = WalletParams(NodeUri(TestConstants.Bob.nodeParams.nodeId, "bob.com", 9735), TestConstants.trampolineFees, InvoiceDefaultRoutingFees(1_000.msat, 100, CltvExpiryDelta(144)), TestConstants.swapInParams) + private val defaultWalletParams = WalletParams( + NodeUri(TestConstants.Bob.nodeParams.nodeId, "bob.com", 9735), + TestConstants.trampolineFees, + InvoiceDefaultRoutingFees(1_000.msat, 100, CltvExpiryDelta(144)), + TestConstants.swapInParams, + ) @Test fun `invalid payment amount`() = runSuspendTest { @@ -470,9 +475,9 @@ class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { } // Bob receives these 2 HTLCs. - val process1 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[0].first, adds[0].second, 3), TestConstants.defaultBlockHeight) + val process1 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[0].first, adds[0].second, 3), TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertTrue(process1 is IncomingPaymentHandler.ProcessAddResult.Pending) - val process2 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[1].first, adds[1].second, 5), TestConstants.defaultBlockHeight) + val process2 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[1].first, adds[1].second, 5), TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null) assertTrue(process2 is IncomingPaymentHandler.ProcessAddResult.Accepted) val fulfills = process2.actions.filterIsInstance().mapNotNull { it.channelCommand as? ChannelCommand.Htlc.Settlement.Fulfill } assertEquals(2, fulfills.size) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt index c780d31f5..9b699dee3 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt @@ -191,7 +191,7 @@ class PaymentPacketTestsCommon : LightningTestSuite() { ) val blindedHop = ChannelHop(d, blindedRoute.blindedNodeIds.last(), channelUpdateDE) val (amountE, expiryE, onionE) = OutgoingPaymentPacket.buildPacket(paymentMetadata.paymentHash, listOf(blindedHop), finalPayload, OnionRoutingPacket.PaymentPacketLength) - val add = UpdateAddHtlc(randomBytes32(), 2, amountE, paymentMetadata.paymentHash, expiryE, onionE.packet, blindedRoute.blindingKey) + val add = UpdateAddHtlc(randomBytes32(), 2, amountE, paymentMetadata.paymentHash, expiryE, onionE.packet, blindedRoute.blindingKey, null) return Pair(add, finalPayload) } } @@ -453,7 +453,7 @@ class PaymentPacketTestsCommon : LightningTestSuite() { ) val blindedHop = ChannelHop(d, blindedRoute.blindedNodeIds.last(), channelUpdateDE) val (amountE, expiryE, onionE) = OutgoingPaymentPacket.buildPacket(addD.paymentHash, listOf(blindedHop), payloadE, OnionRoutingPacket.PaymentPacketLength) - UpdateAddHtlc(randomBytes32(), 2, amountE, addD.paymentHash, expiryE, onionE.packet, blindingE) + UpdateAddHtlc(randomBytes32(), 2, amountE, addD.paymentHash, expiryE, onionE.packet, blindingE, null) } // E can correctly decrypt the blinded payment. @@ -539,7 +539,7 @@ class PaymentPacketTestsCommon : LightningTestSuite() { ) val blindedHop = ChannelHop(d, blindedRoute.blindedNodeIds.last(), channelUpdateDE) val (amountE, expiryE, onionE) = OutgoingPaymentPacket.buildPacket(paymentMetadata.paymentHash, listOf(blindedHop), payloadE, OnionRoutingPacket.PaymentPacketLength) - val addE = UpdateAddHtlc(randomBytes32(), 2, amountE, paymentMetadata.paymentHash, expiryE, onionE.packet, blindedRoute.blindingKey) + val addE = UpdateAddHtlc(randomBytes32(), 2, amountE, paymentMetadata.paymentHash, expiryE, onionE.packet, blindedRoute.blindingKey, null) val failure = IncomingPaymentPacket.decrypt(addE, privE) assertTrue(failure.isLeft) assertEquals(failure.left, InvalidOnionBlinding(hash(addE.onionRoutingPacket))) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/serialization/StateSerializationTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/serialization/StateSerializationTestsCommon.kt index f15a933fc..5742b35eb 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/serialization/StateSerializationTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/serialization/StateSerializationTestsCommon.kt @@ -1,9 +1,6 @@ package fr.acinq.lightning.serialization -import fr.acinq.bitcoin.byteVector import fr.acinq.lightning.Feature -import fr.acinq.lightning.Lightning.randomBytes -import fr.acinq.lightning.Lightning.randomBytes64 import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.channel.* @@ -18,7 +15,6 @@ import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.value import fr.acinq.lightning.wire.EncryptedChannelData import fr.acinq.lightning.wire.LightningMessage -import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.secp256k1.Hex import kotlin.math.max import kotlin.test.* @@ -127,8 +123,8 @@ class StateSerializationTestsCommon : LightningTestSuite() { @Test fun `liquidity ads lease backwards compatibility`() { - // The serialized data was created with lightning-kmp v1.5.12. run { + // The serialized data was created with lightning-kmp v1.5.12. val bin = Hex.decode( "0402b83e19a809cbef04a58caa852693cf7ddebd0fd926875bd1b195fcf5225a869a01010410101000037108815ff0128f7ed22640485c226d9ad64a9fd6d8b41b6623565aed6b34812c09fe8824646afed5cc44a9fecb08263bfee1c34a83feba92e4e8fe65d93543fecb5ee602fe43ec9100fe80000001fd044cfe59682f000090640116001405e0104aa726e34ff5cd3a6320d05c0862b5b01c13280880000000000000000000001000101a51020362b19a83930389b4468be40308efb3f352b23142ae25e6aba0465a8220f95b06fd03e8fe59682f00fd03e89064026843554d5e604ffd3fcabc56cefe5849abbb7fd395f36bcf3e9550594aace9690236633b1e8f7a54ef367482c31c74162f4fd3e4c7d78694e2c6d769af6e33047202e97df1b0423c20ba41a1955e71cfcb96cec4f636b1d310be78e989f92229edb302b3c6959eefecdee406b9b4df0d76126f2c5038811b27abf44738e6db1be0bdf11408220222000000000000000000001000142a5102000000000000000000000100022b1d92a4eb38c6897dd9f9c6931be595d10037831dc20bdf71b8aa5f0f739a8b02fd024a02000000000102d70488b7709a2ea05d808ec1f46d6ec100f85b3c1f1fe909d3dc6332b1b9153a0000000000fdffffff3bd4776fba4675b6b2e56d4ef0b81159c4319cf9942918fc29798f06b95a84270000000000fdffffff0140420f00000000002200201aaf87557cdb6e31b338fa3e6be95fc7d8ee72b80ac8147fc35ce975d145a67e03473044022012d7967e817c6f369aa4f9a69f78ac1008a7f0ea8f62e3510b8ec2ed3e9e109302202fd1fd54d104f7e2fe0a5404edccb7b3f786cd5f82447bcca2bd23fee34cb596014830450221009c192a826ecf488216eea24279c6699c774e9c930194a8357a61d2e4e91b9c1e02202a085efb5276f6d34ec310b8d2ede7bf65fe63d4027b93b89902d3c4e179e91d014d2103d598883dc23081c5926dab28dbd075d542c4fba544471921dbb96a31d7bc4919ad210256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fdac7364024065b26803483045022100ec3085bef0557c2329c0a9ff1df9374973f42247bab7cdcff10b2fa5fa8b89680220040eeeb7576228bcde8d62f930c368010c8c665467299ea7f3a735140a27ae3301483045022100e628ebd5b4f433c1e4127b7d7fb0f625a6dcb1e4cf8cd62aa4a120312c723138022020f22620ebb280dfc8ad5eb1c1671ce13c4cd9bb7166cd50f3a8577a8b79b167014d21020bc21d9cb5ecc60fc2002195429d55ff4dfd0888e377a1b3226b4dc1ee7cedf3ad2102d8c2f4fe8a017ff3a30eb2a4477f3ebe64ae930f67f907270712a70b18cb8951ac7364024065b268801a0600fd1388cc0047b83e19a809cbef04a58caa852693cf7ddebd0fd926875bd1b195fcf5225a869a1f89a5180b0bee658784378bf354a475d4ae359997e2d93964c892dfacb16f180000fd025b409c192a826ecf488216eea24279c6699c774e9c930194a8357a61d2e4e91b9c1e2a085efb5276f6d34ec310b8d2ede7bf65fe63d4027b93b89902d3c4e179e91dfd025d40ec3085bef0557c2329c0a9ff1df9374973f42247bab7cdcff10b2fa5fa8b8968040eeeb7576228bcde8d62f930c368010c8c665467299ea7f3a735140a27ae33000000fd1388fe2faf0800fe0bebc20000241f89a5180b0bee658784378bf354a475d4ae359997e2d93964c892dfacb16f18000000002b40420f00000000002200201aaf87557cdb6e31b338fa3e6be95fc7d8ee72b80ac8147fc35ce975d145a67e475221022b1d92a4eb38c6897dd9f9c6931be595d10037831dc20bdf71b8aa5f0f739a8b2103cb734f5eac1ce94a8d571b17db09bafe974b5ebae76416667bae888a11fbb9a652aefd01bc020000000001011f89a5180b0bee658784378bf354a475d4ae359997e2d93964c892dfacb16f180000000000bc63fb80044a010000000000002200202a962bfb8410b4d8515002cdca69755a4e7b2f35c1d3c8ca23c8c2eb2c663ea84a0100000000000022002086bc033f5435e003d1be7f8d21ffcba84d5177f72d9cab95ddca49557b0db016400d03000000000022002066b48675ced51cc392a9c208c5b6b37173fcd1c88601188c0a55fa5db4a3df87781c0c0000000000220020d4234a5c556b42832ec8985edc4f377809fa254fc509ee5533de099a91579848040048304502210097e686048e14f2e862970d384734ec72d4799afaf7a67f679e5ef1c685e37279022052f6c4647e44bc9ea197431e47903398fc077314e47a19046f5c6f6b130d9fca0147304402204c85f5c533eaf8bfd8fcdbfca7184522eec4d9f3028226450051a7cedd15d0d802202a5ead9a0284bdec30d52326eb2ddc8d6e00a991f14faf5656a05769eb52ce8401475221022b1d92a4eb38c6897dd9f9c6931be595d10037831dc20bdf71b8aa5f0f739a8b2103cb734f5eac1ce94a8d571b17db09bafe974b5ebae76416667bae888a11fbb9a652ae34d5bc20000000fd1388fe0bebc200fe2faf080002613a5fffafc39766ca252b1470bc96161211c3bf0533aa04fd7cb23d05bf6e02cd117f0145d8c5780d5fd5145251257cfc0898145a99750c181927bbe526f4a600000001035a3feef004f091d822802e715c2d6e9e75020af11be99fd3d4c30e2c6ffa2a480000fd0760040042686e87cb623bed5376be9b0b6314dc871fe35781bbbccf91d12cd07adf711c72e520bb2ff4cf3fa9611868a324e153ebf63586083258028b322fbce9995ddd2593a122a97b082d0f2d58a895c9fa06fd535089a04e05fcdf0e8907492a6c5244541b806bcc49120e464ec1eca87840b41e694725528fe8d7d94f640d958d0b43c17478977617d134a4a1fa85c45f135bd626e70ca862cca3e4861e88771a120bfa6898971b4dd3b022dc920f481cca8102fc101e69ac2d92e18773ef6b262356370514cad85f6531f3ae1d8f404e04172917483227ad9ca8ac29a0b01302ecb67adf54e8289f6dedf3c323f1e52daee77b3bd2524a2959f5dc0dec361212b77c593b67419adeb7aeb75b39b9daca003755b0fd50653724df439d95e6a00bb6afb303ac8a39bc47ebc6a0b906532fd14140e6ca727c4b85ca970da5b249374ec813d1f78ff7171711bfd2a2bc204fbe29834bbf8b9bdf1be88f987315e2d3cb56b50056feee5970c9939af176d829e08106dc4101f5f18a8f04c8067e375505f7bea0a20ccadccf3ece22eccb873efd221877100e08ab9b1c241ef36176dc0ab7b41c17a5bddbf243e22c2dc5f5f9b410a90b6e77e09bc95d7e9e50c5a8afdc462408c453d37571a695dbf37945565b605b0b13c70ce03580d0c4c36f453c7a0a1a7418fdaf057c1c3cbbd9f3fdbf667f3d7342b24c4cc5b7b078891b2fb31d2a2f37f9beab0a503c34df80c39eb19c9194bf4b04c164dade1b176c0cc1690ff64bcfc3f4365d7f7ab7777ee20374c1707a794e32eb7792b20cab4d67cd0d226eb93643d35dd479567a90245e518ce4150709a7d550d3b175ca880393830fe784aeac55811ccf62ce15bac14630263ba1c182827646a4bbd26ddbad3100b23b04afc042cefb6489fe1c77f38826d8a39c9cdc906d73317eaa33cf6ca2ed8756925c8919622ee80a87d66f3eb2f43534c6ecb749b2c473d32c7eaaff659d84bf680c702c1e13adcfadd8e907b886300e07cd431fab9affb451196e3dfd77cfafb8de0e1fe65e66ddb7ba594b7369aa52113c3d752b312fbc51a17d504244933cee42909c60c517a4411f841af48799e719554a07bdd3ffbeb14e694e913514856656e7fcdfaaf84daf8f0b2ef4639c0682524874dd7eb4c16844074ac0d97354a7e643a2e3220bf30855c54461464c0bf82bbabbba7fe407e1f2fa394f8e3822c507e2d705e32e13f2a50a5f2c8b3d73b63847cf985f06e25de5629e8a570092a92996c655f5ef3871d2a3a4b556c9b52d40b828475c35262c6f9f5bbfbd3e6ebf09864bfb3d3dcf4f78961d4fc85fd9b9c924ce6ba8c6df4c8525ee4c3f67f97e361566b31a9df0c4bc6da36e9e0e47f0b91a67f489fba2d0eddee58bac5ddc4cfde2c74947a27b49e89fa838bbfaeae6605a7e2dad611252a5d30a5c99592de44aad8fb4253880ce16f60c3231f9824898751e99eb4d554bea9042843a56d5239f8d3aae93696583970822429beef912dcd7129693e11ad39ec0191ee5fc06b58544fbed9c6c12ad73690bf64bb78fb16902e97bc8f8fcbdba321ce0241141542cca9235489459b1b50d44d76bb36492241dfa43f5252331556a9c618f14f89f9b7dc9944498a73ce242a0ec0b2953b25cc5b11c25dbf336a6319f479e561c2c4f6f196a43f93ddb22da68bfe3909c3cb21503a554b895ef4dbd0033684b16b974042386eddef9faf63389d6d07bafdc934884589333da2fd0a6e1e15bdcab663c562e00e887c1b9b5296b8bee678a21d11c45005729bb0e6eb225cb9a480673483634ad21ceb0bec52ec78b13058847e750412ab67e3631187c289aeba97371926027b348bc932b600ece0fa5a8fa69a18d44e51eb7857011e72484e1e8393d94382ddd8e012b676dde44da75eda81aa0ba5ed8e474b7465c5af2b1a1de7aa870fdd191de0caf78875880ab6d5d3fcef3057002e17a07f9e870ae13634cef3bf8a60b41104c39145a1b6dd44f37c3b7d3c78f2f6f1fe83d38c2a54c1597270fed60b157fbdf431d51e98899f6894ad41c4142e271af7d5557faaacdc337caa9f3ecae7a0dbfdf27057c437556c9c9442fdca9e9a07e61741ee56a3db89e29d3d4ab7fc3feda7d737d5ceb3787d103efbd72772a1ebf65541d6d7cdb5ac82e834060d1b9f58be80db537f9ca696a57f21d74fdee7947ff90cb238c3f6f7e084012a1c1466c230d841e7b3cc0b670696e7f3b6186770b2e61c3bae625da4232831058ad73c87744be94f301ce839d6fd46d62fec3edd60ea7cf92fd119b98232cd9621f5e5d37bde331e2db7d4742531e93531676150bbcf8dd28e7acd3181128ebfc36c49aa7ced8fb5af96833769deb6d46f49010ce92ba80e5f7e841360ae01f86a39e24383cab02d31af0745f70b752ebf6e149e38c1c4bc7a6f39124555b449e4887fca29bdc51efd56c0f682458a41a5cf28697c3f79c980df742e80aeae1dcc91309389885c3f2386f4edd14956bf562884f5983bb906fea2bc394efbb67de76720209ae47b3a6ed5e00e82287f3586113de2476d514dc58086e03890ce3247a0d2969a32c995ec7b306c8c6b6737310f8d2bf2499d1659b523e0ceb00eba41af9bb32a81fa230a560a866606481a5086da9ea8b61d0d4dd8b0cff00002a00000000008a01023a973e5a95ba9356ebb5d884eda57169e214d46afbe7e0ede00f4bf4a3acc0336825ba58984754922a4f3a28cbcb5fa52a9b983210bb992eef6e2dfe391a806d06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f00002a00000000006572fa6b0101009000000000000003e8000000640000000a000000003b9aca000000000001b83e19a809cbef04a58caa852693cf7ddebd0fd926875bd1b195fcf5225a869a01fdc256000101241f89a5180b0bee658784378bf354a475d4ae359997e2d93964c892dfacb16f18000000002b40420f00000000002200201aaf87557cdb6e31b338fa3e6be95fc7d8ee72b80ac8147fc35ce975d145a67e475221022b1d92a4eb38c6897dd9f9c6931be595d10037831dc20bdf71b8aa5f0f739a8b2103cb734f5eac1ce94a8d571b17db09bafe974b5ebae76416667bae888a11fbb9a652ae00022b1d92a4eb38c6897dd9f9c6931be595d10037831dc20bdf71b8aa5f0f739a8b0369b5f974eb92be821826fe7d0969744283c917f3411655f190b56fc5930c3cfa00fe00061a80fd044cfd00fd0101010102241f89a5180b0bee658784378bf354a475d4ae359997e2d93964c892dfacb16f1800000000fefffffffdfe2faf0800fe0bebc2000104220020431b8ae82b805d47da74e00ecde92e930c666124d5930fae2958ba309885380ffe32a627f0fe0bebc2000102007d02000000011134cd9d56bee35f5db7b8a8e17ae69eabc8738653da42247fad8996e7419b7d0200000000000000000250c3000000000000220020778f86e213a518e8336ec940874ccf4b92910332f5b3c0265374a86dbd34e6a596000000000000001600141240d4b7fcfbfbd7234cf2dedf071673a0c1e5590000000000fefffffffd03d598883dc23081c5926dab28dbd075d542c4fba544471921dbb96a31d7bc49190256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fdfd6540000000fe00061a80cc0047b83e19a809cbef04a58caa852693cf7ddebd0fd926875bd1b195fcf5225a869a3448c2e0ddb06dce57bc06c2a3d447d958958b4cef6499a4d0bb1eb36d28e2c60000fd0259406e731b1649d06176d0ecf590b385b0123f685cb93ef518124d6b9cbd7062c4265af87d8986ac6fd525d0e738dff61e00d18ce04fe9cee80a99744a65fcd4fb04fd025b4045e91597bd2826f18f58321051c3e0a6728ebbb0d633eba9428139335460c9da322b01137b5a3b5041fd526d5043ffd586d8ef44b7ccb27c9605ab6bb2943d27000000fd1388fe32a627f0fe0bebc20000243448c2e0ddb06dce57bc06c2a3d447d958958b4cef6499a4d0bb1eb36d28e2c6000000002b9604100000000000220020431b8ae82b805d47da74e00ecde92e930c666124d5930fae2958ba309885380f47522102c3cdf2cd990536f7ac520b3a2f66c0a6e302c2fe15a8c3baee24eba1cb9a8b02210369b5f974eb92be821826fe7d0969744283c917f3411655f190b56fc5930c3cfa52aedf02000000013448c2e0ddb06dce57bc06c2a3d447d958958b4cef6499a4d0bb1eb36d28e2c60000000000bc63fb80044a010000000000002200204725be4ed490e91c4ad5824fcc202c53787b147d4ad28a30aafeff90400d17634a010000000000002200204eea61d0b3215da0c03b103e98d24aa4466e0fb1ba80f8d8d85d33ed0a969da3400d03000000000022002066b48675ced51cc392a9c208c5b6b37173fcd1c88601188c0a55fa5db4a3df87cede0c0000000000220020d4234a5c556b42832ec8985edc4f377809fa254fc509ee5533de099a9157984834d5bc20000000fd1388fe0bebc200fe32a627f05f4cb3d37e1f420f5f39b563929d1a82a8e93ee4d864eaca609508b3ad2b6a5702cd117f0145d8c5780d5fd5145251257cfc0898145a99750c181927bbe526f4a600" ) @@ -136,17 +132,11 @@ class StateSerializationTestsCommon : LightningTestSuite() { assertIs(state) val splice = state.spliceStatus assertIs(splice) - assertNull(splice.session.liquidityLease) + assertNull(splice.liquidityPurchase) assertTrue(splice.session.localCommit.isLeft) - assertTrue(state.liquidityLeases.isEmpty()) - val state1 = state.copy( - liquidityLeases = listOf( - LiquidityAds.Lease(50_000.sat, LiquidityAds.LeaseFees(1337.sat, 1329.sat), randomBytes64(), LiquidityAds.LeaseWitness(randomBytes(23).byteVector(), 0, 850_000, 100, 1_000.msat)) - ) - ) - assertEquals(state1, Serialization.deserialize(Serialization.serialize(state1)).value) } run { + // The serialized data was created with lightning-kmp v1.5.12. val bin = Hex.decode( "040238ca11203794f6ef69fc7df13b91e7848c837632bd1407bec63d9ade0330a3f701010410101000037108815ff0128f7ed22640485c226d9ad64a9fd6d8b41b6623565aed6b34812c09fe5b62e3a5fecf5fd832fec7e172c8fe15fff232fe17015da3fe71c99b0afea90a802bfe3993f9cefe80000001fd044cfe59682f000090640116001405e0104aa726e34ff5cd3a6320d05c0862b5b01c13280880000000000000000000001000101a51020362b19a83930389b4468be40308efb3f352b23142ae25e6aba0465a8220f95b06fd03e8fe59682f00fd03e890640349b56ccb150862271cdc1b280d484db844d48ee85f07515cc6e847d1d32a147a02d78bdcc7f2160d5ccdcfbc0ba3dcc4b547d06f38cb65ffac7589ae5ad529d08a03e62f14d41cdf68d7ac982dc03e6492d093d7aec4ef7d1765d5a5bcc995e204b602e95f9d9281919ff9cae84e7dc3b5b1ef1161a6c503617e4f3207e05d722c15a71408220222000000000000000000001000142a510200000000000000000000010002e5b062a9484e793eaf759f5ab4879577e5d1af684866decf0603f1c56a18de0f02fd02490200000000010256724a067da52a008fa768ad15f2a003054882bf0c09693a2c0f386eb5d8c4340000000000fdffffff3be96364f874547c41cf86f1f57c35029a6e082700bcd25f5b3cbd742417ced80000000000fdffffff0140420f000000000022002026fa4316fb307d348467c0edd3b8bb1eff9ca3ec04367ce5788885712ec400b003483045022100e66fec8848962770b61c9835b4e09954dd6dec98c2cd621a8592defe58796ef4022067aa7588b08614346f544322577b7797116c727f452b06cad40c2f9b2af8774601473044022064df9cd5e6e68bad426f4ffe06ccee9eeb5769f4fe65931bc54c0df618accbf1022018488e51b6c6969771de015e9ac41f9426701c5c961df270675cb1476920de98014d2103d598883dc23081c5926dab28dbd075d542c4fba544471921dbb96a31d7bc4919ad210256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fdac7364024065b26803483045022100e9882e40951f43da434e1d86cf9830f5499f8dbb846f5131becfa0fea181ef4602206146db93b26271798d40f453d5255557191d0f45f0b5fb0a0d78ce261afcd386014730440220379f14da69fa108168d32351e5c9127479a8e1858f89cdf1a70c66010348a6c1022039bc27eabe21078071c35c35adc1c11ab8e3f70fc9d4e649f6527618e70ba647014d21020bc21d9cb5ecc60fc2002195429d55ff4dfd0888e377a1b3226b4dc1ee7cedf3ad2102d8c2f4fe8a017ff3a30eb2a4477f3ebe64ae930f67f907270712a70b18cb8951ac7364024065b268801a0600fd1388cc004738ca11203794f6ef69fc7df13b91e7848c837632bd1407bec63d9ade0330a3f7f62dbfbfc3ba1da02c846abbf48de14377d8a73d8b69b9e7145c231e2b060d240000fd025b4064df9cd5e6e68bad426f4ffe06ccee9eeb5769f4fe65931bc54c0df618accbf118488e51b6c6969771de015e9ac41f9426701c5c961df270675cb1476920de98fd025d40e9882e40951f43da434e1d86cf9830f5499f8dbb846f5131becfa0fea181ef466146db93b26271798d40f453d5255557191d0f45f0b5fb0a0d78ce261afcd386000000fd1388fe2faf0800fe0bebc2000024f62dbfbfc3ba1da02c846abbf48de14377d8a73d8b69b9e7145c231e2b060d24000000002b40420f000000000022002026fa4316fb307d348467c0edd3b8bb1eff9ca3ec04367ce5788885712ec400b04752210243fc4580744090c5009be7bb3b85b91c6dfdfeffc67e37a808a89431b24461bc2102e5b062a9484e793eaf759f5ab4879577e5d1af684866decf0603f1c56a18de0f52aefd01bb02000000000101f62dbfbfc3ba1da02c846abbf48de14377d8a73d8b69b9e7145c231e2b060d240000000000620f4680044a01000000000000220020ad6e712f2f3ff4a279f7c1cc4bb31d88c98ad807537616a4f53beed64cb5091d4a01000000000000220020c79d8484429b469c3230783f14fd3228d9b6da520dac471f3b3d826c59ad0b52400d0300000000002200202a563e407578aae18800c2388759b5bad0ceef5a3759de39d7acedffd868d30d781c0c0000000000220020bf57f237188dde15a4a0947bf64cd985f95163d77fee3d8372cdce323de1efdf04004730440220178102bdc1fce536c08c0660749208ff2d1e0aa9bb5ad1b98b120e9e5e263324022057c7033283b0f397f98378d0b2666879ed5da822445ed43dbb26563644d397370147304402206dd5be99fe9473e0221aaf2e37a72fbe38f666c4129c0c164cf9bd2eb7d93fe802204ec11e93732a56f4e759c4ea8359cfda3e1db4cef0cf91f79b1d9906e60eb2b3014752210243fc4580744090c5009be7bb3b85b91c6dfdfeffc67e37a808a89431b24461bc2102e5b062a9484e793eaf759f5ab4879577e5d1af684866decf0603f1c56a18de0f52ae65031b20000000fd1388fe0bebc200fe2faf0800b3822442b3a5d53e6410fe106e9ec9408a9bb0b6b6f34c0ad39d0811b466f86a03cb5d3b7310e6532d403dc7ad5857869e6ace3a2a1a7cf710c8a9b307a5ebb02c0000000103b6668d222ea88836cd25b40784f759c9cc0ff9ac03ef0408a08543ae185405060000fd076004003749c4912f86c5594f2e9775e78a8292f386ca75711bbb7f89c841e99158d24e1aea0c5bf2f810cedf6ca3842aba47df127ad165b13052c8fbc30aa23feb59d01960f2226127e20affb1637bb17394140be970a45c79fa4dd3ac0ba32c6bba5095be8a5ad1c0d6747788fcf128a8f71378d8921d2b7d2c9e999e2898fcb7ae7a5048900b111c973622ebcbdc5e3232efb330464f4d76b1a0fb2d70ddb3882ae9a45a7f3115ad94acc926d1ed33f940cd7bcd8a296983bb3ff4592009ce498b9d4552e6e019d453210545ac5c2f48a1fe75b5dd93cff4f124c363f22578cd7d3b5a5244a871c37244e79eaa1ffc3966f716520b8cbf38ba1c33ec68939fce45a2519eda4f1029d2e5fa3069e5fed848d9e078ed29af5a10541933db39ed353895f2b269437f2a04ba09528b0ba92bf725ea300752226b888f4cf3c3fa973e4b2017b74c86cdbe81829513bd62f2055076e0463b39c2155635772d80b2f6945319bede15535a3becbd9374122f0f974ef2c9ec990369f2a90dfb7f1355e5e183489880c4a9e63740967dec2a77dbfa003361bfee2f3e4f1e4cc02afe0d82a14a47ba9fac237ed616fc892c1d93387b9a9682a78994cd62074b295afc542b190ef2391e8352e8ada52147b448ee2e2cc8cd5170af58cbf211f7b0d49a6b6b6ec628b0dfb4e4636df58dc5c55b3634457d7f949a1f26abc64db158fa51343a5990d707218b01dabf223361cc4f6ce3cfc6b62c5306ad1bfbabf5c51003551a07bb053e5a419d5d8c8c200feb87ab9dd0802d418068285bfa3f0c0ae717d4671cb9d4b2cb0c12d44985961c259f4433fe732da40458c3903d6191f7a6167132a9db3476dfbfc37f3c5d37b49e3027ab9a981ed788e124ed88abe2f3a10f52fd5ba278e6555acc89d916b30c2dcf3bbcc6cfc1985e66a169a6eb1f251cef9ece3487e88d1f81676d97955ef374465e16ad36abaabd3888236dc0eb27050b9050a396a6d8a2cb451b8d75e480d8afa13ddefcda4c28a8483a441edbc034023fe5332c52e86dde7f71dd1865d471deb7ea04a09f38a9307206e2fc53e205362d95247adc5dc5cb5cb064609f2cd11ecbf005612d12165725799044eb45673a1e9c1a1275dc70ff5992500754c6efd851666b6a5d02d438d01e881b430876245d4bc4b888988471fdad5104e5bc5a518a83a9be98f9a1ea11473b8eb32150714ffa1bdddaf35fb7e0b50cb075a8a38437638cd4803e3e8baea0630420947dfb274a4980fd6c8d4a1a79d033406b7dae6cf68f83cfabe9e5bc9e4ef51c49362017fa835497b909bf0599ec764709a527a8bbf59007602bbeb676a60a3c990bb1630c18f4ba3b3ce71d73ebdff879af9347c7fd9d0789cf7d15fbe3196a4cbaecc3fbd2a5ed2f1d995cc03c6e5bfb48395b317ca4b3ff626e291f6cf877186eddd707b8d5a66de90ced49f276b417032d264d992d6dcf26267bdfbeb37a7e65438ae136bf65ad0da4998a3a331e7593786157562ac0eb4d37e68d41181d79677265b27099d770b4443cbf9d08859e4ac79f9adcbc41000ce203fedb40ceeb5050fa56a5bc9f038d4f13cc860e3e68a5df055ae2df2c09a392435e5770790835e2db2081dd21d28f2bc76eba810d5cdba41c97a8a64512af71eb9bbfc8b7ea17f41710cc034d33e92ca73c02a6e7501e33efe57efb54ecdf36e1e18207994779fe8a8e299ec5ddf186b6c859e5884994ac780d6f800d7e65ab1746e56b9dca3f08a0fd7a86680a53ffc70bb1b3138844a3ae4ee7267c2cdbba2cd8da1af7522fb6eaeb6b737637df1e69c0356ba02ca06a064d80add016c1a5fe804be21250c93dc859313ff0c41a68c351a702b2f24279d197cd1201080edb1006ae100ffa7d660a5439a79bcbda24e2fdf445f010bc49514e5030f10b4760101d07cec44773136f884264a3c0dc465fb950bbc2c11cebfd9a7de7b0f18e77e03e2a2e5199308f21fde4d9092328651d13d9b86cfbadde55d4eb3bd815d3c4349ca4e3944bfad27ef31b6034b3c934f8eeed228845091fbd030858ffbb6448dfb3454a5049bc86e3894814de855627b4cbb9a90515360f9087c7f99b894b7839c6b3beb0a6dcfe102d549cf571e287720b02fac463bddbaf1fd3d4865c9444f36d763d977d6e4741bdd983133112bd2567af10bbeba5944c39f3cd3ea9d5249cfcaa56f762224c1fe4ed7a847303859cb36d642c6bf903012327fa4af7ee0d901e09d4b2443f4036e3c7cf46971b90750fdf2c63f3f10b18ad46da18b62f7320d0d05366aecef0d0281ab8ec80888c332761300eff916a846fa3887e58f76fcbd5122861324e748dc0544b5886aab637188d40d4232587d5f11d9ddb9f7c8a1fd9b084032a6724e786d8ea632dfb85a0e650f8120758c1a58ebe077658f28e1181802ea5a90e1fce99a1e0246e60837048a75e47e6deba2959cec9f63029671dba511ff1a3ebee4a0b5868621c56afa369f17cff651d03cc620984dd1d985184ae7b3d2c9d0e838d9843b6a893e22643ce7057d33075dc937146e7d0194a2bbbf87a427d5235fa361f1eae8f35090b83f4a589205b683f773a2588018ae306383fdff65a857fc13be804b20a13f8982a3652eea9006ad3e4767f520abe82f199dbe74870cec8e466cb98ff00002a00000000008a0102a9c1d59ef327d76207a8a26373c89b9d0d0eb25d6ca7551a3f291c8c6cd3f0b45a36669e8960989d59bb5412a3aea7a6c0d994375d43bf812f38c96eedaf712506226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f00002a0000000000657313c00101009000000000000003e8000000640000000a000000003b9aca00000000000138ca11203794f6ef69fc7df13b91e7848c837632bd1407bec63d9ade0330a3f701fdc25600010124f62dbfbfc3ba1da02c846abbf48de14377d8a73d8b69b9e7145c231e2b060d24000000002b40420f000000000022002026fa4316fb307d348467c0edd3b8bb1eff9ca3ec04367ce5788885712ec400b04752210243fc4580744090c5009be7bb3b85b91c6dfdfeffc67e37a808a89431b24461bc2102e5b062a9484e793eaf759f5ab4879577e5d1af684866decf0603f1c56a18de0f52ae0002e5b062a9484e793eaf759f5ab4879577e5d1af684866decf0603f1c56a18de0f029bd74977c75b38711165bb9506c4b2725fbb9a031364d299681e80ec2952285500fe00061a80fd044cfd00fd010101010024f62dbfbfc3ba1da02c846abbf48de14377d8a73d8b69b9e7145c231e2b060d2400000000fefffffffdfe2faf0800fe0bebc2000104220020e4187f51de13d85db945c888357da388e2877ec578316fc25ecac3e716f4866bfe32a627f0fe0bebc2000102027d02000000018c19fefe4b851e9da17f94d44b549d87124aec35a4a85c40c566564c51ada7220200000000000000000250c3000000000000220020778f86e213a518e8336ec940874ccf4b92910332f5b3c0265374a86dbd34e6a5960000000000000016001425deb8d8a6cb84452c47904350e79c523dbfefdb0000000000fefffffffd03d598883dc23081c5926dab28dbd075d542c4fba544471921dbb96a31d7bc49190256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fdfd6540000000fe00061a80cc004738ca11203794f6ef69fc7df13b91e7848c837632bd1407bec63d9ade0330a3f79ad1a0a72421f2f13a86e069498ee9c1a5687c1b06968776f9699940e624d5300000fd02594066195c52f770b513f87862384c79a4ad543fff3a81fa6e3d45e76ebea9bd319a38706e847ae4e53fb9d97111b69f0cbfc86f20c92d35f155855b3de9de804548fd025b40849a87ab6815902abbc4f3d9773517c18eeddc5d10dd949aeccdad068be0e5d8260b54dff0c783ae0c330157954e371fadd82bb084d2fd9de28867a87ca60b70010000fd1388fe32a627f0fe0bebc20000249ad1a0a72421f2f13a86e069498ee9c1a5687c1b06968776f9699940e624d530000000002b9604100000000000220020e4187f51de13d85db945c888357da388e2877ec578316fc25ecac3e716f4866b475221029bd74977c75b38711165bb9506c4b2725fbb9a031364d299681e80ec2952285521029e2ff8460c1ea74f8892509f4c60340d19a1981c4339e2a728ddef8df08eea4b52aefd01bc020000000001019ad1a0a72421f2f13a86e069498ee9c1a5687c1b06968776f9699940e624d5300000000000620f4680044a0100000000000022002063d3fcf7f93eb1b30ff3d7f185c2889a5fa8c4a584cedd4e0ce3a16e10ea2c994a01000000000000220020a483a289ea09e761fae87c628675e3d52f23fcc6355ff15c3bde6ce2dc86d802400d0300000000002200202a563e407578aae18800c2388759b5bad0ceef5a3759de39d7acedffd868d30dcede0c0000000000220020bf57f237188dde15a4a0947bf64cd985f95163d77fee3d8372cdce323de1efdf04004730440220772d80d88ac5156fb8096ba19129492e66bdf8bea76e750847534f7aafb9621d022075f0480e72f576632ad9b80fbc5e7630951753c8425892adac6eb0352823de12014830450221009ea0e484d4d4c43c46960cb2f182e0460ebe560c036691076e2ddc03e2d87933022042074738e8a9c263694060f5fbcdd42a308d56abca4e5466f8d958d018fc5d0501475221029bd74977c75b38711165bb9506c4b2725fbb9a031364d299681e80ec2952285521029e2ff8460c1ea74f8892509f4c60340d19a1981c4339e2a728ddef8df08eea4b52ae65031b20000000fd1388fe0bebc200fe32a627f0b8b2f694372a292f7822c17f2400184c2f70195afc7523987a14f65e7601042d03cb5d3b7310e6532d403dc7ad5857869e6ace3a2a1a7cf710c8a9b307a5ebb02c00" ) @@ -154,16 +144,21 @@ class StateSerializationTestsCommon : LightningTestSuite() { assertIs(state) val splice = state.spliceStatus assertIs(splice) - assertNull(splice.session.liquidityLease) + assertNull(splice.liquidityPurchase) assertTrue(splice.session.localCommit.isRight) - assertTrue(state.liquidityLeases.isEmpty()) - val state1 = state.copy( - liquidityLeases = listOf( - LiquidityAds.Lease(50_000.sat, LiquidityAds.LeaseFees(1337.sat, 1329.sat), randomBytes64(), LiquidityAds.LeaseWitness(randomBytes(23).byteVector(), 0, 850_000, 100, 1_000.msat)), - LiquidityAds.Lease(37_000.sat, LiquidityAds.LeaseFees(2500.sat, 4001.sat), randomBytes64(), LiquidityAds.LeaseWitness(randomBytes(23).byteVector(), 0, 900_000, 100, 1_000.msat)) - ) + } + run { + // The serialized data was created with lightning-kmp v1.6.1 and contained legacy liquidity leases. + val bin = Hex.decode( + "040213081f6d86356f02bbc63032527acc16f247306626923ac644d268c6d388acc201010410101000037108815ff0128f7ed22640485c226d9ad64a9fd6d8b41b6623565aed6b34812c09fe21f607fafe59f292aafea0974737fe11ba0ae3fe832735b0fe3f644273fedfd83bd0fe0efc0570fe80000001fd044cfe59682f000090640116001405e0104aa726e34ff5cd3a6320d05c0862b5b01c13280880000000000000000000001000121a51020362b19a83930389b4468be40308efb3f352b23142ae25e6aba0465a8220f95b06fd03e8fe59682f00fd03e890640260f378afd291d92f6fe86c27178aef4caf84fb545c1e63f9c9a1898298becaf9029f1c8b58528763c71c3a2e1526ec903c5e75a61c870050011fd7ccdd0a803089030c2c6185254249b67c1426f79652be21a26e093bc2ffcbac93e73ee7cf1e7a4b03e22dcab5315ab54d7ccd6cc496273abffcda59bf7e835c0c4fe7fa3884c330bf1408220222000000000000000000001004162a510200000000000000000000010003d0dd6515d07459f110be9c3165b950916df51e1dd342d673278784f4964e135302fd010d020000000001024bd7d5d15958ededa6e71e049765ffa30471938e615ae06af6e9ec88ddd1dcbc0000000000fdffffffb6a76d06353bcfd53969c1c444befc11d460d2a696f1f64104fe6c2dc0a9b6790000000000fdffffff0140420f0000000000220020cfde05ca43faf792db30fbd8511b29068f3f0bd610d850f5ba6d55018511d69a01405925f4175684907db9feb2f110fb67be493d0336043a7950d7cbc282825cb06e71fe2479ae803379113affc37a993b3efd65551e5bfd31c2e57059c2c4cf26680140e30f414054e2b6b8e875ba78f42471be44467d11382e247b8ea6b24163f43628e2ec0979a7a446c4dc6651314ab80e4300bb60497f70ea7914b4eda86428d9f9801a0600fd1388fd0194004713081f6d86356f02bbc63032527acc16f247306626923ac644d268c6d388acc2555a8f256dd2cd0f98bd8f64df22bc7470116f7edad3e51d2ebc61b6e69b67bb0000fd025fa4628dd41f5d8a4efdd98d316d9d857786000804515b25107a8d27a8e74d7b6bda031bbec27357117fe7efb2a70dfcf6cca04f4b26fe94049993334a5412ba2f209303ddb49212eae6b925dbc941924e994a3b81e000342e6f45966f8435eabaaa0f2c03744f6eb266cf5e21110cb0adcebf8eb1daa936b48a21f3cad3628514b991e66002afc74c8668b444b03d503955befd0406dcd191ccd7b7c5a243d2f6893ba27a3bfd0261a4f48ed47669759026cd1f7a08d68f6837741807bb3ea1d5a6a058ec14dca1e1540274440233607f320c1b193d1c1c3d3f5de40df28cb4dba3c187d1139ba1f1ed0d02b50805c457b12fe9c7e2863dba2c723523bc5de831f447d12fff7e34d16b78df03cb03a6802d32f58d7961fbfe1329f5c1a108514993733dedc4e63ea01064a9540384f3da08590794bebf4f07581f94f746e2cf45f865b9634c6d3b9a9d0dfac9ff000000fd1388fe2faf0800fe0bebc2000024555a8f256dd2cd0f98bd8f64df22bc7470116f7edad3e51d2ebc61b6e69b67bb000000002b40420f0000000000220020cfde05ca43faf792db30fbd8511b29068f3f0bd610d850f5ba6d55018511d69a4752210218bb3f1452c4036155e8f06bbb714e62b1dd4185a83c5cd069665782a04b85852103d0dd6515d07459f110be9c3165b950916df51e1dd342d673278784f4964e135352aefd01bd02000000000101555a8f256dd2cd0f98bd8f64df22bc7470116f7edad3e51d2ebc61b6e69b67bb0000000000e3033a80044a01000000000000220020a3aeecbd2707fd89c1339ca772a327ddac438b64c8a8cc6dbe5180456671abb54a01000000000000220020e5258298238c1e7605fa3f2b97ec1dd1a30c01b2e10930a39a2a11cc2ac01ad2400d03000000000022002021d519f1ce1e3ae039dacb110411dfe477ccf4b6c2168a141919ca482b6ae317781c0c00000000002200203cf8e1d032035e81667c393f5f6638143284a4add6efd656b9641c04eb29ff6c0400483045022100825aeb88370fc80925c23230f99ba96949b6a2548d63036d630319c47bb53a8e022035f6fc69b40936e90102a82147a1e4a6b1fcc7b6ebae83c7a83baf4f153dc00e01483045022100e5e07dd084cc3dbc2e6885088769388111978943bbd42975176871baae70634302206c3deb31f4f02bdf208be8d0396908a320d7e259aa02b310dd40845f8478079f014752210218bb3f1452c4036155e8f06bbb714e62b1dd4185a83c5cd069665782a04b85852103d0dd6515d07459f110be9c3165b950916df51e1dd342d673278784f4964e135352aefe3fa020000000fd1388fe0bebc200fe2faf08006639a3d97853ba424ca83d402055fc00c90ee2671ac43c20675f2d206f48826303b93622e8424a4d6fb07b934730afe5d61c98e888d51f9fcffa2cd0aebc6f68890000000103ccfff8d148c8b109d9bccb1fdc9846c4de2605c81faab2d580db7102657bb16d0000fd086d04000e4f35a4e36333583c23eee2d42bbab50a6987a60b082d35fa9c48847b8b0efe13b59f2dc3e00bbb34ef9f499814525189580f167b5acbea567efe287d2e5c11d426eb3d2c47c30feacd3d99520df9f359114e57470bd382937dd0f9ba6953ac9401f543a0fbb56022fbad18bd931c511f70d67293ec864d7943875fc2c8498553cbb023c380e41ad9394d858be52a89d11f85c2e221fa0662f1dd8b9a4634f5a88ebc38456afe48490f74a42b262cf80bc4c9a67bfa8180dc5168f874a1ea43a9915bb02657bebd0ce54d5bfa1a08f8b29df57ebd1d1efd774bac2644f2fe28f20f92b8d79af92efe9a65bf51dcfb753ca7355a7d250496c5cee0db43e750a089f63d044389ddf5339d27288c4103514cfa6a46caaf6fb8b173eb0720d94b7bd5ec458c7367d1d88b9de235fefe7d7f21f71f32f651651adbad47668709c1074a98b129ee65115e5171dbb70c80ccebe6b56091dd214634a5d8e06d3075af927eb97327d71eb7d0397953986acd31c089b93f4a62a0ba6977ccedb7b80af9de2e786be0e37ab3a2e82c42a7d6c788917e800c1bd86f281db9deaf209a440c7cdc159eddfc8f2105fa276a485655d3d2d710bbf7da45bb8e834ab7f470ad569001dfdf36504f5a9e5efd123a8415b3d74a3c493e03c1550bb6aad295d39e7204e4d6908456341717d1760cc3b0d9cbe546af7b4ab471da0dfc89f2d6386d4aa932386e51e9bc3f7d6d140a4126c3a5a3974872df4567875eb277ccc1c0717621a058bff4fb980d6b006a5f540dfff0da403aa2573ba95c42472c04b20c94dc349e58df57476ad65b4604f55acafba905c427d77d1175f35454c368ec0cf78f49f7dd43cd3e275604069f9dd4777923676c40adc0bd2e15f07ac7eebb536a3d653226d14aa094dc7cfe37793ea4a3704b567ab841660e11c621a6e86d426abec6dcf3903423a53552c4ea099e56d590a49f58b6695071382bbf1eba2e36f0d55daa1bbe3d31995acbdcbec8658afc57d8a2ce12d43818ea6303f83f0bde85c5994228a5e7056d5b6828937b5378b5fe4e30e9ac0fd247bafdf35bf5a905d0d38b734398f7a8197e58e2b4ffb20f5a15140bf777de4e599175b24a627c0cdb2b9ad34869ee4f2a4e5072e85c6395be6a34f18f2f4e4b598b54700948d51b7497c66986fab1999ab1f95bc87c11aea4e0f266165fce87a0874981d4246063d8970101165134479f37b0c5d88dacde6f34871effa43600251f9ad1708fa300f03e34a8d965a66624312ba6f9f3bbc4b81417507367ea8be82f3dfda069f4cbc98b2eb09d9e35f5c21972ed366b0fcbdb53154a3772ff3dd00a1d7cdae5859a9c134a2b443297b62ccd95a70c61ef3242c4a94217ce3a04862d5f4c76de922a4a4b41cb6d201de0615524a9cc4f7cbd1b34094661cf49884aea386fd89f5e322ffbd3280a768d7f447b714e68464d00d32bf3fdb12390d6ea79ca7a684ec389ce09902ce71cd980e5bb1ad7902e77729965e4f64d27b40f805c0cc9fe6f21459beaba1fda10331b4dce8cc5eed8e3a15201d00e5ae507875d68f484bc98a8b03a8c8adbad6cadc5a1057d4495effcbc9e677ba11ef5fa34c8c0c5ed36d54e206c3c621806667a1f4265a63db73906969dddd94fc2293dcd03b66013772a81a8d1a391206209209649d0a583752487f5700474dc4ac13d3250b10f5c0e836fac6d07c851a1e98ec4dd2d203a31b04699212e29109224fecf96ffee85e347db4218295957604033b22fc682067651311a20514438a05ea7ebaf0ec8bca155278e6e5635a685b015fa3016325e4714540ee79e841e2cf30012d014be86d6f704d6bfd5746509a1dd3e162a390886b1d1fc8ba0dd63acefcffa3ed15a17df99cf9c13abf610b0c85ca31fd2f8af18c62f96259702fb93c5209e875f0c8e357af1a9928f10537997bb1288a6993f182f38d0604220b2d988307fb4427cdde499412470fe518757111cd862c2efad5985887a93fb94940e4c8ebb10356da48161b330713e890655afbafbb146f61f930b090028b934fd3b67d3b43e2e20c77874fd60773da913a0a72d96c4567e4119f33619347cd686c1d098856c6c0f01dc93388c6bd4ae52ac8ddd2335465aebcb198d8207edb15c61fba8b7bccae3ddf0180d96c61e32823487782ce1cf922da3d0472737c5a65801f3de65a370f82dfa0312ed2f673726ff256b30207652292c76c00f4b639b02733c11496a051716569ddcd99fed72159476bd63f59699b1ecffadc0c60e132b08c115f4b15e7874531e12c326c25058da980318f103b441293661156ec97b59be85bdc6049525ba23a77bff1eaa23936a926a7bddf8a4922967a7552034cbfcaed2411d02c0d889566d8d1998a2b8b33c7d1799364ae56ed0e08533862f2559dece76b188edc9c11f3c52469202a1b1a12de40b6e521a45371bebe3fcc89b9f43ee32c4565db64c471d5492a068d7c210919310df59239688fb0f847b157612f8f9683b92aedfbb7260a3fe6d4f124311b248b22f678c58a96ec5e10ae7bb797420923fac991409ef96aae3b8ab8eb53398bc24b12e867269b18e840eae115f1b5b9425015d24d8614bfefe2c4c6d19651f4fbb9a3aabe05975115496b323746afc5fcdf3666cdbd249fc68ae8564521afc65e53436118978f6b057eb5e4da6466f769c2cfa15b423726d3b9f6074bcc90e25663b0e49977e9eb3ff770e24d6ce2e57e75d76a96b4143a1c8f3620428f0b43bf4566b1ca7435551f693c1b41c5df63df6ba2ac9b58c93edeab7d1389fb8afdff9c1c519009ef2e3c06dac4700c868eca6847a5a4aca409a6397184b9671869412818d9a397b52db95c556d2a78b363690c85f9d86d697e89a3cd4f06d649ad7ea6e55eab2c2a163a7e589a3fff35d520c1795e45c9388e8769b9494a6b49914bba368e481d07e26f2136a43c2862fc4cf794f645204a9ee890c3d23c6b628f32da5f93c206732bffd20c2cbd00a1d667d8d3ecda1295800325003d737e9a7f1cbbca73fe3f030ff00002a00000000008a01021d81bc063000918971c3ef36d6fdf7d2bef977660d8e5e61fe0457864c1330ed1568299ee459d01a9dfd08a3e7a0c74002d36e84e186a5bff4340b183923d65206226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f00002a000000000066505be50101009000000000000003e8000000640000000a000000003b9aca00000000000113081f6d86356f02bbc63032527acc16f247306626923ac644d268c6d388acc201ffffffffffffff3bf900010124555a8f256dd2cd0f98bd8f64df22bc7470116f7edad3e51d2ebc61b6e69b67bb000000002b40420f0000000000220020cfde05ca43faf792db30fbd8511b29068f3f0bd610d850f5ba6d55018511d69a4752210218bb3f1452c4036155e8f06bbb714e62b1dd4185a83c5cd069665782a04b85852103d0dd6515d07459f110be9c3165b950916df51e1dd342d673278784f4964e135352ae0003d0dd6515d07459f110be9c3165b950916df51e1dd342d673278784f4964e1353023820147f58f10fc823579e9ec16bcedddfad27a0a502b5fd1c7b1f89b38f0eb9011f50c30000000000001600146dfb6b029bde323412ab8ab96a06be54693e5c19fe00061a80fd044cfd00fd010101030024555a8f256dd2cd0f98bd8f64df22bc7470116f7edad3e51d2ebc61b6e69b67bb00000000220020cfde05ca43faf792db30fbd8511b29068f3f0bd610d850f5ba6d55018511d69afefffffffdfe2faf0800fe0bebc2000002042200200772d4d7fe05d7bdc102093ffed8bbfcab6ddefed3577bc7c41d16a6fdc5ce1dfe2cb14ca8fe0bebc200000000010202fdc3501600146dfb6b029bde323412ab8ab96a06be54693e5c1900fe00061a8088004713081f6d86356f02bbc63032527acc16f247306626923ac644d268c6d388acc28b280cf870f5064b7dff2fcee3975747922856de2fe7275d959e3bb81ac5e00c0000fd025940a332b7f1f74710ad15bb4d1d3f307d305022ac84b953bfb7f7494c998182e835736f20d226ab927138635b157e0068664b887fc897e4481f78dea0c1e1bd193702fe00030d4064fd015e967c5fc29a6804cca3b35f63ccefbea17035999856483744cb5722f94f04020b890b31e27d192ea79ff17fe3ab38c12adf4bb0997a60295ce6f2525542deda772200200772d4d7fe05d7bdc102093ffed8bbfcab6ddefed3577bc7c41d16a6fdc5ce1d00fe00061a8064fd03e80000fd1388fe2cb14ca8fe0bebc20000248b280cf870f5064b7dff2fcee3975747922856de2fe7275d959e3bb81ac5e00c010000002b397e0e00000000002200200772d4d7fe05d7bdc102093ffed8bbfcab6ddefed3577bc7c41d16a6fdc5ce1d475221023820147f58f10fc823579e9ec16bcedddfad27a0a502b5fd1c7b1f89b38f0eb92103d1d020a4766149a6fbc8eb6a0d4b05e9a4ba0241fd5baf6459534d0c673d69a952aedf02000000018b280cf870f5064b7dff2fcee3975747922856de2fe7275d959e3bb81ac5e00c0100000000e3033a80044a01000000000000220020294e81c524fb7b4e8735d24263c0b586ac131b8fa95651015cfb5e6b1b46f66f4a0100000000000022002043675f52248a7d3765165f8dee4d46e08fa1a57604c13fa991e280d92ad2e6ea400d03000000000022002021d519f1ce1e3ae039dacb110411dfe477ccf4b6c2168a141919ca482b6ae31771580b00000000002200203cf8e1d032035e81667c393f5f6638143284a4add6efd656b9641c04eb29ff6cfe3fa020000000fd1388fe0bebc200fe2cb14ca88b01a0e7d1c61c58cbff673636189d334e654bb72d4d6d1f10d528a1bd69f42603b93622e8424a4d6fb07b934730afe5d61c98e888d51f9fcffa2cd0aebc6f6889010156b38e125574b4454e656a3e546c16cdaedfdffc077d3e807fee4946e8ec15876464fd01f40101fe0007a120fafd02ee4745caee03439c2d96c16f2e43f8de1b408f02404a78a2a75e1e2f9074888c11c8e6a1613bd1223f1d965e4ef31d3e26f7769f818b235ee914f80f6b3dbe4f2d220020cfde05ca43faf792db30fbd8511b29068f3f0bd610d850f5ba6d55018511d69a00fe00061a8064fd03e8" ) - assertEquals(state1, Serialization.deserialize(Serialization.serialize(state1)).value) + val state = Serialization.deserialize(bin).value + assertIs(state) + val splice = state.spliceStatus + assertIs(splice) + assertNull(splice.liquidityPurchase) + assertTrue(splice.session.localCommit.isLeft) } } + } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt b/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt index f15e9d22f..4fc8c4e6c 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt @@ -11,6 +11,7 @@ import fr.acinq.lightning.tests.utils.testLoggerFactory import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.toByteVector32 +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.lightning.wire.OnionRoutingPacket import fr.acinq.secp256k1.Hex @@ -37,6 +38,19 @@ object TestConstants { TrampolineFees(5.sat, 1200, CltvExpiryDelta(576)) ) + val fundingRates = LiquidityAds.WillFundRates( + fundingRates = listOf( + LiquidityAds.FundingRate(100_000.sat, 500_000.sat, 500, 100, 0.sat, 0.sat), + LiquidityAds.FundingRate(500_000.sat, 10_000_000.sat, 750, 100, 0.sat, 0.sat) + ), + paymentTypes = setOf( + LiquidityAds.PaymentType.FromChannelBalance, + LiquidityAds.PaymentType.FromFutureHtlc, + LiquidityAds.PaymentType.FromFutureHtlcWithPreimage, + LiquidityAds.PaymentType.FromChannelBalanceForFutureHtlc, + ) + ) + const val aliceSwapInServerXpub = "tpubDCvYeHUZisCMVTSfWDa1yevTf89NeF6TWxXUQwqkcmFrNvNdNvZQh1j4m4uTA4QcmPEwcrKVF8bJih1v16zDZacRr4j9MCAFQoSydKKy66q" const val bobSwapInServerXpub = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41" @@ -62,12 +76,15 @@ object TestConstants { Feature.AnchorOutputs to FeatureSupport.Mandatory, Feature.RouteBlinding to FeatureSupport.Optional, Feature.DualFunding to FeatureSupport.Mandatory, + Feature.ShutdownAnySegwit to FeatureSupport.Mandatory, + Feature.Quiescence to FeatureSupport.Mandatory, Feature.ChannelType to FeatureSupport.Mandatory, Feature.PaymentMetadata to FeatureSupport.Optional, Feature.ExperimentalTrampolinePayment to FeatureSupport.Optional, Feature.WakeUpNotificationProvider to FeatureSupport.Optional, - Feature.PayToOpenProvider to FeatureSupport.Optional, Feature.ChannelBackupProvider to FeatureSupport.Optional, + Feature.ExperimentalSplice to FeatureSupport.Optional, + Feature.OnTheFlyFunding to FeatureSupport.Optional, ), dustLimit = 1_100.sat, maxRemoteDustLimit = 1_500.sat, @@ -86,7 +103,7 @@ object TestConstants { paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(0), CltvExpiryDelta(0)), ) - fun channelParams(): LocalParams = LocalParams(nodeParams, isInitiator = true) + fun channelParams(payCommitTxFees: Boolean): LocalParams = LocalParams(nodeParams, isChannelOpener = true, payCommitTxFees = payCommitTxFees) } object Bob { @@ -117,7 +134,7 @@ object TestConstants { paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(0), CltvExpiryDelta(0)), ) - fun channelParams(): LocalParams = LocalParams(nodeParams, isInitiator = false) + fun channelParams(payCommitTxFees: Boolean): LocalParams = LocalParams(nodeParams, isChannelOpener = false, payCommitTxFees = payCommitTxFees) } } 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 d4db2a3e2..4b5274a7e 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 @@ -1,5 +1,6 @@ package fr.acinq.lightning.tests.io.peer +import fr.acinq.bitcoin.Block import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.PrivateKey import fr.acinq.lightning.NodeParams @@ -18,12 +19,10 @@ import fr.acinq.lightning.channel.states.Syncing import fr.acinq.lightning.db.InMemoryDatabases import fr.acinq.lightning.io.* import fr.acinq.lightning.logging.MDCLogger +import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.tests.utils.testLoggerFactory import fr.acinq.lightning.utils.sat -import fr.acinq.lightning.wire.ChannelReady -import fr.acinq.lightning.wire.ChannelReestablish -import fr.acinq.lightning.wire.Init -import fr.acinq.lightning.wire.LightningMessage +import fr.acinq.lightning.wire.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -82,12 +81,16 @@ suspend fun connect( } // Initialize Bob with Alice's features. - val aliceInit = Init(alice.nodeParams.features.initFeatures()) + val aliceInit = Init(alice.nodeParams.features.initFeatures(), listOf(Block.RegtestGenesisBlock.hash.value), TestConstants.fundingRates) bob.send(MessageReceived(bobConnection.id, aliceInit)) // Initialize Alice with Bob's features. - val bobInit = Init(bob.nodeParams.features.initFeatures()) + val bobInit = Init(bob.nodeParams.features.initFeatures(), listOf(Block.RegtestGenesisBlock.hash.value), TestConstants.fundingRates) alice.send(MessageReceived(aliceConnection.id, bobInit)) + // Initialize Alice and Bob's current feerates. + alice.send(MessageReceived(aliceConnection.id, RecommendedFeerates(Block.RegtestGenesisBlock.hash, fundingFeerate = FeeratePerKw(FeeratePerByte(20.sat)), commitmentFeerate = FeeratePerKw(FeeratePerByte(1.sat))))) + bob.send(MessageReceived(bobConnection.id, RecommendedFeerates(Block.RegtestGenesisBlock.hash, fundingFeerate = FeeratePerKw(FeeratePerByte(20.sat)), commitmentFeerate = FeeratePerKw(FeeratePerByte(1.sat))))) + if (channelsCount > 0) { // When there are multiple channels, the channel_reestablish and channel_ready messages from different channels // may be interleaved, so we cannot guarantee a deterministic ordering and thus need independent coroutines. diff --git a/src/commonTest/kotlin/fr/acinq/lightning/transactions/AnchorOutputsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/transactions/AnchorOutputsTestsCommon.kt index e57048707..2aca071fd 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/transactions/AnchorOutputsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/transactions/AnchorOutputsTestsCommon.kt @@ -18,7 +18,6 @@ import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.Htl import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.HtlcTx.HtlcTimeoutTx import fr.acinq.lightning.utils.* import fr.acinq.lightning.wire.UpdateAddHtlc -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import org.kodein.memory.file.FileSystem import org.kodein.memory.file.Path @@ -92,7 +91,8 @@ class AnchorOutputsTestsCommon { val localParams = LocalParams( TestConstants.Alice.nodeParams.nodeId, KeyPath.empty, - 546.sat, 1000000000L, 0.msat, CltvExpiryDelta(144), 1000, true, + 546.sat, 1000000000L, 0.msat, CltvExpiryDelta(144), 1000, + isChannelOpener = true, paysCommitTxFees = true, Script.write(Script.pay2wpkh(randomKey().publicKey())).toByteVector(), TestConstants.Alice.nodeParams.features, ) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/transactions/TransactionsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/transactions/TransactionsTestsCommon.kt index eccfc4e4d..6163254b7 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/transactions/TransactionsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/transactions/TransactionsTestsCommon.kt @@ -656,7 +656,7 @@ class TransactionsTestsCommon : LightningTestSuite() { run { // Different amounts, both outputs untrimmed, local is the initiator: val spec = CommitmentSpec(setOf(), feerate, 150_000_000.msat, 250_000_000.msat) - val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localIsInitiator = true, localDustLimit, 1000.sat, spec) + val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localPaysClosingFees = true, localDustLimit, 1000.sat, spec) assertEquals(2, closingTx.tx.txOut.size) assertNotNull(closingTx.toLocalIndex) assertEquals(localPubKeyScript.toByteVector(), closingTx.toLocalOutput!!.publicKeyScript) @@ -667,7 +667,7 @@ class TransactionsTestsCommon : LightningTestSuite() { run { // Same amounts, both outputs untrimmed, local is not the initiator: val spec = CommitmentSpec(setOf(), feerate, 150_000_000.msat, 150_000_000.msat) - val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localIsInitiator = false, localDustLimit, 1000.sat, spec) + val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localPaysClosingFees = false, localDustLimit, 1000.sat, spec) assertEquals(2, closingTx.tx.txOut.size) assertNotNull(closingTx.toLocalIndex) assertEquals(localPubKeyScript.toByteVector(), closingTx.toLocalOutput!!.publicKeyScript) @@ -678,7 +678,7 @@ class TransactionsTestsCommon : LightningTestSuite() { run { // Their output is trimmed: val spec = CommitmentSpec(setOf(), feerate, 150_000_000.msat, 1_000.msat) - val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localIsInitiator = false, localDustLimit, 1000.sat, spec) + val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localPaysClosingFees = false, localDustLimit, 1000.sat, spec) assertEquals(1, closingTx.tx.txOut.size) assertNotNull(closingTx.toLocalOutput) assertEquals(localPubKeyScript.toByteVector(), closingTx.toLocalOutput!!.publicKeyScript) @@ -688,14 +688,14 @@ class TransactionsTestsCommon : LightningTestSuite() { run { // Our output is trimmed: val spec = CommitmentSpec(setOf(), feerate, 50_000.msat, 150_000_000.msat) - val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localIsInitiator = true, localDustLimit, 1000.sat, spec) + val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localPaysClosingFees = true, localDustLimit, 1000.sat, spec) assertEquals(1, closingTx.tx.txOut.size) assertNull(closingTx.toLocalOutput) } run { // Both outputs are trimmed: val spec = CommitmentSpec(setOf(), feerate, 50_000.msat, 10_000.msat) - val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localIsInitiator = true, localDustLimit, 1000.sat, spec) + val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localPaysClosingFees = true, localDustLimit, 1000.sat, spec) assertTrue(closingTx.tx.txOut.isEmpty()) assertNull(closingTx.toLocalOutput) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt index d869d0ba4..2a60e6245 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -9,10 +9,12 @@ import fr.acinq.lightning.Lightning.randomBytes import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomBytes64 import fr.acinq.lightning.Lightning.randomKey -import fr.acinq.lightning.blockchain.fee.FeeratePerByte import fr.acinq.lightning.blockchain.fee.FeeratePerKw -import fr.acinq.lightning.channel.* +import fr.acinq.lightning.channel.ChannelFlags +import fr.acinq.lightning.channel.ChannelType +import fr.acinq.lightning.channel.Helpers import fr.acinq.lightning.message.OnionMessages +import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat @@ -214,10 +216,10 @@ class LightningCodecsTestsCommon : LightningTestSuite() { ), // unknown odd records TestCase(ByteVector("0000 0002088a 03012a04022aa2"), decoded = null), // unknown even records TestCase(ByteVector("0000 0002088a 0120010101010101010101010101010101010101010101010101010101010101"), decoded = null), // invalid tlv stream - TestCase(ByteVector("0000 0002088a 01200101010101010101010101010101010101010101010101010101010101010101"), Init(Features(ByteVector("088a")), listOf(chainHash1), listOf())), // single network + TestCase(ByteVector("0000 0002088a 01200101010101010101010101010101010101010101010101010101010101010101"), Init(Features(ByteVector("088a")), listOf(chainHash1), null)), // single network TestCase( ByteVector("0000 0002088a 014001010101010101010101010101010101010101010101010101010101010101010202020202020202020202020202020202020202020202020202020202020202"), - Init(Features(ByteVector("088a")), listOf(chainHash1, chainHash2), listOf()) + Init(Features(ByteVector("088a")), listOf(chainHash1, chainHash2), null) ), // multiple networks TestCase( ByteVector("0000 0002088a 01200101010101010101010101010101010101010101010101010101010101010101 03012a"), @@ -225,17 +227,36 @@ class LightningCodecsTestsCommon : LightningTestSuite() { ), // network and unknown odd records TestCase(ByteVector("0000 0002088a 01200101010101010101010101010101010101010101010101010101010101010101 02012a"), decoded = null), // network and unknown even records TestCase( - ByteVector("0000 0002088a fd05391007d001f4003200000000025800000000"), - Init(Features(ByteVector("088a")), chainHashs = listOf(), liquidityRates = listOf(LiquidityAds.LeaseRate(2000, 500, 50, 0.sat, 600, 0.msat))), - ), // one liquidity ads + ByteVector("0000 0002088a fd053b190001000186a00007a1200226006400001388000003e8000101"), + Init( + Features(ByteVector("088a")), + chainHashs = listOf(), + liquidityRates = LiquidityAds.WillFundRates( + fundingRates = listOf(LiquidityAds.FundingRate(100_000.sat, 500_000.sat, 550, 100, 5_000.sat, 1_000.sat)), + paymentTypes = setOf(LiquidityAds.PaymentType.FromChannelBalance) + ) + ), + ), // one liquidity ads with the default payment type TestCase( - ByteVector("0000 0002088a fd05392003f0019000c8000061a80064000186a00fc001f401f4000027100096000249f0"), + ByteVector("0000 0002088a fd053b470002000186a00007a1200226006400001388000003e80007a120004c4b40044c004b00000000000005dc001b080000000000000000000700000000000000000000000000000001"), Init( Features(ByteVector("088a")), chainHashs = listOf(), - liquidityRates = listOf(LiquidityAds.LeaseRate(1008, 400, 200, 25_000.sat, 100, 100_000.msat), LiquidityAds.LeaseRate(4032, 500, 500, 10_000.sat, 150, 150_000.msat)) + liquidityRates = LiquidityAds.WillFundRates( + fundingRates = listOf( + LiquidityAds.FundingRate(100_000.sat, 500_000.sat, 550, 100, 5_000.sat, 1_000.sat), + LiquidityAds.FundingRate(500_000.sat, 5_000_000.sat, 1100, 75, 0.sat, 1_500.sat), + ), + paymentTypes = setOf( + LiquidityAds.PaymentType.FromChannelBalance, + LiquidityAds.PaymentType.FromFutureHtlc, + LiquidityAds.PaymentType.FromFutureHtlcWithPreimage, + LiquidityAds.PaymentType.FromChannelBalanceForFutureHtlc, + LiquidityAds.PaymentType.Unknown(211) + ) + ) ), - ), // two liquidity ads + ), // two liquidity ads with multiple payment types ) for (testCase in testCases) { @@ -290,18 +311,59 @@ class LightningCodecsTestsCommon : LightningTestSuite() { @Test fun `encode - decode open_channel`() { + val fundingRates = LiquidityAds.WillFundRates( + fundingRates = listOf( + LiquidityAds.FundingRate(100_000.sat, 500_000.sat, 550, 100, 5_000.sat, 1_000.sat), + LiquidityAds.FundingRate(500_000.sat, 5_000_000.sat, 1100, 75, 0.sat, 1_500.sat), + ), + paymentTypes = setOf( + LiquidityAds.PaymentType.FromChannelBalance, + LiquidityAds.PaymentType.FromFutureHtlc, + LiquidityAds.PaymentType.FromFutureHtlcWithPreimage, + LiquidityAds.PaymentType.FromChannelBalanceForFutureHtlc, + LiquidityAds.PaymentType.Unknown(211) + ) + ) + val requestFundsFromChannelBalance = LiquidityAds.RequestFunding.chooseRate(750_000.sat, LiquidityAds.PaymentDetails.FromChannelBalance, fundingRates)!! + val paymentHashes = listOf( + ByteVector32.fromValidHex("80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734"), + ByteVector32.fromValidHex("d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47"), + ) + val requestFundsFromHtlc = LiquidityAds.RequestFunding.chooseRate(500_000.sat, LiquidityAds.PaymentDetails.FromFutureHtlc(paymentHashes), fundingRates)!! + val requestFundsFromBalanceForHtlc = LiquidityAds.RequestFunding.chooseRate(500_000.sat, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(paymentHashes), fundingRates)!! // @formatter:off - val defaultOpen = OpenDualFundedChannel(BlockHash(ByteVector32.Zeroes), ByteVector32.One, FeeratePerKw(5000.sat), FeeratePerKw(4000.sat), 250_000.sat, 500.sat, 50_000, 15.msat, CltvExpiryDelta(144), 483, 650_000, publicKey(1), publicKey(2), publicKey(3), publicKey(4), publicKey(5), publicKey(6), publicKey(7), 1.toByte()) - val defaultEncoded = ByteVector("0040 0000000000000000000000000000000000000000000000000000000000000000 0100000000000000000000000000000000000000000000000000000000000000 00001388 00000fa0 000000000003d090 00000000000001f4 000000000000c350 000000000000000f 0090 01e3 0009eb10 031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f 024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766 02531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337 03462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b 0362c0a046dacce86ddd0343c6d3c7c79c2208ba0d9c9cf24a6d046d21d21f90f7 03f006a18d5653c4edf5391ff23a61f03ff83d237e880ee61187fa9f379a028e0a 02989c0b76cb563971fdc9bef31ec06c3560f3249d6ee9e5d83c57625596e05f6f 01") + val defaultOpen = OpenDualFundedChannel(BlockHash(ByteVector32.Zeroes), ByteVector32.One, FeeratePerKw(5000.sat), FeeratePerKw(4000.sat), 250_000.sat, 500.sat, 50_000, 15.msat, CltvExpiryDelta(144), 483, 650_000, publicKey(1), publicKey(2), publicKey(3), publicKey(4), publicKey(5), publicKey(6), publicKey(7), ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = false)) + val defaultEncoded = ByteVector("0040 0000000000000000000000000000000000000000000000000000000000000000 0100000000000000000000000000000000000000000000000000000000000000 00001388 00000fa0 000000000003d090 00000000000001f4 000000000000c350 000000000000000f 0090 01e3 0009eb10 031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f 024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766 02531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337 03462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b 0362c0a046dacce86ddd0343c6d3c7c79c2208ba0d9c9cf24a6d046d21d21f90f7 03f006a18d5653c4edf5391ff23a61f03ff83d237e880ee61187fa9f379a028e0a 02989c0b76cb563971fdc9bef31ec06c3560f3249d6ee9e5d83c57625596e05f6f 00") val testCases = listOf( defaultOpen to defaultEncoded, defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs))) to (defaultEncoded + ByteVector("0103101000")), defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.PushAmountTlv(25_000.msat))) to (defaultEncoded + ByteVector("0103101000 fe470000070261a8")), defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.RequireConfirmedInputsTlv)) to (defaultEncoded + ByteVector("0103101000 0200")), - defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.RequestFunds(50_000.sat, 2500, 500_000))) to (defaultEncoded + ByteVector("0103101000 fd05390e000000000000c35009c40007a120")), + defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.RequestFundingTlv(requestFundsFromChannelBalance))) to (defaultEncoded + ByteVector("0103101000 fd053b1e00000000000b71b00007a120004c4b40044c004b00000000000005dc0000")), + defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.RequestFundingTlv(requestFundsFromHtlc))) to (defaultEncoded + ByteVector("0103101000 fd053b5e000000000007a120000186a00007a1200226006400001388000003e8804080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47")), + defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.RequestFundingTlv(requestFundsFromBalanceForHtlc))) to (defaultEncoded + ByteVector("0103101000 fd053b5e000000000007a120000186a00007a1200226006400001388000003e8824080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47")), defaultOpen.copy(tlvStream = TlvStream(setOf(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs)), setOf(GenericTlv(321, ByteVector("2a2a")), GenericTlv(325, ByteVector("02"))))) to (defaultEncoded + ByteVector("0103101000 fd0141022a2a fd01450102")), - defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.OriginTlv(Origin.PayToOpenOrigin(ByteVector32.fromValidHex("187bf923f7f11ef732b73c417eb5a57cd4667b20a6f130ff505cd7ad3ab87281"), 1_000_000.msat, 1234.sat, 200_000_000.msat)))) to (defaultEncoded + ByteVector("fe47000005 3a 0001 187bf923f7f11ef732b73c417eb5a57cd4667b20a6f130ff505cd7ad3ab87281 00000000000004d2 00000000000f4240 000000000bebc200")), - defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.OriginTlv(Origin.PleaseOpenChannelOrigin(ByteVector32("2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db25"), 1_234_567.msat, 321.sat, 1_111_000.msat)))) to (defaultEncoded + ByteVector("fe47000005 3a 0004 2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db25 0000000000000141 000000000012d687 000000000010f3d8")), + ) + // @formatter:on + testCases.forEach { (open, bin) -> + val decoded = LightningMessage.decode(bin.toByteArray()) + assertNotNull(decoded) + assertEquals(decoded, open) + val encoded = LightningMessage.encode(open) + assertEquals(encoded.byteVector(), bin) + } + } + + @Test + fun `encode - decode open_channel flags`() { + // @formatter:off + val defaultOpen = OpenDualFundedChannel(BlockHash(ByteVector32.Zeroes), ByteVector32.One, FeeratePerKw(5000.sat), FeeratePerKw(4000.sat), 250_000.sat, 500.sat, 50_000, 15.msat, CltvExpiryDelta(144), 483, 650_000, publicKey(1), publicKey(2), publicKey(3), publicKey(4), publicKey(5), publicKey(6), publicKey(7), ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = false)) + val defaultEncodedWithoutFlags = ByteVector("0040 0000000000000000000000000000000000000000000000000000000000000000 0100000000000000000000000000000000000000000000000000000000000000 00001388 00000fa0 000000000003d090 00000000000001f4 000000000000c350 000000000000000f 0090 01e3 0009eb10 031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f 024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766 02531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337 03462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b 0362c0a046dacce86ddd0343c6d3c7c79c2208ba0d9c9cf24a6d046d21d21f90f7 03f006a18d5653c4edf5391ff23a61f03ff83d237e880ee61187fa9f379a028e0a 02989c0b76cb563971fdc9bef31ec06c3560f3249d6ee9e5d83c57625596e05f6f") + val testCases = listOf( + defaultOpen to (defaultEncodedWithoutFlags + ByteVector("00")), + defaultOpen.copy(channelFlags = ChannelFlags(announceChannel = true, nonInitiatorPaysCommitFees = false)) to (defaultEncodedWithoutFlags + ByteVector("01")), + defaultOpen.copy(channelFlags = ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = true)) to (defaultEncodedWithoutFlags + ByteVector("02")), + defaultOpen.copy(channelFlags = ChannelFlags(announceChannel = true, nonInitiatorPaysCommitFees = true)) to (defaultEncodedWithoutFlags + ByteVector("03")), ) // @formatter:on testCases.forEach { (open, bin) -> @@ -315,6 +377,11 @@ class LightningCodecsTestsCommon : LightningTestSuite() { @Test fun `encode - decode accept_channel`() { + val nodeKey = PrivateKey.fromHex("57ac961f1b80ebfb610037bf9c96c6333699bde42257919a53974811c34649e3") + val fundingLease = LiquidityAds.FundingRate(500_000.sat, 5_000_000.sat, 1100, 75, 0.sat, 1_500.sat) + val requestFunds = LiquidityAds.RequestFunding(750_000.sat, fundingLease, LiquidityAds.PaymentDetails.FromChannelBalance) + val fundingScript = Helpers.Funding.makeFundingPubKeyScript(publicKey(1), publicKey(1)) + val willFund = LiquidityAds.WillFundRates(listOf(fundingLease), setOf(LiquidityAds.PaymentType.FromChannelBalance)).validateRequest(nodeKey, fundingScript, FeeratePerKw(5000.sat), requestFunds, isChannelCreation = true)!!.willFund // @formatter:off val defaultAccept = AcceptDualFundedChannel(ByteVector32.One, 50_000.sat, 473.sat, 100_000_000, 1.msat, 6, CltvExpiryDelta(144), 50, publicKey(1), point(2), point(3), point(4), point(5), point(6), publicKey(7)) val defaultEncoded = ByteVector("0041 0100000000000000000000000000000000000000000000000000000000000000 000000000000c350 00000000000001d9 0000000005f5e100 0000000000000001 00000006 0090 0032 031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f 024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766 02531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337 03462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b 0362c0a046dacce86ddd0343c6d3c7c79c2208ba0d9c9cf24a6d046d21d21f90f7 03f006a18d5653c4edf5391ff23a61f03ff83d237e880ee61187fa9f379a028e0a 02989c0b76cb563971fdc9bef31ec06c3560f3249d6ee9e5d83c57625596e05f6f") @@ -322,7 +389,7 @@ class LightningCodecsTestsCommon : LightningTestSuite() { defaultAccept to defaultEncoded, defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.UnsupportedChannelType(Features(Feature.StaticRemoteKey to FeatureSupport.Mandatory))))) to (defaultEncoded + ByteVector("01021000")), defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.UpfrontShutdownScriptTlv(ByteVector("01abcdef")), ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs))) to (defaultEncoded + ByteVector("000401abcdef 0103101000")), - defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.WillFund(ByteVector64.Zeroes, 750, 150, 250.sat, 100, 5.msat))) to (defaultEncoded + ByteVector("0103101000 fd05394e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002ee0096000000fa006400000005")), + defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.ProvideFundingTlv(willFund))) to (defaultEncoded + ByteVector("0103101000 fd053b780007a120004c4b40044c004b00000000000005dc002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103cc57cf393f6bd534472ec08cbfbbc7268501b32f563a21cdf02a99127c4f25168249acd6509f96b2e93843c3b838ee4808c75d0a15ff71ba886fda980b8ca954f")), defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.PushAmountTlv(1729.msat))) to (defaultEncoded + ByteVector("0103101000 fe470000070206c1")), defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs), ChannelTlv.RequireConfirmedInputsTlv)) to (defaultEncoded + ByteVector("0103101000 0200")), defaultAccept.copy(tlvStream = TlvStream(setOf(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputs)), setOf(GenericTlv(113, ByteVector("deadbeef"))))) to (defaultEncoded + ByteVector("0103101000 7104deadbeef")), @@ -461,6 +528,7 @@ class LightningCodecsTestsCommon : LightningTestSuite() { val channelId = ByteVector32("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") val fundingTxId = TxId(TxHash("24e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566")) val fundingPubkey = PublicKey(ByteVector.fromHex("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798")) + val fundingRate = LiquidityAds.FundingRate(100_000.sat, 100_000.sat, 400, 150, 0.sat, 0.sat) val testCases = listOf( // @formatter:off Stfu(channelId, false) to ByteVector("0002 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00"), @@ -469,12 +537,12 @@ class LightningCodecsTestsCommon : LightningTestSuite() { SpliceInit(channelId, 150_000.sat, 25_000_000.msat, FeeratePerKw(2500.sat), 100, fundingPubkey, null) to ByteVector("9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000249f0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000704017d7840"), SpliceInit(channelId, 0.sat, FeeratePerKw(500.sat), 0, fundingPubkey) to ByteVector("9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000000 000001f4 00000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), SpliceInit(channelId, (-50_000).sat, FeeratePerKw(500.sat), 0, fundingPubkey) to ByteVector("9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ffffffffffff3cb0 000001f4 00000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), - SpliceInit(channelId, 100_000.sat, 0.msat, FeeratePerKw(2500.sat), 100, fundingPubkey, ChannelTlv.RequestFunds(100_000.sat, 4000, 850_000)) to ByteVector("9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd05390e00000000000186a00fa0000cf850"), + SpliceInit(channelId, 100_000.sat, 0.msat, FeeratePerKw(2500.sat), 100, fundingPubkey, LiquidityAds.RequestFunding(100_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance)) to ByteVector("9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b1e00000000000186a0000186a0000186a00190009600000000000000000000"), SpliceAck(channelId, 25_000.sat, fundingPubkey) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), SpliceAck(channelId, 40_000.sat, 10_000_000.msat, fundingPubkey, null) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000009c40 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000703989680"), SpliceAck(channelId, 0.sat, fundingPubkey) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), SpliceAck(channelId, (-25_000).sat, fundingPubkey) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ffffffffffff9e58 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), - SpliceAck(channelId, 25_000.sat, 0.msat, fundingPubkey, ChannelTlv.WillFund(ByteVector64.Zeroes, 750, 150, 250.sat, 100, 0.msat)) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd05394e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002ee0096000000fa006400000000"), + SpliceAck(channelId, 25_000.sat, 0.msat, fundingPubkey, LiquidityAds.WillFund(fundingRate, ByteVector("deadbeef"), ByteVector64.Zeroes)) to ByteVector("908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b5a000186a0000186a00190009600000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), SpliceLocked(channelId, fundingTxId) to ByteVector("908c aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 24e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566"), // @formatter:on ) @@ -531,8 +599,9 @@ class LightningCodecsTestsCommon : LightningTestSuite() { @Test fun `decode channel_update with htlc_maximum_msat`() { // this was generated by c-lightning - val encoded = - ByteVector("010258fff7d0e987e2cdd560e3bb5a046b4efe7b26c969c2f51da1dceec7bcb8ae1b634790503d5290c1a6c51d681cf8f4211d27ed33a257dcc1102862571bf1792306226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f0005a100000200005bc75919010100060000000000000001000000010000000a000000003a699d00") + val encoded = ByteVector( + "010258fff7d0e987e2cdd560e3bb5a046b4efe7b26c969c2f51da1dceec7bcb8ae1b634790503d5290c1a6c51d681cf8f4211d27ed33a257dcc1102862571bf1792306226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f0005a100000200005bc75919010100060000000000000001000000010000000a000000003a699d00" + ) val decoded = LightningMessage.decode(encoded.toByteArray()) val expected = ChannelUpdate( ByteVector64("58fff7d0e987e2cdd560e3bb5a046b4efe7b26c969c2f51da1dceec7bcb8ae1b634790503d5290c1a6c51d681cf8f4211d27ed33a257dcc1102862571bf17923"), @@ -752,20 +821,25 @@ class LightningCodecsTestsCommon : LightningTestSuite() { } @Test - fun `encode - decode pay-to-open messages`() { - val onionPacket = OnionRoutingPacket(0, ByteVector("0209be9bd1016d73fc1f611c6f8fdccd99ffb0885594e96156a268ee9afd35559c"), ByteVector("0102030405"), ByteVector32("e0a0d5be2ca6faafa03880258e4af33a0d15aa950ab738c88566a471bf3bb14f")) - val blinding = PublicKey.fromHex("033da8b63fd839472b49935127072039e65d8f99d4603f14d79cdf74b59f895721") - val preimage = ByteVector32("339770785632e71fe1f4b48b8b90d14af94a2a3a2c70af66f2156ed8a150f795") + fun `encode - decode on-the-fly funding messages`() { + val channelId = ByteVector32("c11b8fbd682b3c6ee11f9d7268e22bb5887cd4d3bf3338bfcc340583f685733c") + val paymentId = ByteVector32("3118a7954088c27b19923894ed27923c297f88ec3734f90b2b4aafcb11238503") + val blinding = PublicKey.fromHex("0296d5c32655a5eaa8be086479d7bcff967b6e9ca8319b69565747ae16ff20fad6") + val paymentHash1 = ByteVector32("80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734") + val paymentHash2 = ByteVector32("3213a810a0bfc54566d9be09da1484538b5d19229e928dfa8b692966a8df6785") + val fundingFee = LiquidityAds.FundingFee(5_000_100.msat, TxId(TxHash("24e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566"))) val testCases = listOf( // @formatter:off - PayToOpenRequest(Block.LivenetGenesisBlock.hash, 5_000.msat, 10.sat, preimage.sha256(), 100, onionPacket, 1_000_000.sat) to Hex.decode("88cd 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 0000000000000000 0000000000001388 0000000000000000 000000000000000a e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 00000064 0005 000209be9bd1016d73fc1f611c6f8fdccd99ffb0885594e96156a268ee9afd35559c0102030405e0a0d5be2ca6faafa03880258e4af33a0d15aa950ab738c88566a471bf3bb14f 00000000000f4240"), - PayToOpenRequest(Block.LivenetGenesisBlock.hash, 5_000.msat, 10.sat, preimage.sha256(), 100, onionPacket, 0.sat, TlvStream(PayToOpenRequestTlv.Blinding(blinding))) to Hex.decode("88cd 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 0000000000000000 0000000000001388 0000000000000000 000000000000000a e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 00000064 0005 000209be9bd1016d73fc1f611c6f8fdccd99ffb0885594e96156a268ee9afd35559c0102030405e0a0d5be2ca6faafa03880258e4af33a0d15aa950ab738c88566a471bf3bb14f 0000000000000000 0021033da8b63fd839472b49935127072039e65d8f99d4603f14d79cdf74b59f895721"), - PayToOpenResponse(Block.LivenetGenesisBlock.hash, preimage.sha256(), PayToOpenResponse.Result.Success(preimage)) to Hex.decode("88bb 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 339770785632e71fe1f4b48b8b90d14af94a2a3a2c70af66f2156ed8a150f795"), - PayToOpenResponse(Block.LivenetGenesisBlock.hash, preimage.sha256(), PayToOpenResponse.Result.Failure(null)) to Hex.decode("88bb 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 0000000000000000000000000000000000000000000000000000000000000000"), - PayToOpenResponse(Block.LivenetGenesisBlock.hash, preimage.sha256(), PayToOpenResponse.Result.Failure(ByteVector("deadbeef"))) to Hex.decode("88bb 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 e7e2ae1540f63627007acc816c8b978b8344265b581840d5feec7ff0a85bbf0b 0000000000000000000000000000000000000000000000000000000000000000 0004deadbeef"), + UpdateAddHtlc(channelId, 7, 75_000_000.msat, paymentHash1, CltvExpiry(840_000), TestConstants.emptyOnionPacket, blinding = null, fundingFee = fundingFee) to Hex.decode("0080 c11b8fbd682b3c6ee11f9d7268e22bb5887cd4d3bf3338bfcc340583f685733c 0000000000000007 00000000047868c0 80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734 000cd140 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 fda0512800000000004c4ba424e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566"), + WillAddHtlc(Block.RegtestGenesisBlock.hash, paymentId, 50_000_000.msat, paymentHash1, CltvExpiry(840_000), TestConstants.emptyOnionPacket, blinding = null) to Hex.decode("a051 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 3118a7954088c27b19923894ed27923c297f88ec3734f90b2b4aafcb11238503 0000000002faf080 80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734 000cd140 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), + WillAddHtlc(Block.RegtestGenesisBlock.hash, paymentId, 50_000_000.msat, paymentHash1, CltvExpiry(840_000), TestConstants.emptyOnionPacket, blinding) to Hex.decode("a051 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 3118a7954088c27b19923894ed27923c297f88ec3734f90b2b4aafcb11238503 0000000002faf080 80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734 000cd140 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 00210296d5c32655a5eaa8be086479d7bcff967b6e9ca8319b69565747ae16ff20fad6"), + WillFailHtlc(paymentId, paymentHash1, ByteVector("deadbeef")) to Hex.decode("a052 3118a7954088c27b19923894ed27923c297f88ec3734f90b2b4aafcb11238503 80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734 0004 deadbeef"), + WillFailMalformedHtlc(paymentId, paymentHash1, ByteVector32("9d60e5791eee0799ce7b00009f56f56c6b988f6129b6a88494cce2cf2fa8b319"), 49157) to Hex.decode("a053 3118a7954088c27b19923894ed27923c297f88ec3734f90b2b4aafcb11238503 80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734 9d60e5791eee0799ce7b00009f56f56c6b988f6129b6a88494cce2cf2fa8b319 c005"), + CancelOnTheFlyFunding(channelId, listOf(), ByteVector("deadbeef")) to Hex.decode("a054 c11b8fbd682b3c6ee11f9d7268e22bb5887cd4d3bf3338bfcc340583f685733c 0000 0004 deadbeef"), + CancelOnTheFlyFunding(channelId, listOf(paymentHash1), ByteVector("deadbeef")) to Hex.decode("a054 c11b8fbd682b3c6ee11f9d7268e22bb5887cd4d3bf3338bfcc340583f685733c 0001 80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734 0004 deadbeef"), + CancelOnTheFlyFunding(channelId, listOf(paymentHash1, paymentHash2), ByteVector("deadbeef")) to Hex.decode("a054 c11b8fbd682b3c6ee11f9d7268e22bb5887cd4d3bf3338bfcc340583f685733c 0002 80417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb21067343213a810a0bfc54566d9be09da1484538b5d19229e928dfa8b692966a8df6785 0004 deadbeef"), // @formatter:on ) - testCases.forEach { val decoded = LightningMessage.decode(it.second) assertNotNull(decoded) @@ -776,16 +850,11 @@ class LightningCodecsTestsCommon : LightningTestSuite() { } @Test - fun `encode - decode please-open-channel messages`() { + fun `encode - decode phoenix-android-legacy-info messages`() { val testCases = listOf( - // @formatter:off - PleaseOpenChannel(Block.RegtestGenesisBlock.hash, ByteVector32("2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db25"), 123_456.sat, 2, 522_000) to Hex.decode("8ca1 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db25 000000000001e240 0002 0007f710"), - PleaseOpenChannel(Block.RegtestGenesisBlock.hash, ByteVector32("2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db25"), 123_456.sat, 2, 522_000, TlvStream(PleaseOpenChannelTlv.GrandParents(listOf()))) to Hex.decode("8ca1 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db25 000000000001e240 0002 0007f710 fd023100"), - PleaseOpenChannel(Block.RegtestGenesisBlock.hash, ByteVector32("2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db25"), 123_456.sat, 2, 522_000, TlvStream(PleaseOpenChannelTlv.GrandParents(listOf(OutPoint(TxHash("d0556c8cc004933f40b9ca5e87e18cb549298fb02d7e64b0c0ee95303485145a"), 5))))) to Hex.decode("8ca1 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db25 000000000001e240 0002 0007f710 fd023128d0556c8cc004933f40b9ca5e87e18cb549298fb02d7e64b0c0ee95303485145a0000000000000005"), - PleaseOpenChannel(Block.RegtestGenesisBlock.hash, ByteVector32("2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db25"), 123_456.sat, 2, 522_000, TlvStream(PleaseOpenChannelTlv.GrandParents(listOf(OutPoint(TxHash("572b045edb5f0e3ff667e914e368273b11a874fae56a735b332b54048b7978c2"), 0), OutPoint(TxHash("cd6ac843158a1c317021de1323cdd2071f0f59744f79b298a8a45fda2dd7989f"), 1105))))) to Hex.decode("8ca1 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db25 000000000001e240 0002 0007f710 fd023150572b045edb5f0e3ff667e914e368273b11a874fae56a735b332b54048b7978c20000000000000000cd6ac843158a1c317021de1323cdd2071f0f59744f79b298a8a45fda2dd7989f0000000000000451"), - // @formatter:on + Pair(PhoenixAndroidLegacyInfo(hasChannels = true), Hex.decode("88cfff")), + Pair(PhoenixAndroidLegacyInfo(hasChannels = false), Hex.decode("88cf00")), ) - testCases.forEach { val decoded = LightningMessage.decode(it.second) assertNotNull(decoded) @@ -796,10 +865,15 @@ class LightningCodecsTestsCommon : LightningTestSuite() { } @Test - fun `encode - decode phoenix-android-legacy-info messages`() { + fun `encode - decode recommended feerates messages`() { + val fundingRange = RecommendedFeeratesTlv.FundingFeerateRange(FeeratePerKw(5000.sat), FeeratePerKw(15_000.sat)) + val commitmentRange = RecommendedFeeratesTlv.CommitmentFeerateRange(FeeratePerKw(253.sat), FeeratePerKw(2_000.sat)) val testCases = listOf( - Pair(PhoenixAndroidLegacyInfo(hasChannels = true), Hex.decode("88cfff")), - Pair(PhoenixAndroidLegacyInfo(hasChannels = false), Hex.decode("88cf00")), + // @formatter:off + RecommendedFeerates(Block.Testnet3GenesisBlock.hash, FeeratePerKw(2500.sat), FeeratePerKw(2500.sat)) to Hex.decode("99f1 43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000 000009c4 000009c4"), + RecommendedFeerates(Block.Testnet3GenesisBlock.hash, FeeratePerKw(5000.sat), FeeratePerKw(253.sat)) to Hex.decode("99f1 43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000 00001388 000000fd"), + RecommendedFeerates(Block.Testnet3GenesisBlock.hash, FeeratePerKw(10_000.sat), FeeratePerKw(1000.sat), TlvStream(fundingRange, commitmentRange)) to Hex.decode("99f1 43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000 00002710 000003e8 01080000138800003a98 0308000000fd000007d0"), + // @formatter:on ) testCases.forEach { val decoded = LightningMessage.decode(it.second) @@ -811,54 +885,31 @@ class LightningCodecsTestsCommon : LightningTestSuite() { } @Test - fun `validate liquidity ads lease`() { - // The following lease has been signed by eclair. - val channelId = randomBytes32() - val remoteNodeId = PublicKey.fromHex("024dd1d24f950df788c124fe855d5a48c632d5fb6e59cf95f7ea6bee2ad47e5bc8") - val fundingScript = ByteVector.fromHex("00202395c9c52c02ca069f1d56a3c6124bf8b152a617328c76e6b31f83ace370c2ff") - val remoteWillFund = ChannelTlv.WillFund( - sig = ByteVector64("a1b9850389d21b49e074f183e6e1e2d0416e47b4c031843f4cf6f02f68e44ebd5f6ad1baee0b49098c517ac1f04fee6c58335e64ed45f5b0e4ce4b8546cbba09"), - fundingWeight = 500, - leaseFeeProportional = 100, - leaseFeeBase = 10.sat, - maxRelayFeeProportional = 250, - maxRelayFeeBase = 2000.msat, - ) - assertEquals(remoteWillFund.leaseRate(0).fees(FeeratePerKw(FeeratePerByte(5.sat)), 500_000.sat, 500_000.sat).total, 5635.sat) - assertEquals(remoteWillFund.leaseRate(0).fees(FeeratePerKw(FeeratePerByte(5.sat)), 500_000.sat, 600_000.sat).total, 5635.sat) - assertEquals(remoteWillFund.leaseRate(0).fees(FeeratePerKw(FeeratePerByte(5.sat)), 500_000.sat, 400_000.sat).total, 4635.sat) - - data class TestCase(val remoteFundingAmount: Satoshi, val feerate: FeeratePerKw, val willFund: ChannelTlv.WillFund?, val failure: ChannelException?) - - val testCases = listOf( - TestCase(500_000.sat, FeeratePerKw(FeeratePerByte(5.sat)), remoteWillFund, failure = null), - TestCase(500_000.sat, FeeratePerKw(FeeratePerByte(5.sat)), willFund = null, failure = MissingLiquidityAds(channelId)), - TestCase(500_000.sat, FeeratePerKw(FeeratePerByte(5.sat)), remoteWillFund.copy(sig = randomBytes64()), failure = InvalidLiquidityAdsSig(channelId)), - TestCase(0.sat, FeeratePerKw(FeeratePerByte(5.sat)), remoteWillFund, failure = InvalidLiquidityAdsAmount(channelId, 0.sat, 500_000.sat)), + fun `decode unknown liquidity ads types`() { + val fundingRate = LiquidityAds.FundingRate(100_000.sat, 500_000.sat, 550, 100, 5_000.sat, 0.sat) + val testCases = mapOf( + // @formatter:off + ByteVector("0001 000186a00007a120022600640000138800000000 0001 01") to LiquidityAds.WillFundRates(listOf(fundingRate), setOf(LiquidityAds.PaymentType.FromChannelBalance)), + ByteVector("0001 000186a00007a120022600640000138800000000 001b 080000000000000000000000000000000008000000000000000001") to LiquidityAds.WillFundRates(listOf(fundingRate), setOf(LiquidityAds.PaymentType.FromChannelBalance, LiquidityAds.PaymentType.Unknown(75), LiquidityAds.PaymentType.Unknown(211))), + // @formatter:on ) testCases.forEach { - val request = LiquidityAds.RequestRemoteFunding(500_000.sat, leaseStart = 820_000, rate = remoteWillFund.leaseRate(leaseDuration = 0)) - val result = request.validateLease(remoteNodeId, channelId, fundingScript, it.remoteFundingAmount, it.feerate, it.willFund) - assertEquals(result.left, it.failure) + val decoded = LiquidityAds.WillFundRates.read(ByteArrayInput(it.key.toByteArray())) + assertEquals(it.value, decoded) } - } @Test fun `encoded node id`() { val testCases = mapOf( - ByteVector.fromHex("00 0d950b0001c80000") to - EncodedNodeId.ShortChannelIdDir(isNode1 = true, ShortChannelId(890123, 456, 0)), - ByteVector.fromHex("01 0c0a14000d800005") to - EncodedNodeId.ShortChannelIdDir(isNode1 = false, ShortChannelId(789012, 3456, 5)), - ByteVector.fromHex("022d3b15cea00ee4a8e710b082bef18f0f3409cc4e7aff41c26eb0a4d3ab20dd73") to - EncodedNodeId.WithPublicKey.Plain(PublicKey.fromHex("022d3b15cea00ee4a8e710b082bef18f0f3409cc4e7aff41c26eb0a4d3ab20dd73")), - ByteVector.fromHex("03ba3c458e3299eb19d2e07ae86453f4290bcdf8689707f0862f35194397c45922") to - EncodedNodeId.WithPublicKey.Plain(PublicKey.fromHex("03ba3c458e3299eb19d2e07ae86453f4290bcdf8689707f0862f35194397c45922")), - ByteVector.fromHex("042d3b15cea00ee4a8e710b082bef18f0f3409cc4e7aff41c26eb0a4d3ab20dd73") to - EncodedNodeId.WithPublicKey.Wallet(PublicKey.fromHex("022d3b15cea00ee4a8e710b082bef18f0f3409cc4e7aff41c26eb0a4d3ab20dd73")), - ByteVector.fromHex("05ba3c458e3299eb19d2e07ae86453f4290bcdf8689707f0862f35194397c45922") to - EncodedNodeId.WithPublicKey.Wallet(PublicKey.fromHex("03ba3c458e3299eb19d2e07ae86453f4290bcdf8689707f0862f35194397c45922")), + // @formatter:off + ByteVector.fromHex("00 0d950b0001c80000") to EncodedNodeId.ShortChannelIdDir(isNode1 = true, ShortChannelId(890123, 456, 0)), + ByteVector.fromHex("01 0c0a14000d800005") to EncodedNodeId.ShortChannelIdDir(isNode1 = false, ShortChannelId(789012, 3456, 5)), + ByteVector.fromHex("022d3b15cea00ee4a8e710b082bef18f0f3409cc4e7aff41c26eb0a4d3ab20dd73") to EncodedNodeId.WithPublicKey.Plain(PublicKey.fromHex("022d3b15cea00ee4a8e710b082bef18f0f3409cc4e7aff41c26eb0a4d3ab20dd73")), + ByteVector.fromHex("03ba3c458e3299eb19d2e07ae86453f4290bcdf8689707f0862f35194397c45922") to EncodedNodeId.WithPublicKey.Plain(PublicKey.fromHex("03ba3c458e3299eb19d2e07ae86453f4290bcdf8689707f0862f35194397c45922")), + ByteVector.fromHex("042d3b15cea00ee4a8e710b082bef18f0f3409cc4e7aff41c26eb0a4d3ab20dd73") to EncodedNodeId.WithPublicKey.Wallet(PublicKey.fromHex("022d3b15cea00ee4a8e710b082bef18f0f3409cc4e7aff41c26eb0a4d3ab20dd73")), + ByteVector.fromHex("05ba3c458e3299eb19d2e07ae86453f4290bcdf8689707f0862f35194397c45922") to EncodedNodeId.WithPublicKey.Wallet(PublicKey.fromHex("03ba3c458e3299eb19d2e07ae86453f4290bcdf8689707f0862f35194397c45922")), + // @formatter:on ) for (testCase in testCases) { diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LiquidityAdsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LiquidityAdsTestsCommon.kt new file mode 100644 index 000000000..69c619612 --- /dev/null +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LiquidityAdsTestsCommon.kt @@ -0,0 +1,57 @@ +package fr.acinq.lightning.wire + +import fr.acinq.bitcoin.* +import fr.acinq.lightning.Lightning.randomBytes32 +import fr.acinq.lightning.Lightning.randomBytes64 +import fr.acinq.lightning.blockchain.fee.FeeratePerByte +import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.channel.ChannelException +import fr.acinq.lightning.channel.InvalidLiquidityAdsAmount +import fr.acinq.lightning.channel.InvalidLiquidityAdsSig +import fr.acinq.lightning.channel.MissingLiquidityAds +import fr.acinq.lightning.tests.utils.LightningTestSuite +import fr.acinq.lightning.utils.sat +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class LiquidityAdsTestsCommon : LightningTestSuite() { + + @Test + fun `validate liquidity ads funding attempt`() { + val nodeKey = PrivateKey.fromHex("57ac961f1b80ebfb610037bf9c96c6333699bde42257919a53974811c34649e3") + assertEquals(PublicKey.fromHex("03ca9b880627d2d4e3b33164f66946349f820d26aa9572fe0e525e534850cbd413"), nodeKey.publicKey()) + + val fundingRate = LiquidityAds.FundingRate(100_000.sat, 1_000_000.sat, 500, 100, 10.sat, 1000.sat) + assertEquals(fundingRate.fees(FeeratePerKw(FeeratePerByte(5.sat)), 500_000.sat, 500_000.sat, isChannelCreation = false).total, 5635.sat) + assertEquals(fundingRate.fees(FeeratePerKw(FeeratePerByte(5.sat)), 500_000.sat, 600_000.sat, isChannelCreation = false).total, 5635.sat) + assertEquals(fundingRate.fees(FeeratePerKw(FeeratePerByte(5.sat)), 500_000.sat, 600_000.sat, isChannelCreation = true).total, 6635.sat) + assertEquals(fundingRate.fees(FeeratePerKw(FeeratePerByte(5.sat)), 500_000.sat, 400_000.sat, isChannelCreation = false).total, 4635.sat) + assertEquals(fundingRate.fees(FeeratePerKw(FeeratePerByte(10.sat)), 500_000.sat, 500_000.sat, isChannelCreation = false).total, 6260.sat) + + val fundingRates = LiquidityAds.WillFundRates(listOf(fundingRate), setOf(LiquidityAds.PaymentType.FromChannelBalance)) + val request = LiquidityAds.RequestFunding.chooseRate(500_000.sat, LiquidityAds.PaymentDetails.FromChannelBalance, fundingRates) + assertNotNull(request) + val fundingScript = ByteVector.fromHex("00202395c9c52c02ca069f1d56a3c6124bf8b152a617328c76e6b31f83ace370c2ff") + val willFund = fundingRates.validateRequest(nodeKey, fundingScript, FeeratePerKw(1000.sat), request, isChannelCreation = true)?.willFund + assertNotNull(willFund) + assertEquals(fundingScript, willFund.fundingScript) + assertEquals(fundingRate, willFund.fundingRate) + assertEquals(ByteVector64.fromValidHex("a53106bd20027b0215480ff0b06b2bf9324bb257c2a0e74c2604ec347493f90d3a975d56a68b21a6cc48d6763d96f70e1d630dd1720cf6b7314d4304050fe265"), willFund.signature) + + data class TestCase(val remoteFundingAmount: Satoshi, val willFund: LiquidityAds.WillFund?, val failure: ChannelException?) + + val channelId = randomBytes32() + val testCases = listOf( + TestCase(500_000.sat, willFund, failure = null), + TestCase(500_000.sat, willFund = null, failure = MissingLiquidityAds(channelId)), + TestCase(500_000.sat, willFund.copy(signature = randomBytes64()), failure = InvalidLiquidityAdsSig(channelId)), + TestCase(0.sat, willFund, failure = InvalidLiquidityAdsAmount(channelId, 0.sat, 500_000.sat)), + ) + testCases.forEach { + val result = request.validateRemoteFunding(nodeKey.publicKey(), channelId, fundingScript, it.remoteFundingAmount, FeeratePerKw(2500.sat), isChannelCreation = true, it.willFund) + assertEquals(it.failure, result.left) + } + } + +} \ No newline at end of file diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/OpenTlvTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/OpenTlvTestsCommon.kt index 61f208fab..6b326064d 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/OpenTlvTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/OpenTlvTestsCommon.kt @@ -1,14 +1,10 @@ package fr.acinq.lightning.wire -import fr.acinq.bitcoin.ByteVector32 import fr.acinq.lightning.Feature import fr.acinq.lightning.FeatureSupport import fr.acinq.lightning.Features import fr.acinq.lightning.channel.ChannelType -import fr.acinq.lightning.channel.Origin import fr.acinq.lightning.tests.utils.LightningTestSuite -import fr.acinq.lightning.utils.msat -import fr.acinq.lightning.utils.sat import fr.acinq.secp256k1.Hex import kotlin.test.* @@ -59,31 +55,4 @@ class OpenTlvTestsCommon : LightningTestSuite() { } } - @Test - fun `channel origin TLV`() { - val testCases = listOf( - Pair( - Origin.PayToOpenOrigin(ByteVector32.fromValidHex("187bf923f7f11ef732b73c417eb5a57cd4667b20a6f130ff505cd7ad3ab87281"), 1_000_000.msat, 1234.sat, 200_000_000.msat), - Hex.decode("fe47000005 3a 0001 187bf923f7f11ef732b73c417eb5a57cd4667b20a6f130ff505cd7ad3ab87281 00000000000004d2 00000000000f4240 000000000bebc200") - ), - Pair( - Origin.PleaseOpenChannelOrigin(ByteVector32("2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db25"), 1_234_567.msat, 321.sat, 1_111_000.msat), - Hex.decode("fe47000005 3a 0004 2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db25 0000000000000141 000000000012d687 000000000010f3d8") - ) - ) - - @Suppress("UNCHECKED_CAST") - val readers = mapOf(ChannelTlv.OriginTlv.tag to ChannelTlv.OriginTlv.Companion as TlvValueReader) - val tlvStreamSerializer = TlvStreamSerializer(false, readers) - - testCases.forEach { - val decoded = tlvStreamSerializer.read(it.second) - val encoded = tlvStreamSerializer.write(decoded) - assertContentEquals(it.second, encoded) - val channelOrigin = decoded.get()?.origin - assertNotNull(channelOrigin) - assertEquals(it.first, channelOrigin) - } - } - } diff --git a/src/commonTest/resources/nonreg/v2/Closing_0ba41d17/data.json b/src/commonTest/resources/nonreg/v2/Closing_0ba41d17/data.json index fda5a3fe7..4a95ea4b8 100644 --- a/src/commonTest/resources/nonreg/v2/Closing_0ba41d17/data.json +++ b/src/commonTest/resources/nonreg/v2/Closing_0ba41d17/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/Closing_0ed6ff68/data.json b/src/commonTest/resources/nonreg/v2/Closing_0ed6ff68/data.json index 2029cf437..5d3bbb100 100644 --- a/src/commonTest/resources/nonreg/v2/Closing_0ed6ff68/data.json +++ b/src/commonTest/resources/nonreg/v2/Closing_0ed6ff68/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 1000, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": false, + "isChannelOpener": false, + "paysCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/Closing_0efffae3/data.json b/src/commonTest/resources/nonreg/v2/Closing_0efffae3/data.json index d730794a6..88f4ea5e0 100644 --- a/src/commonTest/resources/nonreg/v2/Closing_0efffae3/data.json +++ b/src/commonTest/resources/nonreg/v2/Closing_0efffae3/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/Closing_2fd2a3fa/data.json b/src/commonTest/resources/nonreg/v2/Closing_2fd2a3fa/data.json index 59ec43e0a..26f1cbbbd 100644 --- a/src/commonTest/resources/nonreg/v2/Closing_2fd2a3fa/data.json +++ b/src/commonTest/resources/nonreg/v2/Closing_2fd2a3fa/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/Closing_3bb07fb6/data.json b/src/commonTest/resources/nonreg/v2/Closing_3bb07fb6/data.json index de5ee79f7..e45f340f5 100644 --- a/src/commonTest/resources/nonreg/v2/Closing_3bb07fb6/data.json +++ b/src/commonTest/resources/nonreg/v2/Closing_3bb07fb6/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/Closing_8f1a524e/data.json b/src/commonTest/resources/nonreg/v2/Closing_8f1a524e/data.json index 1f38a6dff..6c47ac089 100644 --- a/src/commonTest/resources/nonreg/v2/Closing_8f1a524e/data.json +++ b/src/commonTest/resources/nonreg/v2/Closing_8f1a524e/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/Closing_ef682e2e/data.json b/src/commonTest/resources/nonreg/v2/Closing_ef682e2e/data.json index 004f8362b..c4afcbc46 100644 --- a/src/commonTest/resources/nonreg/v2/Closing_ef682e2e/data.json +++ b/src/commonTest/resources/nonreg/v2/Closing_ef682e2e/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -79,7 +80,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/Negotiating_c8d15808/data.json b/src/commonTest/resources/nonreg/v2/Negotiating_c8d15808/data.json index 420a85dd6..d11f3451c 100644 --- a/src/commonTest/resources/nonreg/v2/Negotiating_c8d15808/data.json +++ b/src/commonTest/resources/nonreg/v2/Negotiating_c8d15808/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/Negotiating_d9b4cd96/data.json b/src/commonTest/resources/nonreg/v2/Negotiating_d9b4cd96/data.json index 845c2fbed..93b457720 100644 --- a/src/commonTest/resources/nonreg/v2/Negotiating_d9b4cd96/data.json +++ b/src/commonTest/resources/nonreg/v2/Negotiating_d9b4cd96/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 1000, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": false, + "isChannelOpener": false, + "paysCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/Negotiating_ee10091c/data.json b/src/commonTest/resources/nonreg/v2/Negotiating_ee10091c/data.json index 7c1dd0298..f6569bd10 100644 --- a/src/commonTest/resources/nonreg/v2/Negotiating_ee10091c/data.json +++ b/src/commonTest/resources/nonreg/v2/Negotiating_ee10091c/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 1000, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": false, + "isChannelOpener": false, + "paysCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/Negotiating_f52b19b8/data.json b/src/commonTest/resources/nonreg/v2/Negotiating_f52b19b8/data.json index 7e0b6714f..80d2aeba6 100644 --- a/src/commonTest/resources/nonreg/v2/Negotiating_f52b19b8/data.json +++ b/src/commonTest/resources/nonreg/v2/Negotiating_f52b19b8/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/Normal_748a735b/data.json b/src/commonTest/resources/nonreg/v2/Normal_748a735b/data.json index 243ee7a39..602f95563 100644 --- a/src/commonTest/resources/nonreg/v2/Normal_748a735b/data.json +++ b/src/commonTest/resources/nonreg/v2/Normal_748a735b/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -79,7 +80,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { @@ -171,6 +175,5 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None", - "liquidityLeases": [] + "spliceStatus": "None" } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v2/Normal_e2253ddd/data.json b/src/commonTest/resources/nonreg/v2/Normal_e2253ddd/data.json index 772cc5a10..e72eeab7f 100644 --- a/src/commonTest/resources/nonreg/v2/Normal_e2253ddd/data.json +++ b/src/commonTest/resources/nonreg/v2/Normal_e2253ddd/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { @@ -352,6 +356,5 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None", - "liquidityLeases": [] + "spliceStatus": "None" } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v2/Normal_ff248f8d/data.json b/src/commonTest/resources/nonreg/v2/Normal_ff248f8d/data.json index 747293f92..c11a9729d 100644 --- a/src/commonTest/resources/nonreg/v2/Normal_ff248f8d/data.json +++ b/src/commonTest/resources/nonreg/v2/Normal_ff248f8d/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 1000, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": false, + "isChannelOpener": false, + "paysCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { @@ -184,6 +188,5 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None", - "liquidityLeases": [] + "spliceStatus": "None" } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v2/Normal_ff4a71b6/data.json b/src/commonTest/resources/nonreg/v2/Normal_ff4a71b6/data.json index b9f21db00..632438fee 100644 --- a/src/commonTest/resources/nonreg/v2/Normal_ff4a71b6/data.json +++ b/src/commonTest/resources/nonreg/v2/Normal_ff4a71b6/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { @@ -360,6 +364,5 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None", - "liquidityLeases": [] + "spliceStatus": "None" } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v2/Normal_ffd9f5db/data.json b/src/commonTest/resources/nonreg/v2/Normal_ffd9f5db/data.json index 5778445a9..8ab396eb4 100644 --- a/src/commonTest/resources/nonreg/v2/Normal_ffd9f5db/data.json +++ b/src/commonTest/resources/nonreg/v2/Normal_ffd9f5db/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { @@ -201,6 +205,5 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None", - "liquidityLeases": [] + "spliceStatus": "None" } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v2/ShuttingDown_c321b947/data.json b/src/commonTest/resources/nonreg/v2/ShuttingDown_c321b947/data.json index 571d29d77..7af3c7ae3 100644 --- a/src/commonTest/resources/nonreg/v2/ShuttingDown_c321b947/data.json +++ b/src/commonTest/resources/nonreg/v2/ShuttingDown_c321b947/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/ShuttingDown_f89ecd50/data.json b/src/commonTest/resources/nonreg/v2/ShuttingDown_f89ecd50/data.json index 20cb223d8..679424d87 100644 --- a/src/commonTest/resources/nonreg/v2/ShuttingDown_f89ecd50/data.json +++ b/src/commonTest/resources/nonreg/v2/ShuttingDown_f89ecd50/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 1000, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": false, + "isChannelOpener": false, + "paysCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/WaitForFundingConfirmed_f7421b49/data.json b/src/commonTest/resources/nonreg/v2/WaitForFundingConfirmed_f7421b49/data.json index f3d8d48e9..53698523e 100644 --- a/src/commonTest/resources/nonreg/v2/WaitForFundingConfirmed_f7421b49/data.json +++ b/src/commonTest/resources/nonreg/v2/WaitForFundingConfirmed_f7421b49/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 1000, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": false, + "isChannelOpener": false, + "paysCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/WaitForFundingConfirmed_fe3c5978/data.json b/src/commonTest/resources/nonreg/v2/WaitForFundingConfirmed_fe3c5978/data.json index 47d05ee46..a86556ee2 100644 --- a/src/commonTest/resources/nonreg/v2/WaitForFundingConfirmed_fe3c5978/data.json +++ b/src/commonTest/resources/nonreg/v2/WaitForFundingConfirmed_fe3c5978/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 1000, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": false, + "isChannelOpener": false, + "paysCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -81,7 +82,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/WaitForFundingConfirmed_ff74dd33/data.json b/src/commonTest/resources/nonreg/v2/WaitForFundingConfirmed_ff74dd33/data.json index a06eee4db..bb25ba1b6 100644 --- a/src/commonTest/resources/nonreg/v2/WaitForFundingConfirmed_ff74dd33/data.json +++ b/src/commonTest/resources/nonreg/v2/WaitForFundingConfirmed_ff74dd33/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/WaitForFundingLocked_f3437082/data.json b/src/commonTest/resources/nonreg/v2/WaitForFundingLocked_f3437082/data.json index 37223cd68..0dee1662d 100644 --- a/src/commonTest/resources/nonreg/v2/WaitForFundingLocked_f3437082/data.json +++ b/src/commonTest/resources/nonreg/v2/WaitForFundingLocked_f3437082/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 1000, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": false, + "isChannelOpener": false, + "paysCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/WaitForRemotePublishFutureCommitment_ae47fde9/data.json b/src/commonTest/resources/nonreg/v2/WaitForRemotePublishFutureCommitment_ae47fde9/data.json index be47e4fd4..25fb5c97f 100644 --- a/src/commonTest/resources/nonreg/v2/WaitForRemotePublishFutureCommitment_ae47fde9/data.json +++ b/src/commonTest/resources/nonreg/v2/WaitForRemotePublishFutureCommitment_ae47fde9/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -79,7 +80,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/WaitForRemotePublishFutureCommitment_d803549f/data.json b/src/commonTest/resources/nonreg/v2/WaitForRemotePublishFutureCommitment_d803549f/data.json index 52c053a9f..b2209f2a9 100644 --- a/src/commonTest/resources/nonreg/v2/WaitForRemotePublishFutureCommitment_d803549f/data.json +++ b/src/commonTest/resources/nonreg/v2/WaitForRemotePublishFutureCommitment_d803549f/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -79,7 +80,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/Closing_029bf8f3/data.json b/src/commonTest/resources/nonreg/v3/Closing_029bf8f3/data.json index e8a0d306e..2d57e4eeb 100644 --- a/src/commonTest/resources/nonreg/v3/Closing_029bf8f3/data.json +++ b/src/commonTest/resources/nonreg/v3/Closing_029bf8f3/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -78,7 +79,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/Closing_0ba41d17/data.json b/src/commonTest/resources/nonreg/v3/Closing_0ba41d17/data.json index da2e910d9..c9c73f51c 100644 --- a/src/commonTest/resources/nonreg/v3/Closing_0ba41d17/data.json +++ b/src/commonTest/resources/nonreg/v3/Closing_0ba41d17/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -78,7 +79,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/Closing_0ed6ff68/data.json b/src/commonTest/resources/nonreg/v3/Closing_0ed6ff68/data.json index 86a06d814..b44ecb4f7 100644 --- a/src/commonTest/resources/nonreg/v3/Closing_0ed6ff68/data.json +++ b/src/commonTest/resources/nonreg/v3/Closing_0ed6ff68/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 1000, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": false, + "isChannelOpener": false, + "paysCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -78,7 +79,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/Closing_0efffae3/data.json b/src/commonTest/resources/nonreg/v3/Closing_0efffae3/data.json index 722aec142..9fe8fd38d 100644 --- a/src/commonTest/resources/nonreg/v3/Closing_0efffae3/data.json +++ b/src/commonTest/resources/nonreg/v3/Closing_0efffae3/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -78,7 +79,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/Closing_ebbd24bc/data.json b/src/commonTest/resources/nonreg/v3/Closing_ebbd24bc/data.json index f32dce63d..2ff5cbc8c 100644 --- a/src/commonTest/resources/nonreg/v3/Closing_ebbd24bc/data.json +++ b/src/commonTest/resources/nonreg/v3/Closing_ebbd24bc/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -78,7 +79,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/Closing_f137669f/data.json b/src/commonTest/resources/nonreg/v3/Closing_f137669f/data.json index 70fa4c89b..9b3aa9aaa 100644 --- a/src/commonTest/resources/nonreg/v3/Closing_f137669f/data.json +++ b/src/commonTest/resources/nonreg/v3/Closing_f137669f/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -77,7 +78,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/Negotiating_da44c6e2/data.json b/src/commonTest/resources/nonreg/v3/Negotiating_da44c6e2/data.json index 583533780..6b9cdd587 100644 --- a/src/commonTest/resources/nonreg/v3/Negotiating_da44c6e2/data.json +++ b/src/commonTest/resources/nonreg/v3/Negotiating_da44c6e2/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -78,7 +79,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/Negotiating_dabbed55/data.json b/src/commonTest/resources/nonreg/v3/Negotiating_dabbed55/data.json index 01f25fdfc..72e70b027 100644 --- a/src/commonTest/resources/nonreg/v3/Negotiating_dabbed55/data.json +++ b/src/commonTest/resources/nonreg/v3/Negotiating_dabbed55/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -78,7 +79,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/Negotiating_fadb50c1/data.json b/src/commonTest/resources/nonreg/v3/Negotiating_fadb50c1/data.json index 2faf3de86..cf146d3bc 100644 --- a/src/commonTest/resources/nonreg/v3/Negotiating_fadb50c1/data.json +++ b/src/commonTest/resources/nonreg/v3/Negotiating_fadb50c1/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 1000, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": false, + "isChannelOpener": false, + "paysCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -80,7 +81,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/Normal_fd10d3cc/data.json b/src/commonTest/resources/nonreg/v3/Normal_fd10d3cc/data.json index 27c4b845d..512ac704c 100644 --- a/src/commonTest/resources/nonreg/v3/Normal_fd10d3cc/data.json +++ b/src/commonTest/resources/nonreg/v3/Normal_fd10d3cc/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -78,7 +79,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { @@ -248,6 +252,5 @@ }, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None", - "liquidityLeases": [] + "spliceStatus": "None" } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v3/Normal_fe897b64/data.json b/src/commonTest/resources/nonreg/v3/Normal_fe897b64/data.json index 43304ecba..2b92004d7 100644 --- a/src/commonTest/resources/nonreg/v3/Normal_fe897b64/data.json +++ b/src/commonTest/resources/nonreg/v3/Normal_fe897b64/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -78,7 +79,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { @@ -216,6 +220,5 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None", - "liquidityLeases": [] + "spliceStatus": "None" } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v3/Normal_ff248f8d/data.json b/src/commonTest/resources/nonreg/v3/Normal_ff248f8d/data.json index 762b28ffd..e1ee17f9b 100644 --- a/src/commonTest/resources/nonreg/v3/Normal_ff248f8d/data.json +++ b/src/commonTest/resources/nonreg/v3/Normal_ff248f8d/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 1000, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": false, + "isChannelOpener": false, + "paysCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -78,7 +79,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { @@ -182,6 +186,5 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None", - "liquidityLeases": [] + "spliceStatus": "None" } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v3/Normal_ff4a71b6/data.json b/src/commonTest/resources/nonreg/v3/Normal_ff4a71b6/data.json index cfbaf4cae..c544a46fc 100644 --- a/src/commonTest/resources/nonreg/v3/Normal_ff4a71b6/data.json +++ b/src/commonTest/resources/nonreg/v3/Normal_ff4a71b6/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -78,7 +79,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { @@ -358,6 +362,5 @@ "localShutdown": null, "remoteShutdown": null, "closingFeerates": null, - "spliceStatus": "None", - "liquidityLeases": [] + "spliceStatus": "None" } \ No newline at end of file diff --git a/src/commonTest/resources/nonreg/v3/ShuttingDown_ef41a1a5/data.json b/src/commonTest/resources/nonreg/v3/ShuttingDown_ef41a1a5/data.json index fc2ac892e..e9cc3eb21 100644 --- a/src/commonTest/resources/nonreg/v3/ShuttingDown_ef41a1a5/data.json +++ b/src/commonTest/resources/nonreg/v3/ShuttingDown_ef41a1a5/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 1000, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": false, + "isChannelOpener": false, + "paysCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -78,7 +79,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/ShuttingDown_ef7081a1/data.json b/src/commonTest/resources/nonreg/v3/ShuttingDown_ef7081a1/data.json index ff270a33b..83e5f8439 100644 --- a/src/commonTest/resources/nonreg/v3/ShuttingDown_ef7081a1/data.json +++ b/src/commonTest/resources/nonreg/v3/ShuttingDown_ef7081a1/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -78,7 +79,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/WaitForFundingConfirmed_fe3c5978/data.json b/src/commonTest/resources/nonreg/v3/WaitForFundingConfirmed_fe3c5978/data.json index 8a6864d8a..771038e28 100644 --- a/src/commonTest/resources/nonreg/v3/WaitForFundingConfirmed_fe3c5978/data.json +++ b/src/commonTest/resources/nonreg/v3/WaitForFundingConfirmed_fe3c5978/data.json @@ -21,7 +21,8 @@ "htlcMinimum": 1000, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": false, + "isChannelOpener": false, + "paysCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -81,7 +82,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/WaitForFundingConfirmed_ff74dd33/data.json b/src/commonTest/resources/nonreg/v3/WaitForFundingConfirmed_ff74dd33/data.json index 6128d7bf5..e9b8d4857 100644 --- a/src/commonTest/resources/nonreg/v3/WaitForFundingConfirmed_ff74dd33/data.json +++ b/src/commonTest/resources/nonreg/v3/WaitForFundingConfirmed_ff74dd33/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -78,7 +79,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/WaitForFundingLocked_f3437082/data.json b/src/commonTest/resources/nonreg/v3/WaitForFundingLocked_f3437082/data.json index 8cf2c2376..6663b2af0 100644 --- a/src/commonTest/resources/nonreg/v3/WaitForFundingLocked_f3437082/data.json +++ b/src/commonTest/resources/nonreg/v3/WaitForFundingLocked_f3437082/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 1000, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": false, + "isChannelOpener": false, + "paysCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -78,7 +79,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/WaitForRemotePublishFutureCommitment_ae47fde9/data.json b/src/commonTest/resources/nonreg/v3/WaitForRemotePublishFutureCommitment_ae47fde9/data.json index 2c5c4d810..8b6b7b1b6 100644 --- a/src/commonTest/resources/nonreg/v3/WaitForRemotePublishFutureCommitment_ae47fde9/data.json +++ b/src/commonTest/resources/nonreg/v3/WaitForRemotePublishFutureCommitment_ae47fde9/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -77,7 +78,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/WaitForRemotePublishFutureCommitment_d803549f/data.json b/src/commonTest/resources/nonreg/v3/WaitForRemotePublishFutureCommitment_d803549f/data.json index 20291fb84..5162faaed 100644 --- a/src/commonTest/resources/nonreg/v3/WaitForRemotePublishFutureCommitment_d803549f/data.json +++ b/src/commonTest/resources/nonreg/v3/WaitForRemotePublishFutureCommitment_d803549f/data.json @@ -19,7 +19,8 @@ "htlcMinimum": 0, "toSelfDelay": 144, "maxAcceptedHtlcs": 100, - "isInitiator": true, + "isChannelOpener": true, + "paysCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -77,7 +78,10 @@ "unknown": [] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": {