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": {