From a7a0e70d086498440e598cd13dfaa38006aa7619 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 1 Mar 2024 17:53:30 +0100 Subject: [PATCH 1/6] Remove `please_open_channel` It is usually the wallet that decides that it needs a channel, but we want the LSP to pay the commit fees to allow the wallet user to empty its wallet over lightning. We previously used a `please_open_channel` message that was sent by the wallet to the LSP, but it doesn't work well with liquidity ads. We remove that message and instead send `open_channel` from the wallet but with a custom channel flag that tells the LSP that they should be paying the commit fees. This only works if the LSP adds funds on their side of the channel, so we couple that with liquidity ads to request funds from the LSP. --- .../kotlin/fr/acinq/lightning/NodeEvents.kt | 7 - .../kotlin/fr/acinq/lightning/NodeParams.kt | 2 +- .../blockchain/electrum/SwapInManager.kt | 17 +- .../acinq/lightning/channel/ChannelAction.kt | 2 +- .../acinq/lightning/channel/ChannelCommand.kt | 15 +- .../fr/acinq/lightning/channel/ChannelData.kt | 43 ++- .../fr/acinq/lightning/channel/Commitments.kt | 34 +-- .../fr/acinq/lightning/channel/Helpers.kt | 12 +- .../acinq/lightning/channel/InteractiveTx.kt | 6 +- .../acinq/lightning/channel/states/Channel.kt | 4 +- .../lightning/channel/states/Negotiating.kt | 6 +- .../acinq/lightning/channel/states/Normal.kt | 21 +- .../lightning/channel/states/ShuttingDown.kt | 4 +- .../acinq/lightning/channel/states/Syncing.kt | 2 +- .../channel/states/WaitForAcceptChannel.kt | 5 +- .../channel/states/WaitForFundingConfirmed.kt | 6 +- .../channel/states/WaitForFundingCreated.kt | 2 +- .../channel/states/WaitForFundingSigned.kt | 5 +- .../lightning/channel/states/WaitForInit.kt | 9 +- .../channel/states/WaitForOpenChannel.kt | 2 +- .../kotlin/fr/acinq/lightning/io/Peer.kt | 280 +++++++++--------- .../acinq/lightning/json/JsonSerializers.kt | 4 + .../payment/IncomingPaymentHandler.kt | 5 +- .../lightning/payment/LiquidityPolicy.kt | 21 +- .../serialization/v2/ChannelState.kt | 6 +- .../serialization/v3/ChannelState.kt | 6 +- .../serialization/v4/Deserialization.kt | 100 ++++--- .../serialization/v4/Serialization.kt | 28 +- .../lightning/transactions/Transactions.kt | 14 +- .../fr/acinq/lightning/wire/ChannelTlv.kt | 108 ------- .../acinq/lightning/wire/LightningMessages.kt | 128 ++++---- .../fr/acinq/lightning/wire/LiquidityAds.kt | 2 +- .../electrum/SwapInManagerTestsCommon.kt | 40 +-- .../channel/CommitmentsTestsCommon.kt | 19 +- .../fr/acinq/lightning/channel/TestsHelper.kt | 6 +- .../channel/states/QuiescenceTestsCommon.kt | 3 +- .../channel/states/SpliceTestsCommon.kt | 18 +- .../states/WaitForFundingSignedTestsCommon.kt | 49 ++- .../fr/acinq/lightning/io/peer/PeerTest.kt | 133 +++------ .../IncomingPaymentHandlerTestsCommon.kt | 7 +- .../payment/LiquidityPolicyTestsCommon.kt | 16 +- .../fr/acinq/lightning/tests/TestConstants.kt | 14 +- .../acinq/lightning/tests/io/peer/builders.kt | 18 +- .../transactions/AnchorOutputsTestsCommon.kt | 3 +- .../transactions/TransactionsTestsCommon.kt | 10 +- .../wire/LightningCodecsTestsCommon.kt | 44 ++- .../lightning/wire/OpenTlvTestsCommon.kt | 31 -- .../nonreg/v2/Closing_0ba41d17/data.json | 8 +- .../nonreg/v2/Closing_0ed6ff68/data.json | 8 +- .../nonreg/v2/Closing_0efffae3/data.json | 8 +- .../nonreg/v2/Closing_2fd2a3fa/data.json | 8 +- .../nonreg/v2/Closing_3bb07fb6/data.json | 8 +- .../nonreg/v2/Closing_8f1a524e/data.json | 8 +- .../nonreg/v2/Closing_ef682e2e/data.json | 8 +- .../nonreg/v2/Negotiating_c8d15808/data.json | 8 +- .../nonreg/v2/Negotiating_d9b4cd96/data.json | 8 +- .../nonreg/v2/Negotiating_ee10091c/data.json | 8 +- .../nonreg/v2/Negotiating_f52b19b8/data.json | 8 +- .../nonreg/v2/Normal_748a735b/data.json | 8 +- .../nonreg/v2/Normal_e2253ddd/data.json | 8 +- .../nonreg/v2/Normal_ff248f8d/data.json | 8 +- .../nonreg/v2/Normal_ff4a71b6/data.json | 8 +- .../nonreg/v2/Normal_ffd9f5db/data.json | 8 +- .../nonreg/v2/ShuttingDown_c321b947/data.json | 8 +- .../nonreg/v2/ShuttingDown_f89ecd50/data.json | 8 +- .../data.json | 8 +- .../data.json | 8 +- .../data.json | 8 +- .../WaitForFundingLocked_f3437082/data.json | 8 +- .../data.json | 8 +- .../data.json | 8 +- .../nonreg/v3/Closing_029bf8f3/data.json | 8 +- .../nonreg/v3/Closing_0ba41d17/data.json | 8 +- .../nonreg/v3/Closing_0ed6ff68/data.json | 8 +- .../nonreg/v3/Closing_0efffae3/data.json | 8 +- .../nonreg/v3/Closing_ebbd24bc/data.json | 8 +- .../nonreg/v3/Closing_f137669f/data.json | 8 +- .../nonreg/v3/Negotiating_da44c6e2/data.json | 8 +- .../nonreg/v3/Negotiating_dabbed55/data.json | 8 +- .../nonreg/v3/Negotiating_fadb50c1/data.json | 8 +- .../nonreg/v3/Normal_fd10d3cc/data.json | 8 +- .../nonreg/v3/Normal_fe897b64/data.json | 8 +- .../nonreg/v3/Normal_ff248f8d/data.json | 8 +- .../nonreg/v3/Normal_ff4a71b6/data.json | 8 +- .../nonreg/v3/ShuttingDown_ef41a1a5/data.json | 8 +- .../nonreg/v3/ShuttingDown_ef7081a1/data.json | 8 +- .../data.json | 8 +- .../data.json | 8 +- .../WaitForFundingLocked_f3437082/data.json | 8 +- .../data.json | 8 +- .../data.json | 8 +- 91 files changed, 847 insertions(+), 822 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt index 76991cda7..cd190fa0e 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt @@ -9,17 +9,10 @@ import fr.acinq.lightning.channel.states.Normal import fr.acinq.lightning.channel.states.WaitForFundingCreated import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.utils.sum -import fr.acinq.lightning.wire.Node -import fr.acinq.lightning.wire.PleaseOpenChannel import kotlinx.coroutines.CompletableDeferred sealed interface 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 -} - sealed interface ChannelEvents : NodeEvents { data class Creating(val state: WaitForFundingCreated) : ChannelEvents data class Created(val state: ChannelStateWithCommitments) : ChannelEvents diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index f82ce2fab..24232a747 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -219,6 +219,6 @@ 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)) ) } 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 4e9bd5f98..f42812994 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.OpenOrSpliceChannel 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,19 +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): OpenOrSpliceChannel? = 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.all.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) + OpenOrSpliceChannel(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..f30f448b1 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt @@ -79,7 +79,7 @@ sealed class ChannelAction { 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 ViaSpliceIn(val amount: 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() { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt index 0a3f7643c..a8ed4a5e9 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.RequestRemoteFunding?, + val channelOrigin: Origin?, ) : Init() { fun temporaryChannelId(keyManager: KeyManager): ByteVector32 = keyManager.channelKeys(localParams.fundingKeyPath).temporaryChannelId } @@ -83,9 +84,9 @@ sealed class ChannelCommand { sealed class Commitment : ChannelCommand() { object Sign : Commitment(), ForbiddenDuringSplice data class UpdateFee(val feerate: FeeratePerKw, val commit: Boolean = false) : Commitment(), ForbiddenDuringSplice, ForbiddenDuringQuiescence - object CheckHtlcTimeout : Commitment() + 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.RequestRemoteFunding?, 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() @@ -93,12 +94,6 @@ sealed class ChannelCommand { data class SpliceOut(val amount: Satoshi, val scriptPubKey: ByteVector) } - /** - * @param miningFee on-chain fee that will be paid for the splice transaction. - * @param serviceFee service-fee that will be paid to the remote node for a service they provide with the splice transaction. - */ - data class Fees(val miningFee: Satoshi, val serviceFee: MilliSatoshi) - sealed class Response { /** * This response doesn't fully guarantee that the splice will confirm, because our peer may potentially double-spend diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt index 248ba9eab..1e375fd7a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt @@ -9,7 +9,7 @@ import fr.acinq.lightning.channel.Helpers.publishIfNeeded import fr.acinq.lightning.channel.Helpers.watchConfirmedIfNeeded import fr.acinq.lightning.channel.Helpers.watchSpentIfNeeded import fr.acinq.lightning.crypto.KeyManager -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.LoggingContext import fr.acinq.lightning.transactions.Scripts import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.* import fr.acinq.lightning.wire.ClosingSigned @@ -350,23 +350,29 @@ data class LocalParams( val htlcMinimum: MilliSatoshi, val toSelfDelay: CltvExpiryDelta, val maxAcceptedHtlcs: Int, - val isInitiator: Boolean, + val isChannelOpener: Boolean, + val payCommitTxFees: 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, + payCommitTxFees = 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 payClosingFees: Boolean = payCommitTxFees + fun channelKeys(keyManager: KeyManager) = keyManager.channelKeys(fundingKeyPath) } @@ -384,20 +390,29 @@ 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) -/** Reason for creating a new channel or a splice. */ +/** + * @param miningFee fee paid to miners for the underlying on-chain transaction. + * @param serviceFee fee paid to our peer for any service provided with the on-chain transaction. + */ +data class TransactionFees(val miningFee: Satoshi, val serviceFee: Satoshi) { + val total: Satoshi = miningFee + serviceFee +} + +/** 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() + abstract val fees: TransactionFees + + data class OffChainPayment(val paymentHash: ByteVector32, override val amount: MilliSatoshi, override val fees: TransactionFees) : Origin() + data class OnChainWallet(val inputs: Set, override val amount: MilliSatoshi, override val fees: TransactionFees) : Origin() } // @formatter:on diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt index c1b728c9b..b95167cee 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.payCommitTxFees) { // 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.payCommitTxFees) { // 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.payCommitTxFees) 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.payCommitTxFees) 0.msat else fees.toMilliSatoshi()) if (missingForSender < 0.msat) { - val actualFees = if (params.localParams.isInitiator) fees else 0.sat + val actualFees = if (params.localParams.payCommitTxFees) 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.payCommitTxFees) { // 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.payCommitTxFees) 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.payCommitTxFees) fees else 0.sat).toMilliSatoshi() if (missingForSender < 0.sat) { - val actualFees = if (params.localParams.isInitiator) 0.sat else fees + val actualFees = if (params.localParams.payCommitTxFees) 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.payCommitTxFees) { 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.payCommitTxFees) 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.payCommitTxFees) 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.payCommitTxFees, 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.payCommitTxFees, 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 c37a6a3e0..793fb1115 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt @@ -309,8 +309,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.payCommitTxFees) { + // 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. @@ -375,7 +375,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.payClosingFees, 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) } @@ -407,7 +407,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.payClosingFees, 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) @@ -561,7 +561,7 @@ object Helpers { val outputs = makeCommitTxOutputs( commitment.remoteFundingPubkey, channelKeys.fundingPubKey(commitment.fundingTxIndex), - !localParams.isInitiator, + !localParams.payCommitTxFees, remoteParams.dustLimit, remoteRevocationPubkey, localParams.toSelfDelay, @@ -675,7 +675,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 5074949ee..97a374cac 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt @@ -339,7 +339,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) { @@ -1178,10 +1178,10 @@ sealed class SpliceStatus { val localPushAmount: MilliSatoshi, val remotePushAmount: MilliSatoshi, val liquidityLease: LiquidityAds.Lease?, - val origins: List + 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 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 b0f57c89b..8d7524731 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt @@ -333,7 +333,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 payCommitTxFees: Boolean get() = commitments.params.localParams.payCommitTxFees + val payClosingFees: Boolean get() = commitments.params.localParams.payClosingFees 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/Negotiating.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Negotiating.kt index b50299edb..508ab536c 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(!payClosingFees || !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 && !payClosingFees -> { + // 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 be6fb9ec9..cf9fae06f 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt @@ -242,7 +242,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 (payClosingFees) { val (closingTx, closingSigned) = Helpers.Closing.makeFirstClosingTx( channelKeys(), commitments1.latest, @@ -313,7 +313,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() && payClosingFees -> { val (closingTx, closingSigned) = Helpers.Closing.makeFirstClosingTx( channelKeys(), commitments1.latest, @@ -378,7 +378,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 +389,7 @@ data class Normal( targetFeerate = spliceStatus.command.feerate ) val commitTxFees = when { - commitments.params.localParams.isInitiator -> Transactions.commitTxFee(commitments.params.remoteParams.dustLimit, parentCommitment.remoteCommit.spec) + payCommitTxFees -> 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)) { @@ -493,7 +493,7 @@ data class Normal( localPushAmount = 0.msat, remotePushAmount = cmd.message.pushAmount, liquidityLease = null, - origins = cmd.message.origins + origins = listOf() ) ) Pair(nextState, listOf(ChannelAction.Message.Send(spliceAck))) @@ -566,7 +566,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 -> { @@ -577,7 +578,7 @@ data class Normal( localPushAmount = spliceStatus.spliceInit.pushAmount, remotePushAmount = cmd.message.pushAmount, liquidityLease = liquidityLease.value, - origins = spliceStatus.spliceInit.origins + origins = spliceStatus.command.origins, ) ) Pair(nextState, listOf(ChannelAction.Message.Send(interactiveTxAction.msg))) @@ -842,7 +843,7 @@ data class Normal( } private fun ChannelContext.sendSpliceTxSigs( - origins: List, + origins: List, action: InteractiveTxSigningSessionAction.SendTxSigs, liquidityLease: LiquidityAds.Lease?, remoteChannelData: EncryptedChannelData @@ -862,8 +863,8 @@ data class Normal( addAll(origins.map { origin -> ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn( amount = origin.amount, - serviceFee = origin.serviceFee, - miningFee = origin.miningFee, + 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 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 b9bb26da7..f6882d704 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() && payClosingFees -> { 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() && payClosingFees -> { 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 c95250824..8c35f9a04 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt @@ -238,7 +238,7 @@ data class Syncing(val state: PersistedChannelState, val channelReestablishSent: // BOLT 2: A node if it has sent a previous shutdown MUST retransmit shutdown. // 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 - state is Negotiating && state.commitments.params.localParams.isInitiator -> { + state is Negotiating && state.payClosingFees -> { // 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 06f4383b1..2ad4f6b41 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt @@ -21,7 +21,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 @@ -74,7 +75,7 @@ data class WaitForAcceptChannel( lastSent.channelFlags, init.channelConfig, channelFeatures, - null + channelOrigin ) val actions = listOf( ChannelAction.ChannelId.IdAssigned(staticParams.remoteNodeId, temporaryChannelId, channelId), 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 22876b53d..b83de569e 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, 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 45f093b15..831a140e2 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreated.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreated.kt @@ -38,7 +38,7 @@ 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 channelOrigin: Origin? 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 2ed3c3ed7..c4eebe328 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSigned.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSigned.kt @@ -12,6 +12,7 @@ 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 @@ -123,8 +124,8 @@ data class WaitForFundingSigned( 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(), + 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 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 d45f4328a..73215dd67 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 @@ -50,12 +53,12 @@ data object WaitForInit : ChannelState() { tlvStream = TlvStream( buildSet { add(ChannelTlv.ChannelTypeTlv(cmd.channelType)) + cmd.requestRemoteFunding?.let { add(it.requestFunds) } 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 adc88ce7d..e4921b42a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannel.kt @@ -99,7 +99,7 @@ data class WaitForOpenChannel( open.channelFlags, channelConfig, channelFeatures, - open.origin + channelOrigin = null, ) val actions = listOf( ChannelAction.ChannelId.IdAssigned(staticParams.remoteNodeId, temporaryChannelId, channelId), diff --git a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index afb150d13..109254dc9 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -16,6 +16,7 @@ import fr.acinq.lightning.logging.* import fr.acinq.lightning.payment.* import fr.acinq.lightning.serialization.Encryption.from import fr.acinq.lightning.serialization.Serialization.DeserializationResult +import fr.acinq.lightning.transactions.Scripts import fr.acinq.lightning.transactions.Transactions import fr.acinq.lightning.utils.* import fr.acinq.lightning.utils.UUID.Companion.randomUUID @@ -32,11 +33,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, @@ -44,10 +40,14 @@ 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 OpenOrSpliceChannel(val walletInputs: List) : PeerCommand() { + val totalAmount: Satoshi = walletInputs.map { it.amount }.sum() +} + 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. @@ -101,8 +101,8 @@ data class PhoenixAndroidLegacyInfoEvent(val info: PhoenixAndroidLegacyInfo) : P * @param walletParams High level parameters for our node. It especially contains the Peer's [NodeUri]. * @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 leaseRate Rate at which our peer sells their liquidity. * @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) @@ -111,9 +111,9 @@ class Peer( val walletParams: WalletParams, val watcher: ElectrumWatcher, val db: Databases, + val leaseRate: LiquidityAds.LeaseRate, socketBuilder: TcpSocket.Builder?, scope: CoroutineScope, - private val trustedSwapInTxs: Set = emptySet(), private val initTlvStream: TlvStream = TlvStream.empty() ) : CoroutineScope by scope { companion object { @@ -151,9 +151,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 @@ -173,7 +170,7 @@ class Peer( val currentTipFlow = MutableStateFlow?>(null) val onChainFeeratesFlow = MutableStateFlow(null) - val swapInFeeratesFlow = MutableStateFlow(null) + val peerFeeratesFlow = MutableStateFlow(null) private val _channelLogger = nodeParams.loggerFactory.newLogger(ChannelState::class) private suspend fun ChannelState.process(cmd: ChannelCommand): Pair> { @@ -390,8 +387,15 @@ class Peer( while (isActive) { val received = session.receive { size -> socket.receiveFully(size) } try { - val msg = LightningMessage.decode(received) - input.send(MessageReceived(peerConnection.id, msg)) + when (val msg = LightningMessage.decode(received)) { + // We treat this message immediately, which ensures that other operations can + // suspend until we receive our peer's feerates without deadlocking. + is RecommendedFeerates -> { + logger.info { "received peer recommended feerates: $msg" } + peerFeeratesFlow.value = msg + } + else -> input.send(MessageReceived(peerConnection.id, msg)) + } } catch (e: Throwable) { logger.warning { "cannot deserialize message: ${received.byteVector().toHex()}" } } @@ -451,17 +455,9 @@ class Peer( swapInJob = launch { swapInWallet.wallet.walletStateFlow .combine(currentTipFlow.filterNotNull()) { walletState, currentTip -> Pair(walletState, currentTip.first) } - .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)) } } } @@ -502,14 +498,14 @@ class Peer( * Estimate the actual feerate to use (and corresponding fee to pay) in order to reach the target feerate * for a splice out, taking into account potential unconfirmed parent splices. */ - suspend fun estimateFeeForSpliceOut(amount: Satoshi, scriptPubKey: ByteVector, targetFeerate: FeeratePerKw): Pair? { + suspend fun estimateFeeForSpliceOut(amount: Satoshi, scriptPubKey: ByteVector, targetFeerate: FeeratePerKw): Pair? { return channels.values .filterIsInstance() .firstOrNull { it.commitments.availableBalanceForSend() > amount } ?.let { channel -> val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = channel.commitments.active.first(), walletInputs = emptyList(), localOutputs = listOf(TxOut(amount, scriptPubKey))) val (actualFeerate, miningFee) = watcher.client.computeSpliceCpfpFeerate(channel.commitments, targetFeerate, spliceWeight = weight, logger) - Pair(actualFeerate, ChannelCommand.Commitment.Splice.Fees(miningFee, 0.msat)) + Pair(actualFeerate, TransactionFees(miningFee, 0.sat)) } } @@ -521,14 +517,14 @@ class Peer( * NB: if the output feerate is equal to the input feerate then the cpfp is useless and * should not be attempted. */ - suspend fun estimateFeeForSpliceCpfp(channelId: ByteVector32, targetFeerate: FeeratePerKw): Pair? { + suspend fun estimateFeeForSpliceCpfp(channelId: ByteVector32, targetFeerate: FeeratePerKw): Pair? { return channels.values .filterIsInstance() .find { it.channelId == channelId } ?.let { channel -> val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = channel.commitments.active.first(), walletInputs = emptyList(), localOutputs = emptyList()) val (actualFeerate, miningFee) = watcher.client.computeSpliceCpfpFeerate(channel.commitments, targetFeerate, spliceWeight = weight, logger) - Pair(actualFeerate, ChannelCommand.Commitment.Splice.Fees(miningFee, 0.msat)) + Pair(actualFeerate, TransactionFees(miningFee, 0.sat)) } } @@ -536,7 +532,7 @@ 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, leaseRate: LiquidityAds.LeaseRate): Pair? { return channels.values .filterIsInstance() .firstOrNull() @@ -546,7 +542,7 @@ class Peer( val (actualFeerate, miningFee) = watcher.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, ChannelCommand.Commitment.Splice.Fees(miningFee, leaseFees.serviceFee.toMilliSatoshi())) + Pair(actualFeerate, TransactionFees(miningFee, leaseFees.serviceFee)) } } @@ -565,7 +561,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() @@ -583,7 +580,8 @@ class Peer( spliceIn = null, spliceOut = null, requestRemoteFunding = null, - feerate = feerate + feerate = feerate, + origins = listOf(), ) send(WrappedChannelCommand(channel.channelId, spliceCommand)) spliceCommand.replyTo.await() @@ -601,7 +599,8 @@ class Peer( spliceIn = null, spliceOut = null, requestRemoteFunding = LiquidityAds.RequestRemoteFunding(amount, leaseStart, leaseRate), - feerate = feerate + feerate = feerate, + origins = listOf(), ) send(WrappedChannelCommand(channel.channelId, spliceCommand)) spliceCommand.replyTo.await() @@ -930,50 +929,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!!)) + val (state2, actions2) = state1.process(ChannelCommand.MessageReceived(msg)) + _channels = _channels + (msg.temporaryChannelId to state2) + processActions(msg.temporaryChannelId, peerConnection, actions1 + actions2) } } is ChannelReestablish -> { @@ -1106,64 +1068,8 @@ class Peer( _channels = _channels + (cmd.watch.channelId to state1) } } - is RequestChannelOpen -> { - when (val channel = channels.values.firstOrNull { it is Normal }) { - is Normal -> { - // 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 = channel.commitments.active.first(), walletInputs = cmd.walletInputs, localOutputs = emptyList()) - val (feerate, fee) = watcher.client.computeSpliceCpfpFeerate(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(channel.channelId, spliceCommand)) - } - else -> { - if (channels.values.all { it is ShuttingDown || it is Negotiating || it is Closing || it is Closed || it is Aborted }) { - // 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) - } else { - // 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())) - } - } - } - } 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( @@ -1174,15 +1080,117 @@ 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 OpenOrSpliceChannel -> { + when (val channel = channels.values.firstOrNull { it is Normal }) { + is Normal -> { + // We have a channel and we are connected (otherwise state would be Offline/Syncing). + val targetFeerate = peerFeeratesFlow.filterNotNull().first().fundingFeerate + val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = channel.commitments.active.first(), walletInputs = cmd.walletInputs, localOutputs = emptyList()) + val (feerate, fee) = watcher.client.computeSpliceCpfpFeerate(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(), TransactionFees(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(channel.channelId, spliceCommand)) + } + } + } + else -> { + if (channels.values.all { it is ShuttingDown || it is Negotiating || it is Closing || it is Closed || it is Aborted }) { + // Either there are no channels, or they will never be suitable for a splice-in: we open a new channel. + val currentFeerates = peerFeeratesFlow.filterNotNull().first() + val requestRemoteFunding = run { + val inboundLiquidityTarget = when (val policy = nodeParams.liquidityPolicy.first()) { + is LiquidityPolicy.Disable -> null + is LiquidityPolicy.Auto -> policy.inboundLiquidityTarget + } + // We need our peer to contribute, because they must have enough funds to pay the commitment fees. + // If we don't have an inbound liquidity target set, we use a default amount of 100 000 sat. + LiquidityAds.RequestRemoteFunding(inboundLiquidityTarget ?: 100_000.sat, currentTipFlow.filterNotNull().first().first, leaseRate) + } + val (localFundingAmount, fees) = run { + 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())) + // We directly pay the on-chain fees for our inputs/outputs of the transaction. + val localFundingAmount = cmd.totalAmount - localMiningFee + val leaseFees = leaseRate.fees(currentFeerates.fundingFeerate, requestRemoteFunding.fundingAmount, requestRemoteFunding.fundingAmount) + // We also refund the liquidity provider for some of the on-chain fees they will pay for their inputs/outputs of the transaction. + val totalFees = TransactionFees(miningFee = localMiningFee + leaseFees.miningFee, serviceFee = leaseFees.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 { + when (val rejected = nodeParams.liquidityPolicy.first().maybeReject(requestRemoteFunding.fundingAmount.toMilliSatoshi(), fees.total.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger)) { + is LiquidityEvents.Rejected -> { + logger.info { "rejecting channel open: reason=${rejected.reason}" } + nodeParams._nodeEvents.emit(rejected) + 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) + } + } + } + } else { + // There are existing channels but not immediately usable (e.g. creating, disconnected), we don't do anything yet. + logger.info { "ignoring request to add utxos to channel, existing channels are not ready for splice-in: ${channels.values.map { it::class.simpleName }}" } + swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) + } + } + } + } is PayToOpenResponseCommand -> { logger.info { "sending ${cmd.payToOpenResponse::class.simpleName}" } peerConnection?.send(cmd.payToOpenResponse) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt b/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt index 8852a8f35..0d7831de6 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt @@ -64,6 +64,7 @@ JsonSerializers.LiquidityLeaseFeesSerializer::class, JsonSerializers.LiquidityLeaseWitnessSerializer::class, JsonSerializers.LiquidityLeaseSerializer::class, + JsonSerializers.ChannelFlagsSerializer::class, JsonSerializers.ChannelParamsSerializer::class, JsonSerializers.ChannelOriginSerializer::class, JsonSerializers.CommitmentChangesSerializer::class, @@ -294,6 +295,9 @@ object JsonSerializers { @Serializer(forClass = LiquidityAds.Lease::class) object LiquidityLeaseSerializer + @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 089ac5162..fd83e8166 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -15,7 +15,8 @@ import fr.acinq.lightning.db.IncomingPaymentsDb import fr.acinq.lightning.io.PayToOpenResponseCommand import fr.acinq.lightning.io.PeerCommand import fr.acinq.lightning.io.WrappedChannelCommand -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.MDCLogger +import fr.acinq.lightning.logging.mdc import fr.acinq.lightning.utils.* import fr.acinq.lightning.wire.* @@ -133,7 +134,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment ) } when (val origin = action.origin) { - is Origin.PayToOpenOrigin -> { + is Origin.OffChainPayment -> // there already is a corresponding Lightning invoice in the db db.receivePayment( paymentHash = origin.paymentHash, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt index 101dc7bb7..b49a9fcaa 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt @@ -3,24 +3,25 @@ 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.toMilliSatoshi - sealed class LiquidityPolicy { /** Never initiates swap-ins, never accept pay-to-open */ 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 maintain (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,11 +29,11 @@ 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) } } 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 5e4a7c017..7f46f3354 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.* @@ -273,7 +274,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, @@ -295,6 +296,7 @@ internal data class LocalParams constructor( toSelfDelay, maxAcceptedHtlcs, isFunder, + isFunder, defaultFinalScriptPubKey, features ) @@ -388,7 +390,7 @@ internal data class Commitments( ChannelVersion.channelFeatures, localParams.export(), remoteParams.export(), - channelFlags + ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = false), ), fr.acinq.lightning.channel.CommitmentChanges( localChanges.export(), 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 a22d32396..ea8526d47 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.* @@ -273,7 +274,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, @@ -295,6 +296,7 @@ internal data class LocalParams constructor( toSelfDelay, maxAcceptedHtlcs, isFunder, + isFunder, defaultFinalScriptPubKey, features ) @@ -381,7 +383,7 @@ internal data class Commitments( channelFeatures.export(), localParams.export(), remoteParams.export(), - channelFlags + ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = false), ), fr.acinq.lightning.channel.CommitmentChanges( localChanges.export(), 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..13346fdff 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 { @@ -97,10 +100,7 @@ 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(), readCollection { readChannelOrigin() }.toList()) else -> error("unknown discriminator $discriminator for class ${SpliceStatus::class}") }, liquidityLeases = when { @@ -424,51 +424,75 @@ object Deserialization { } private fun Input.readChannelOrigin(): Origin = when (val discriminator = read()) { - 0x01 -> Origin.PayToOpenOrigin( + 0x01 -> { + val paymentHash = readByteVector32() + val serviceFee = readNumber().msat + val miningFee = readNumber().sat + val amount = readNumber().msat + Origin.OffChainPayment(paymentHash, amount, TransactionFees(miningFee, serviceFee.truncateToSatoshi())) + } + 0x02 -> { + readByteVector32() // unused requestId + val serviceFee = readNumber().msat + val miningFee = readNumber().sat + val amount = readNumber().msat + Origin.OnChainWallet(setOf(), amount, TransactionFees(miningFee, serviceFee.truncateToSatoshi())) + } + 0x03 -> Origin.OffChainPayment( paymentHash = readByteVector32(), - serviceFee = readNumber().msat, - miningFee = readNumber().sat, amount = readNumber().msat, + fees = TransactionFees(miningFee = readNumber().sat, serviceFee = readNumber().sat), ) - 0x02 -> Origin.PleaseOpenChannelOrigin( - requestId = readByteVector32(), - serviceFee = readNumber().msat, - miningFee = readNumber().sat, + 0x04 -> Origin.OnChainWallet( + inputs = readCollection { readOutPoint() }.toSet(), amount = readNumber().msat, + fees = TransactionFees(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..ce081974f 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt @@ -462,19 +462,19 @@ object Serialization { } private fun Output.writeChannelOrigin(o: Origin) = when (o) { - is Origin.PayToOpenOrigin -> { - write(0x01) + is Origin.OffChainPayment -> { + write(0x03) writeByteVector32(o.paymentHash) - writeNumber(o.serviceFee.toLong()) - writeNumber(o.miningFee.toLong()) writeNumber(o.amount.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()) + is Origin.OnChainWallet -> { + write(0x04) + writeCollection(o.inputs) { writeBtcObject(it) } writeNumber(o.amount.toLong()) + writeNumber(o.fees.miningFee.toLong()) + writeNumber(o.fees.serviceFee.toLong()) } } @@ -490,7 +490,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 (payCommitTxFees) 2 else 0 + writeNumber(isOpenerFlag + payCommitTxFeesFlag) writeDelimited(defaultFinalScriptPubKey.toByteArray()) writeDelimited(features.toByteArray()) } @@ -507,7 +510,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 b826695a4..0d30501a5 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 bd1aa3e0d..0ca65c36f 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 @@ -116,79 +115,6 @@ sealed class ChannelTlv : Tlv { } } - 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 - ) - - 4 -> Origin.PleaseOpenChannelOrigin( - requestId = LightningCodecs.bytes(input, 32).byteVector32(), - miningFee = LightningCodecs.u64(input).sat, - serviceFee = LightningCodecs.u64(input).msat, - amount = LightningCodecs.u64(input).msat - ) - - 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) - } - } - } - /** Amount that will be offered by the initiator of a dual-funded channel to the non-initiator. */ data class PushAmountTlv(val amount: MilliSatoshi) : ChannelTlv() { override val tag: Long get() = PushAmountTlv.tag @@ -339,37 +265,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) - } - } -} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index 598be3826..542f94a36 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -8,9 +8,10 @@ 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 @@ -82,7 +83,7 @@ interface LightningMessage { FCMToken.type -> FCMToken.read(stream) UnsetFCMToken.type -> UnsetFCMToken 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) @@ -664,13 +665,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 override val type: Long get() = OpenDualFundedChannel.type @@ -693,7 +693,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) } @@ -706,32 +708,54 @@ data class OpenDualFundedChannel( 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.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 + ) + } } } @@ -926,7 +950,6 @@ data class SpliceInit( val requireConfirmedInputs: Boolean = tlvStream.get()?.let { true } ?: false val requestFunds: ChannelTlv.RequestFunds? get() = tlvStream.get() val pushAmount: MilliSatoshi = tlvStream.get()?.amount ?: 0.msat - val origins: List = tlvStream.get()?.origins?.filterIsInstance() ?: emptyList() constructor(channelId: ByteVector32, fundingContribution: Satoshi, pushAmount: MilliSatoshi, feerate: FeeratePerKw, lockTime: Long, fundingPubkey: PublicKey, requestFunds: ChannelTlv.RequestFunds?) : this( channelId, @@ -954,7 +977,6 @@ data class SpliceInit( ChannelTlv.RequireConfirmedInputsTlv.tag to ChannelTlv.RequireConfirmedInputsTlv as TlvValueReader, ChannelTlv.RequestFunds.tag to ChannelTlv.RequestFunds as TlvValueReader, ChannelTlv.PushAmountTlv.tag to ChannelTlv.PushAmountTlv.Companion as TlvValueReader, - ChannelTlv.OriginsTlv.tag to ChannelTlv.OriginsTlv.Companion as TlvValueReader ) override fun read(input: Input): SpliceInit = SpliceInit( @@ -1715,48 +1737,26 @@ data class PhoenixAndroidLegacyInfo( } } -/** - * 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 ) : 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) } - companion object : LightningMessageReader { - const val type: Long = 36001 + companion object : LightningMessageReader { + const val type: Long = 35025 - @Suppress("UNCHECKED_CAST") - val readers = mapOf( - PleaseOpenChannelTlv.GrandParents.tag to PleaseOpenChannelTlv.GrandParents.Companion 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), ) } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt index b3e0439ce..cc75d2b95 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt @@ -20,7 +20,7 @@ import fr.acinq.lightning.utils.sat 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) { 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..69f07bd6b 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, payCommitTxFees = 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/TestsHelper.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt index b2fc90955..8cea22665 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt @@ -177,7 +177,6 @@ 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 aliceInit = Init(aliceFeatures) @@ -191,10 +190,11 @@ object TestsHelper { TestConstants.feeratePerKw, aliceChannelParams, bobInit, - channelFlags, + ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = false), ChannelConfig.standard, channelType, - channelOrigin + requestRemoteFunding = null, + channelOrigin, ) ) assertIs>(alice1) 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/SpliceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt index f8d7eb039..1713e3651 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt @@ -191,7 +191,7 @@ class SpliceTestsCommon : LightningTestSuite() { 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 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) // Alice's contribution is negative: she needs to pay on-chain fees for the splice. @@ -237,7 +237,7 @@ class SpliceTestsCommon : LightningTestSuite() { 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 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)) @@ -250,7 +250,7 @@ class SpliceTestsCommon : LightningTestSuite() { 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 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)) @@ -1284,7 +1284,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 +1330,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 +1368,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 +1402,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)) 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..316cc9fe9 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSignedTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSignedTestsCommon.kt @@ -71,40 +71,39 @@ class WaitForFundingSignedTestsCommon : LightningTestSuite() { } @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, TransactionFees(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, 5) + actionsAlice1.hasOutgoingMessage() + actionsAlice1.has() + actionsAlice1.find().also { + assertEquals(50_000_000.msat, it.amount) 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() + actionsAlice1.has() } @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, TransactionFees(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, 5) + actionsAlice1.hasOutgoingMessage() + actionsAlice1.has() + actionsAlice1.find().also { + assertEquals(it.amount, 200_000_000.msat) assertEquals(it.origin, channelOrigin) assertTrue(it.localInputs.isNotEmpty()) } - actionsBob1.hasWatch() - actionsBob1.has() + actionsAlice1.hasWatch() + actionsAlice1.has() } @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 ae8c58e45..13404ff56 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/io/peer/PeerTest.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/io/peer/PeerTest.kt @@ -8,25 +8,29 @@ import fr.acinq.lightning.CltvExpiryDelta import fr.acinq.lightning.InvoiceDefaultRoutingFees import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey +import fr.acinq.lightning.LiquidityEvents import fr.acinq.lightning.NodeUri 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.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.* import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.tests.utils.runSuspendTest -import fr.acinq.lightning.utils.* +import fr.acinq.lightning.utils.Connection +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat import fr.acinq.lightning.wire.* import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -54,7 +58,7 @@ class PeerTest : LightningTestSuite() { randomKey().publicKey(), randomKey().publicKey(), randomKey().publicKey(), - 0.toByte(), + ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = false), TlvStream(ChannelTlv.ChannelTypeTlv(ChannelType.SupportedChannelType.AnchorOutputsZeroReserve)) ) @@ -118,7 +122,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) @@ -178,7 +182,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) @@ -220,106 +224,43 @@ 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 = null, 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(OpenOrSpliceChannel(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.requestFunds?.amount, 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(OpenOrSpliceChannel(walletBob)) + + val rejected = bob.nodeParams.nodeEvents.first() + assertIs(rejected) + assertEquals(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 b0d0a3a24..fbbf01458 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt @@ -173,7 +173,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { miningFee = 0.sat, localInputs = emptySet(), txId = TxId(randomBytes32()), - origin = Origin.PayToOpenOrigin(amount = payToOpenRequest.amountMsat, paymentHash = payToOpenRequest.paymentHash, serviceFee = 0.msat, miningFee = payToOpenRequest.payToOpenFeeSatoshis) + origin = Origin.OffChainPayment(payToOpenRequest.paymentHash, payToOpenRequest.amountMsat, TransactionFees(miningFee = payToOpenRequest.payToOpenFeeSatoshis, serviceFee = 0.sat)) ) paymentHandler.process(channelId, amountOrigin) paymentHandler.db.getIncomingPayment(payToOpenRequest.paymentHash).also { dbPayment -> @@ -1229,11 +1229,6 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { return Pair(payee.db.getIncomingPayment(paymentRequest.paymentHash)!!, paymentRequest.paymentSecret) } - private fun makeReceivedWithNewChannel(payToOpen: PayToOpenRequest, feeRatio: Double = 0.1): IncomingPayment.ReceivedWith.NewChannel { - val fee = payToOpen.amountMsat * feeRatio - return IncomingPayment.ReceivedWith.NewChannel(amount = payToOpen.amountMsat - fee, serviceFee = fee, miningFee = 0.sat, channelId = randomBytes32(), txId = TxId(randomBytes32()), confirmedAt = null, lockedAt = null) - } - private suspend fun checkDbPayment(incomingPayment: IncomingPayment, db: IncomingPaymentsDb) { val dbPayment = db.getIncomingPayment(incomingPayment.paymentHash)!! assertEquals(incomingPayment.preimage, dbPayment.preimage) 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/tests/TestConstants.kt b/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt index e7eec6023..c8969499c 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,15 @@ object TestConstants { TrampolineFees(5.sat, 1200, CltvExpiryDelta(576)) ) + val leaseRate = LiquidityAds.LeaseRate( + leaseDuration = 0, + fundingWeight = 500, + leaseFeeProportional = 100, // 1% + leaseFeeBase = 0.sat, + maxRelayFeeProportional = 50, // 0.5% + maxRelayFeeBase = 1_000.msat, + ) + const val aliceSwapInServerXpub = "tpubDCvYeHUZisCMVTSfWDa1yevTf89NeF6TWxXUQwqkcmFrNvNdNvZQh1j4m4uTA4QcmPEwcrKVF8bJih1v16zDZacRr4j9MCAFQoSydKKy66q" const val bobSwapInServerXpub = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41" @@ -85,7 +95,7 @@ object TestConstants { paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(0), CltvExpiryDelta(0)), ) - fun channelParams(): LocalParams = LocalParams(nodeParams, isInitiator = true) + fun channelParams(): LocalParams = LocalParams(nodeParams, isChannelOpener = true, payCommitTxFees = true) } object Bob { @@ -116,7 +126,7 @@ object TestConstants { paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(0), CltvExpiryDelta(0)), ) - fun channelParams(): LocalParams = LocalParams(nodeParams, isInitiator = false) + fun channelParams(): LocalParams = LocalParams(nodeParams, isChannelOpener = false, payCommitTxFees = false) } } 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 c82c3bde3..29e06ccf5 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 @@ -18,14 +18,12 @@ import fr.acinq.lightning.channel.states.PersistedChannelState import fr.acinq.lightning.channel.states.Syncing import fr.acinq.lightning.db.InMemoryDatabases import fr.acinq.lightning.io.* -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.MDCLogger +import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.tests.utils.testLoggerFactory import fr.acinq.lightning.utils.Connection 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 @@ -83,11 +81,15 @@ suspend fun connect( } } - // Initialize Bob with Alice's features + // Initialize Bob with Alice's features. bob.send(MessageReceived(bobConnection.id, Init(features = alice.nodeParams.features.initFeatures()))) - // Initialize Alice with Bob's features + // Initialize Alice with Bob's features. alice.send(MessageReceived(aliceConnection.id, Init(features = bob.nodeParams.features.initFeatures()))) + // Initialize Alice and Bob's current feerates. + alice.peerFeeratesFlow.emit(RecommendedFeerates(Block.RegtestGenesisBlock.hash, fundingFeerate = FeeratePerKw(FeeratePerByte(20.sat)), commitmentFeerate = FeeratePerKw(FeeratePerByte(1.sat)))) + bob.peerFeeratesFlow.emit(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. @@ -186,7 +188,7 @@ suspend fun buildPeer( ): Peer { val electrum = ElectrumClient(scope, nodeParams.loggerFactory) val watcher = ElectrumWatcher(electrum, scope, nodeParams.loggerFactory) - val peer = Peer(nodeParams, walletParams, watcher, databases, TcpSocket.Builder(), scope) + val peer = Peer(nodeParams, walletParams, watcher, databases, TestConstants.leaseRate, TcpSocket.Builder(), scope) peer.currentTipFlow.value = currentTip peer.onChainFeeratesFlow.value = OnChainFeerates( fundingFeerate = FeeratePerKw(FeeratePerByte(5.sat)), diff --git a/src/commonTest/kotlin/fr/acinq/lightning/transactions/AnchorOutputsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/transactions/AnchorOutputsTestsCommon.kt index e57048707..a7ad13420 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/transactions/AnchorOutputsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/transactions/AnchorOutputsTestsCommon.kt @@ -92,7 +92,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, payCommitTxFees = 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 acb976052..8cc9475af 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -290,8 +290,8 @@ class LightningCodecsTestsCommon : LightningTestSuite() { @Test fun `encode - decode open_channel`() { // @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")), @@ -299,8 +299,27 @@ class LightningCodecsTestsCommon : LightningTestSuite() { 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(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) -> @@ -768,16 +787,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) @@ -788,10 +802,10 @@ class LightningCodecsTestsCommon : LightningTestSuite() { } @Test - fun `encode - decode phoenix-android-legacy-info messages`() { + fun `encode - decode recommended feerates messages`() { val testCases = listOf( - Pair(PhoenixAndroidLegacyInfo(hasChannels = true), Hex.decode("88cfff")), - Pair(PhoenixAndroidLegacyInfo(hasChannels = false), Hex.decode("88cf00")), + RecommendedFeerates(Block.TestnetGenesisBlock.hash, FeeratePerKw(2500.sat), FeeratePerKw(2500.sat)) to Hex.decode("88d1 43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000 000009c4 000009c4"), + RecommendedFeerates(Block.TestnetGenesisBlock.hash, FeeratePerKw(5000.sat), FeeratePerKw(253.sat)) to Hex.decode("88d1 43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000 00001388 000000fd"), ) testCases.forEach { val decoded = LightningMessage.decode(it.second) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/OpenTlvTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/OpenTlvTestsCommon.kt index cda89ae6c..cb37d0f7a 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/OpenTlvTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/OpenTlvTestsCommon.kt @@ -1,15 +1,11 @@ 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.crypto.assertArrayEquals 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.Test import kotlin.test.assertEquals @@ -63,31 +59,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) - assertArrayEquals(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 764aa406e..d566407a9 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 ae5f7de63..7a5c66978 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, + "payCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 9a5b4c324..390bce225 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 14d3c2f81..4d0b79072 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 76bc7ee99..1b6d19aee 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 5c5a6ce51..49c99308a 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 8442323e4..9bed51489 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -81,7 +82,10 @@ ] } }, - "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 897fe83f2..0c84b67c4 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 76501b4ad..2410c147f 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, + "payCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 97299fe3d..d36c3c2ad 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, + "payCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 9bc7d6500..4d347efc9 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 1972b21d2..cbfff57b4 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -81,7 +82,10 @@ ] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/Normal_e2253ddd/data.json b/src/commonTest/resources/nonreg/v2/Normal_e2253ddd/data.json index f5e6d6451..c94aefcdb 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/Normal_ff248f8d/data.json b/src/commonTest/resources/nonreg/v2/Normal_ff248f8d/data.json index 85e46b5a8..e32c08b6c 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, + "payCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/Normal_ff4a71b6/data.json b/src/commonTest/resources/nonreg/v2/Normal_ff4a71b6/data.json index fdf1580ff..173e7ae17 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/Normal_ffd9f5db/data.json b/src/commonTest/resources/nonreg/v2/Normal_ffd9f5db/data.json index 77e09eece..8dd7868bd 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v2/ShuttingDown_c321b947/data.json b/src/commonTest/resources/nonreg/v2/ShuttingDown_c321b947/data.json index e3c2516d7..feadb2b7a 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 fc47ec79b..a6863fa82 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, + "payCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 a090b6f17..2bad05172 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, + "payCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 86fbba546..9dd2d59cc 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, + "payCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -83,7 +84,10 @@ ] } }, - "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 fa73be0a9..4d76cfd26 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 d0fca3dd7..01bd48192 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, + "payCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 0656d1373..956cfbe23 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -81,7 +82,10 @@ ] } }, - "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 c0a1906a9..30a9d3392 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -81,7 +82,10 @@ ] } }, - "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 ea25e9026..240e9ee22 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ ] } }, - "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 aa3ffa902..9a9494679 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ ] } }, - "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 66d2bbb69..df14cd449 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, + "payCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -80,7 +81,10 @@ ] } }, - "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 b160fb04b..9dcaadc10 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ ] } }, - "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 dfe296fb1..8f7b96b40 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ ] } }, - "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 78d80803a..5642158e6 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -79,7 +80,10 @@ ] } }, - "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 1a0f84e64..2e19f3d9b 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ ] } }, - "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 4c122a653..f7a93b201 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ ] } }, - "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 152463972..9c49cd849 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, + "payCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -82,7 +83,10 @@ ] } }, - "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 60ae64d06..919f3ff53 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ ] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/Normal_fe897b64/data.json b/src/commonTest/resources/nonreg/v3/Normal_fe897b64/data.json index 4add406ad..3ddc313ba 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ ] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/Normal_ff248f8d/data.json b/src/commonTest/resources/nonreg/v3/Normal_ff248f8d/data.json index 4ab6752e6..7085e526d 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, + "payCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -80,7 +81,10 @@ ] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/Normal_ff4a71b6/data.json b/src/commonTest/resources/nonreg/v3/Normal_ff4a71b6/data.json index 43df4d86f..43b043f53 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ ] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { diff --git a/src/commonTest/resources/nonreg/v3/ShuttingDown_ef41a1a5/data.json b/src/commonTest/resources/nonreg/v3/ShuttingDown_ef41a1a5/data.json index 37bfaadfc..fceb3a138 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, + "payCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -80,7 +81,10 @@ ] } }, - "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 4eccd551b..e659368ae 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ ] } }, - "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 a23eaa96b..a7e197946 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, + "payCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -83,7 +84,10 @@ ] } }, - "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 fa16439a2..1564835fa 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -80,7 +81,10 @@ ] } }, - "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 8b7a87823..0264c15e7 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, + "payCommitTxFees": false, "defaultFinalScriptPubKey": "001434947cfb2e8f6054ddf12daed4308cbe342580d1", "features": { "activated": { @@ -80,7 +81,10 @@ ] } }, - "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 c6f0f40da..276d3c7aa 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -79,7 +80,10 @@ ] } }, - "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 552674510..39e4c4707 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, + "payCommitTxFees": true, "defaultFinalScriptPubKey": "001405e0104aa726e34ff5cd3a6320d05c0862b5b01c", "features": { "activated": { @@ -79,7 +80,10 @@ ] } }, - "channelFlags": 0 + "channelFlags": { + "announceChannel": false, + "nonInitiatorPaysCommitFees": false + } }, "changes": { "localChanges": { From fae1bed490acb2217aeb59757fe45dd6734abf06 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 5 Mar 2024 11:51:33 +0100 Subject: [PATCH 2/6] Add liquidity ads to channel opening flow We previously only used liquidity ads with splicing: we now support it during the initial channel opening flow as well. This lets us add more unit tests, including tests for the case where the node receiving the `open_channel` message is responsible for paying the commitment fees. --- .../acinq/lightning/channel/ChannelCommand.kt | 3 +- .../acinq/lightning/channel/InteractiveTx.kt | 13 +- .../channel/states/WaitForAcceptChannel.kt | 91 ++++++++----- .../channel/states/WaitForFundingCreated.kt | 3 +- .../lightning/channel/states/WaitForInit.kt | 3 +- .../channel/states/WaitForOpenChannel.kt | 21 ++- .../kotlin/fr/acinq/lightning/io/Peer.kt | 2 +- .../fr/acinq/lightning/wire/LiquidityAds.kt | 10 +- .../channel/InteractiveTxTestsCommon.kt | 12 +- .../fr/acinq/lightning/channel/TestsHelper.kt | 30 ++++- .../channel/states/ClosingTestsCommon.kt | 31 +++++ .../channel/states/NegotiatingTestsCommon.kt | 39 ++++++ .../channel/states/NormalTestsCommon.kt | 37 +++++ .../channel/states/ShutdownTestsCommon.kt | 19 +++ .../channel/states/SpliceTestsCommon.kt | 4 +- .../states/WaitForAcceptChannelTestsCommon.kt | 52 ++++++- .../states/WaitForChannelReadyTestsCommon.kt | 5 +- .../WaitForFundingConfirmedTestsCommon.kt | 2 + .../WaitForFundingCreatedTestsCommon.kt | 3 +- .../states/WaitForFundingSignedTestsCommon.kt | 127 +++++++++++++----- .../crypto/LocalKeyManagerTestsCommon.kt | 8 +- .../fr/acinq/lightning/tests/TestConstants.kt | 9 +- 22 files changed, 415 insertions(+), 109 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt index a8ed4a5e9..c34238da8 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt @@ -48,7 +48,8 @@ sealed class ChannelCommand { val walletInputs: List, val localParams: LocalParams, val channelConfig: ChannelConfig, - val remoteInit: InitMessage + val remoteInit: InitMessage, + val leaseRate: LiquidityAds.LeaseRate?, ) : Init() data class Restore(val state: PersistedChannelState) : Init() diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt index 97a374cac..77f4553f2 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.* @@ -1075,7 +1070,7 @@ data class InteractiveTxSigningSession( val channelKeys = channelParams.localParams.channelKeys(keyManager) val unsignedTx = sharedTx.buildUnsignedTx() val sharedOutputIndex = unsignedTx.txOut.indexOfFirst { it.publicKeyScript == fundingParams.fundingPubkeyScript(channelKeys) } - val liquidityFees = liquidityLease?.fees?.total?.toMilliSatoshi() ?: 0.msat + val liquidityFees = liquidityLease?.fees?.total?.toMilliSatoshi()?.let { if (fundingParams.isInitiator) it else -it } ?: 0.msat return Helpers.Funding.makeCommitTxs( channelKeys, channelParams.channelId, 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 2ad4f6b41..90bcd8d47 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt @@ -8,6 +8,7 @@ import fr.acinq.lightning.channel.Helpers.Funding.computeChannelId import fr.acinq.lightning.utils.msat import fr.acinq.lightning.wire.AcceptDualFundedChannel import fr.acinq.lightning.wire.Error +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.lightning.wire.OpenDualFundedChannel /* @@ -53,41 +54,67 @@ 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)) { - is Either.Left -> { - logger.error { "could not fund channel: ${fundingContributions.value}" } - Pair(Aborted, listOf(ChannelAction.Message.Send(Error(channelId, ChannelFundingError(channelId).message)))) + when (val liquidityLease = LiquidityAds.validateLease( + init.requestRemoteFunding, + staticParams.remoteNodeId, + channelId, + fundingParams.fundingPubkeyScript(channelKeys), + accept.fundingAmount, + lastSent.fundingFeerate, + accept.willFund + )) { + is Either.Left -> { + logger.error { "rejecting liquidity proposal: ${liquidityLease.value.message}" } + Pair(Aborted, listOf(ChannelAction.Message.Send(Error(cmd.message.temporaryChannelId, liquidityLease.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, - 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" } + 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, + liquidityLease.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)))) + } + } + } } } } 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 831a140e2..2a4a2614a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreated.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreated.kt @@ -41,6 +41,7 @@ data class WaitForFundingCreated( val channelFlags: ChannelFlags, val channelConfig: ChannelConfig, val channelFeatures: ChannelFeatures, + val liquidityLease: LiquidityAds.Lease?, val channelOrigin: Origin? ) : ChannelState() { val channelId: ByteVector32 = interactiveTxSession.fundingParams.channelId @@ -63,7 +64,7 @@ data class WaitForFundingCreated( interactiveTxAction.sharedTx, localPushAmount, remotePushAmount, - liquidityLease = null, + liquidityLease, localCommitmentIndex = 0, remoteCommitmentIndex = 0, commitTxFeerate, 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 73215dd67..cf3dac567 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForInit.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForInit.kt @@ -24,7 +24,8 @@ data object WaitForInit : ChannelState() { cmd.walletInputs, cmd.localParams, cmd.channelConfig, - cmd.remoteInit + cmd.remoteInit, + cmd.leaseRate, ) Pair(nextState, listOf()) } 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 e4921b42a..c75f0e59a 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 leaseRate: LiquidityAds.LeaseRate?, ) : ChannelState() { override fun ChannelContext.processInternal(cmd: ChannelCommand): Pair> { return when (cmd) { @@ -40,6 +41,17 @@ 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 willFundLease = leaseRate?.let { rate -> + open.requestFunds?.let { request -> + if (request.amount <= fundingAmount) { + val fundingScript = Helpers.Funding.makeFundingPubKeyScript(localFundingPubkey, open.fundingPubkey) + rate.signLease(staticParams.nodeParams.nodePrivateKey, fundingAmount, fundingScript, open.fundingFeerate, request) + } else { + null + } + } + } val accept = AcceptDualFundedChannel( temporaryChannelId = open.temporaryChannelId, fundingAmount = fundingAmount, @@ -49,7 +61,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 +71,7 @@ data class WaitForOpenChannel( tlvStream = TlvStream( buildSet { add(ChannelTlv.ChannelTypeTlv(channelType)) + willFundLease?.let { add(it.willFund) } if (pushAmount > 0.msat) add(ChannelTlv.PushAmountTlv(pushAmount)) } ), @@ -88,7 +101,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(payCommitTxFees = open.channelFlags.nonInitiatorPaysCommitFees), remoteParams, interactiveTxSession, pushAmount, @@ -99,6 +113,7 @@ data class WaitForOpenChannel( open.channelFlags, channelConfig, channelFeatures, + willFundLease?.lease, channelOrigin = null, ) val actions = listOf( diff --git a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index 109254dc9..feec19ac1 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -932,7 +932,7 @@ class Peer( 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!!)) + val (state1, actions1) = state.process(ChannelCommand.Init.NonInitiator(msg.temporaryChannelId, 0.sat, 0.msat, listOf(), localParams, channelConfig, theirInit!!, leaseRate = null)) val (state2, actions2) = state1.process(ChannelCommand.MessageReceived(msg)) _channels = _channels + (msg.temporaryChannelId to state2) processActions(msg.temporaryChannelId, peerConnection, actions1 + actions2) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt index cc75d2b95..64f75e819 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt @@ -51,10 +51,14 @@ object LiquidityAds { return LeaseFees(onChainFees, leaseFeeBase + proportionalFee) } - fun signLease(nodeKey: PrivateKey, fundingScript: ByteVector, requestFunds: ChannelTlv.RequestFunds): ChannelTlv.WillFund { + fun signLease(nodeKey: PrivateKey, fundingAmount: Satoshi, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, requestFunds: ChannelTlv.RequestFunds): WillFundLease { + require(fundingAmount >= requestFunds.amount) { "funding amount is smaller than requested by our peer ($fundingAmount < ${requestFunds.amount})" } val witness = LeaseWitness(fundingScript, requestFunds.leaseDuration, requestFunds.leaseExpiry, maxRelayFeeProportional, maxRelayFeeBase) val sig = witness.sign(nodeKey) - return ChannelTlv.WillFund(sig, fundingWeight, leaseFeeProportional, leaseFeeBase, maxRelayFeeProportional, maxRelayFeeBase) + val leaseFees = fees(fundingFeerate, requestFunds.amount, fundingAmount) + val lease = Lease(requestFunds.amount, leaseFees, sig, witness) + val willFund = ChannelTlv.WillFund(sig, fundingWeight, leaseFeeProportional, leaseFeeBase, maxRelayFeeProportional, maxRelayFeeBase) + return WillFundLease(willFund, lease) } fun write(out: Output) { @@ -137,6 +141,8 @@ object LiquidityAds { val expiry: Int = witness.leaseEnd } + data class WillFundLease(val willFund: ChannelTlv.WillFund, val lease: Lease) + /** 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) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt index 955293a92..8efd14986 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt @@ -1188,8 +1188,8 @@ class InteractiveTxTestsCommon : LightningTestSuite() { ): Fixture { val channelId = randomBytes32() val fundingTxIndex = 0L - val localParamsA = TestConstants.Alice.channelParams() - val localParamsB = TestConstants.Bob.channelParams() + val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = true) + val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = false) val channelKeysA = localParamsA.channelKeys(TestConstants.Alice.keyManager) val channelKeysB = localParamsB.channelKeys(TestConstants.Bob.keyManager) val swapInKeysA = TestConstants.Alice.keyManager.swapInOnChainWallet @@ -1219,8 +1219,8 @@ class InteractiveTxTestsCommon : LightningTestSuite() { lockTime: Long ): Either { val channelId = randomBytes32() - val localParamsA = TestConstants.Alice.channelParams() - val localParamsB = TestConstants.Bob.channelParams() + val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = true) + val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = false) val channelKeysA = localParamsA.channelKeys(TestConstants.Alice.keyManager) val channelKeysB = localParamsB.channelKeys(TestConstants.Bob.keyManager) val swapInKeysA = TestConstants.Alice.keyManager.swapInOnChainWallet @@ -1252,8 +1252,8 @@ class InteractiveTxTestsCommon : LightningTestSuite() { ): Fixture { val channelId = randomBytes32() val fundingTxIndex = 0L - val localParamsA = TestConstants.Alice.channelParams() - val localParamsB = TestConstants.Bob.channelParams() + val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = true) + val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = false) 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 8cea22665..ce835b478 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt @@ -11,7 +11,8 @@ import fr.acinq.lightning.channel.states.* import fr.acinq.lightning.crypto.KeyManager import fr.acinq.lightning.db.ChannelClosingType import fr.acinq.lightning.json.JsonSerializers -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.MDCLogger +import fr.acinq.lightning.logging.mdc import fr.acinq.lightning.payment.OutgoingPaymentPacket import fr.acinq.lightning.router.ChannelHop import fr.acinq.lightning.serialization.Serialization @@ -145,6 +146,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> { @@ -177,8 +179,9 @@ object TestsHelper { WaitForInit ) - 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( @@ -190,16 +193,17 @@ object TestsHelper { TestConstants.feeratePerKw, aliceChannelParams, bobInit, - ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = false), + channelFlags, ChannelConfig.standard, channelType, - requestRemoteFunding = null, + requestRemoteFunding?.let { LiquidityAds.RequestRemoteFunding(it, alice.currentBlockHeight, TestConstants.leaseRate) }, 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.leaseRate)) assertIs>(bob1) val open = actionsAlice1.findOutgoingMessage() return Triple(alice1, bob1, open) @@ -214,9 +218,21 @@ 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 63af10dcb..d12c57f98 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.payCommitTxFees) + assertTrue(bob0.commitments.params.localParams.payCommitTxFees) + 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.payCommitTxFees) + assertTrue(bob0.commitments.params.localParams.payCommitTxFees) + 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..0a4281103 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NegotiatingTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NegotiatingTestsCommon.kt @@ -7,6 +7,9 @@ import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.blockchain.* import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.* +import fr.acinq.lightning.channel.TestsHelper.addHtlc +import fr.acinq.lightning.channel.TestsHelper.crossSign +import fr.acinq.lightning.channel.TestsHelper.fulfillHtlc import fr.acinq.lightning.channel.TestsHelper.makeCmdAdd import fr.acinq.lightning.channel.TestsHelper.mutualCloseAlice import fr.acinq.lightning.channel.TestsHelper.mutualCloseBob @@ -146,6 +149,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.payCommitTxFees) + assertTrue(bob.commitments.params.localParams.payCommitTxFees) + // Alice sends all of her balance to Bob. + val (nodes1, r, htlc) = addHtlc(alice.commitments.availableBalanceForSend(), alice, bob) + val (alice1, bob1) = crossSign(nodes1.first, nodes1.second) + val (alice2, bob2) = fulfillHtlc(htlc.id, r, alice1, bob1) + val (bob3, alice3) = 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 f80517101..ea037aef2 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt @@ -821,6 +821,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.payCommitTxFees) + assertTrue(bob0.commitments.params.localParams.payCommitTxFees) + 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() @@ -1424,6 +1445,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() 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 50b4685ff..0a3543b1f 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.payCommitTxFees) + assertTrue(bob.commitments.params.localParams.payCommitTxFees) + // 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 1713e3651..d3cbf7571 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt @@ -202,7 +202,7 @@ 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 willFund = leaseRate.signLease(bob.staticParams.nodeParams.nodePrivateKey, liquidityRequest.fundingAmount, fundingScript, cmd.feerate, spliceInit.requestFunds!!).willFund val spliceAck = SpliceAck(alice.channelId, liquidityRequest.fundingAmount, 0.msat, defaultSpliceAck.fundingPubkey, willFund) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceAck)) assertIs(alice2.state) @@ -212,7 +212,7 @@ class SpliceTestsCommon : LightningTestSuite() { 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 willFund = bobLiquidityRates.signLease(bob.staticParams.nodeParams.nodePrivateKey, liquidityRequest.fundingAmount, fundingScript, cmd.feerate, spliceInit.requestFunds!!).willFund val spliceAck = SpliceAck(alice.channelId, liquidityRequest.fundingAmount, 0.msat, defaultSpliceAck.fundingPubkey, willFund) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceAck)) assertIs(alice2.state) 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..cb127c9a0 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannelTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannelTestsCommon.kt @@ -29,6 +29,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 lease = alice1.state.liquidityLease + assertNotNull(lease) + assertTrue(lease.fees.total > 0.sat) + actions1.hasOutgoingMessage() + } + @Test fun `recv AcceptChannel -- without non-initiator contribution`() { val (alice, _, accept) = init(bobFundingAmount = 0.sat) @@ -81,6 +92,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.WillFund }.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 rates`() { + val (alice, _, accept) = init(requestRemoteFunding = TestConstants.bobFundingAmount) + val willFund = accept.willFund!!.copy(leaseFeeBase = 500.sat) + val accept1 = accept.copy(tlvStream = accept.tlvStream.copy(records = accept.tlvStream.records.filterNot { it is ChannelTlv.WillFund }.toSet() + willFund)) + val (alice1, actions1) = alice.process(ChannelCommand.MessageReceived(accept1)) + assertIs>(alice1) + val error = actions1.hasOutgoingMessage() + assertEquals(error, Error(accept.temporaryChannelId, InvalidLiquidityRates(accept.temporaryChannelId).message)) + } + @Test fun `recv AcceptChannel -- invalid max accepted htlcs`() { val (alice, _, accept) = init() @@ -154,19 +195,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.requestFunds) + } 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 316cc9fe9..4284d6ec3 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.amount) } + actions.has() + actions.find().also { assertEquals(ChannelEvents.Created(state.state), it.event) } + } } } @@ -49,24 +47,89 @@ 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.amount) } + 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 lease = alice.process(ChannelCommand.MessageReceived(commitSigBob)).let { (state, actions) -> + assertIs(state.state) + assertTrue(actions.isEmpty()) + val lease = state.state.signingSession.liquidityLease + assertNotNull(lease) + assertEquals(TestConstants.bobFundingAmount / 100, lease.fees.serviceFee) + val localCommit = state.state.signingSession.localCommit.right!! + assertEquals(TestConstants.aliceFundingAmount - lease.fees.total, localCommit.spec.toLocal.truncateToSatoshi()) + assertEquals(TestConstants.bobFundingAmount + lease.fees.total, localCommit.spec.toRemote.truncateToSatoshi()) + lease + } + bob.process(ChannelCommand.MessageReceived(commitSigAlice)).also { (state, actions) -> + assertIs(state.state) + assertEquals(actions.size, 5) + assertEquals(TestConstants.bobFundingAmount + lease.fees.total, state.commitments.latest.localCommit.spec.toLocal.truncateToSatoshi()) + assertEquals(TestConstants.aliceFundingAmount - lease.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 + lease.fees.total).toMilliSatoshi(), it.amount) } + actions.has() + actions.find().also { assertEquals(ChannelEvents.Created(state.state), it.event) } + } + } + + @Test + fun `recv CommitSig -- liquidity ads -- with push_amount`() { + val aliceFundingAmount = 520_000.sat + val alicePushAmount = 20_000_500.msat + val bobFundingAmount = 210_000.sat + val bobPushAmount = 9_999_500.msat + val requestFunding = 200_000.sat + val (alice, commitSigAlice, bob, commitSigBob) = init( + aliceFundingAmount = aliceFundingAmount, + alicePushAmount = alicePushAmount, + bobFundingAmount = bobFundingAmount, + bobPushAmount = bobPushAmount, + requestRemoteFunding = requestFunding + ) + val (aliceBalance, bobBalance) = alice.process(ChannelCommand.MessageReceived(commitSigBob)).let { (state, actions) -> + assertIs(state.state) + assertTrue(actions.isEmpty()) + val lease = state.state.signingSession.liquidityLease + assertNotNull(lease) + assertEquals(2_000.sat, lease.fees.serviceFee) // 1% + val aliceBalance = aliceFundingAmount.toMilliSatoshi() - alicePushAmount - lease.fees.total.toMilliSatoshi() + bobPushAmount + val bobBalance = bobFundingAmount.toMilliSatoshi() - bobPushAmount + alicePushAmount + lease.fees.total.toMilliSatoshi() + val localCommit = state.state.signingSession.localCommit.right!! + assertEquals(aliceBalance, localCommit.spec.toLocal) + assertEquals(bobBalance, localCommit.spec.toRemote) + Pair(aliceBalance, bobBalance) + } + bob.process(ChannelCommand.MessageReceived(commitSigAlice)).also { (state, actions) -> + assertIs(state.state) + assertEquals(actions.size, 5) + assertEquals(bobBalance, state.commitments.latest.localCommit.spec.toLocal) + assertEquals(aliceBalance, state.commitments.latest.localCommit.spec.toRemote) + actions.hasOutgoingMessage().also { assertFalse(it.channelData.isEmpty()) } + actions.findWatch().also { assertEquals(BITCOIN_FUNDING_DEPTHOK, it.event) } + actions.find().also { assertEquals(bobBalance, it.amount) } + actions.has() + actions.find().also { assertEquals(ChannelEvents.Created(state.state), it.event) } } } @@ -294,6 +357,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 { @@ -306,6 +370,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 1d245a177..b13d82fae 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/tests/TestConstants.kt b/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt index c8969499c..db8a221f8 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt @@ -1,6 +1,9 @@ package fr.acinq.lightning.tests -import fr.acinq.bitcoin.* +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Chain +import fr.acinq.bitcoin.MnemonicCode import fr.acinq.lightning.* import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.blockchain.fee.FeerateTolerance @@ -95,7 +98,7 @@ object TestConstants { paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(0), CltvExpiryDelta(0)), ) - fun channelParams(): LocalParams = LocalParams(nodeParams, isChannelOpener = true, payCommitTxFees = true) + fun channelParams(payCommitTxFees: Boolean): LocalParams = LocalParams(nodeParams, isChannelOpener = true, payCommitTxFees = payCommitTxFees) } object Bob { @@ -126,7 +129,7 @@ object TestConstants { paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(0), CltvExpiryDelta(0)), ) - fun channelParams(): LocalParams = LocalParams(nodeParams, isChannelOpener = false, payCommitTxFees = false) + fun channelParams(payCommitTxFees: Boolean): LocalParams = LocalParams(nodeParams, isChannelOpener = false, payCommitTxFees = payCommitTxFees) } } From c7f4353c8f8413597f1ae4c55d97bb8b55294ecc Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 11 Mar 2024 15:12:03 +0100 Subject: [PATCH 3/6] Replace `pay_to_open` with `maybe_add_htlc` We replace the previous pay-to-open protocol with a new protocol that only relies on liquidity ads for paying fees. We simply transmit HTLCs that cannot be relayed on existing channels with a new message called `maybe_add_htlc` that contains all the HTLC data. If the recipient wishes to accept that payment, it reveals the preimage when sending `open_channel2` or `splice_init` and marks the on-chain part of that payment as pending. It then keeps retrying the on-chain funding operation until it completes or our peer sends a dedicated error asking us to cancel it. --- .../kotlin/fr/acinq/lightning/Features.kt | 30 +- .../kotlin/fr/acinq/lightning/NodeEvents.kt | 7 +- .../kotlin/fr/acinq/lightning/NodeParams.kt | 4 +- .../acinq/lightning/channel/ChannelAction.kt | 6 + .../fr/acinq/lightning/channel/ChannelData.kt | 6 +- .../lightning/channel/ChannelException.kt | 1 - .../acinq/lightning/channel/InteractiveTx.kt | 28 +- .../acinq/lightning/channel/states/Normal.kt | 61 +- .../channel/states/WaitForAcceptChannel.kt | 14 +- .../lightning/channel/states/WaitForInit.kt | 9 +- .../fr/acinq/lightning/db/PaymentsDb.kt | 150 +++-- .../kotlin/fr/acinq/lightning/io/Peer.kt | 203 ++++-- .../payment/IncomingPaymentHandler.kt | 244 ++++--- .../lightning/payment/LiquidityPolicy.kt | 11 +- .../serialization/v4/Deserialization.kt | 3 +- .../serialization/v4/Serialization.kt | 2 +- .../fr/acinq/lightning/wire/ChannelTlv.kt | 13 + .../acinq/lightning/wire/LightningMessages.kt | 135 ++-- .../channel/InteractiveTxTestsCommon.kt | 116 +++- .../channel/states/SpliceTestsCommon.kt | 62 +- .../acinq/lightning/db/InMemoryPaymentsDb.kt | 26 +- .../lightning/db/PaymentsDbTestsCommon.kt | 60 +- .../payment/Bolt11InvoiceTestsCommon.kt | 2 +- .../IncomingPaymentHandlerTestsCommon.kt | 611 ++++++++---------- .../OutgoingPaymentHandlerTestsCommon.kt | 6 +- .../fr/acinq/lightning/tests/TestConstants.kt | 1 - .../acinq/lightning/tests/io/peer/builders.kt | 8 +- .../wire/LightningCodecsTestsCommon.kt | 46 +- 28 files changed, 1070 insertions(+), 795 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/Features.kt b/src/commonMain/kotlin/fr/acinq/lightning/Features.kt index a819b9ecb..a864b0ff0 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/Features.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/Features.kt @@ -126,6 +126,13 @@ sealed class Feature { override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) } + @Serializable + object Quiescence : Feature() { + override val rfcName get() = "option_quiescence" + override val mandatory get() = 34 + override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) + } + @Serializable object ChannelType : Feature() { override val rfcName get() = "option_channel_type" @@ -185,7 +192,7 @@ sealed class Feature { override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) } - /** This feature bit should be activated when a node accepts on-the-fly channel creation. */ + /** DEPRECATED: this feature bit was used for the legacy pay-to-open protocol. */ @Serializable object PayToOpenClient : Feature() { override val rfcName get() = "pay_to_open_client" @@ -193,7 +200,7 @@ sealed class Feature { override val scopes: Set get() = setOf(FeatureScope.Init) } - /** This feature bit should be activated when a node supports opening channels on-the-fly when liquidity is missing to receive a payment. */ + /** DEPRECATED: this feature bit was used for the legacy pay-to-open protocol. */ @Serializable object PayToOpenProvider : Feature() { override val rfcName get() = "pay_to_open_provider" @@ -249,10 +256,19 @@ sealed class Feature { override val scopes: Set get() = setOf(FeatureScope.Init) } + /** This feature bit should be activated when a node accepts on-the-fly funding using the [MaybeAddHtlc] message. */ @Serializable - object Quiescence : Feature() { - override val rfcName get() = "option_quiescence" - override val mandatory get() = 34 + object OnTheFlyFundingClient : Feature() { + override val rfcName get() = "on_the_fly_funding_client" + override val mandatory get() = 156 + override val scopes: Set get() = setOf(FeatureScope.Init) + } + + /** This feature bit should be activated when a node supports on-the-fly funding when liquidity is missing to receive a payment. */ + @Serializable + object OnTheFlyFundingProvider : Feature() { + override val rfcName get() = "on_the_fly_funding_provider" + override val mandatory get() = 158 override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) } @@ -321,6 +337,7 @@ data class Features(val activated: Map, val unknown: Se Feature.AnchorOutputs, Feature.ShutdownAnySegwit, Feature.DualFunding, + Feature.Quiescence, Feature.ChannelType, Feature.PaymentMetadata, Feature.TrampolinePayment, @@ -336,7 +353,8 @@ data class Features(val activated: Map, val unknown: Se Feature.ChannelBackupClient, Feature.ChannelBackupProvider, Feature.ExperimentalSplice, - Feature.Quiescence + Feature.OnTheFlyFundingClient, + Feature.OnTheFlyFundingProvider, ) operator fun invoke(bytes: ByteVector): Features = invoke(bytes.toByteArray()) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt index cd190fa0e..164775c86 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt @@ -9,7 +9,6 @@ import fr.acinq.lightning.channel.states.Normal import fr.acinq.lightning.channel.states.WaitForFundingCreated import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.utils.sum -import kotlinx.coroutines.CompletableDeferred sealed interface NodeEvents @@ -32,11 +31,11 @@ sealed interface LiquidityEvents : NodeEvents { data class OverAbsoluteFee(val maxAbsoluteFee: Satoshi) : TooExpensive() data class OverRelativeFee(val maxRelativeFeeBasisPoints: Int) : TooExpensive() } - data object ChannelInitializing : Reason() + data object ChannelFundingInProgress : Reason() + data class MissingOffChainAmountTooLow(val missingOffChainAmount: MilliSatoshi) : Reason() + data class ChannelFundingCancelled(val paymentHash: ByteVector32) : Reason() } } - - data class ApprovalRequested(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source, val replyTo: CompletableDeferred) : LiquidityEvents } /** This is useful on iOS to ask the OS for time to finish some sensitive tasks. */ diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index 24232a747..7d599c40f 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -182,16 +182,16 @@ data class NodeParams( Feature.StaticRemoteKey to FeatureSupport.Mandatory, Feature.AnchorOutputs to FeatureSupport.Optional, // can't set Mandatory because peers prefers AnchorOutputsZeroFeeHtlcTx Feature.DualFunding to FeatureSupport.Mandatory, + Feature.Quiescence to FeatureSupport.Mandatory, Feature.ShutdownAnySegwit to FeatureSupport.Mandatory, Feature.ChannelType to FeatureSupport.Mandatory, Feature.PaymentMetadata to FeatureSupport.Optional, Feature.ExperimentalTrampolinePayment to FeatureSupport.Optional, Feature.ZeroReserveChannels to FeatureSupport.Optional, Feature.WakeUpNotificationClient to FeatureSupport.Optional, - Feature.PayToOpenClient to FeatureSupport.Optional, Feature.ChannelBackupClient to FeatureSupport.Optional, Feature.ExperimentalSplice to FeatureSupport.Optional, - Feature.Quiescence to FeatureSupport.Mandatory + Feature.OnTheFlyFundingClient to FeatureSupport.Optional, ), dustLimit = 546.sat, maxRemoteDustLimit = 600.sat, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt index f30f448b1..53bc1619b 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt @@ -78,8 +78,14 @@ sealed class ChannelAction { abstract val origin: Origin? abstract val txId: TxId abstract val localInputs: Set + /** @param amount amount received after deducing service and mining fees. */ data class ViaNewChannel(val amount: MilliSatoshi, val serviceFee: MilliSatoshi, val miningFee: Satoshi, override val localInputs: Set, override val txId: TxId, override val origin: Origin?) : StoreIncomingPayment() + /** @param amount amount received after deducing service and mining fees. */ data class ViaSpliceIn(val amount: MilliSatoshi, val serviceFee: MilliSatoshi, val miningFee: Satoshi, override val localInputs: Set, override val txId: TxId, override val origin: Origin?) : StoreIncomingPayment() + data class Cancelled(override val origin: Origin.OffChainPayment) : StoreIncomingPayment() { + override val localInputs: Set = setOf() + override val txId: TxId = TxId(ByteVector32.Zeroes) + } } /** Payment sent through on-chain operations (channel close or splice-out) */ sealed class StoreOutgoingPayment : Storage() { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt index 1e375fd7a..087140139 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt @@ -409,10 +409,14 @@ data class TransactionFees(val miningFee: Satoshi, val serviceFee: Satoshi) { /** Reason for creating a new channel or splicing into an existing channel. */ // @formatter:off sealed class Origin { + /** Amount of the origin payment, before fees are paid. */ abstract val amount: MilliSatoshi + /** Fees applied for the channel funding transaction. */ abstract val fees: TransactionFees - data class OffChainPayment(val paymentHash: ByteVector32, override val amount: MilliSatoshi, override val fees: TransactionFees) : Origin() + data class OffChainPayment(val paymentPreimage: ByteVector32, override val amount: MilliSatoshi, override val fees: TransactionFees) : Origin() { + val paymentHash: ByteVector32 = Crypto.sha256(paymentPreimage).byteVector32() + } data class OnChainWallet(val inputs: Set, override val amount: MilliSatoshi, override val fees: TransactionFees) : Origin() } // @formatter:on diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt index 3f08d7cc2..a3cbb46f5 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt @@ -63,7 +63,6 @@ data class InvalidHtlcSignature (override val channelId: Byte data class InvalidCloseSignature (override val channelId: ByteVector32, val txId: TxId) : ChannelException(channelId, "invalid close signature: txId=$txId") data class InvalidCloseAmountBelowDust (override val channelId: ByteVector32, val txId: TxId) : ChannelException(channelId, "invalid closing tx: some outputs are below dust: txId=$txId") data class CommitSigCountMismatch (override val channelId: ByteVector32, val expected: Int, val actual: Int) : ChannelException(channelId, "commit sig count mismatch: expected=$expected actual=$actual") -data class SwapInSigCountMismatch (override val channelId: ByteVector32, val expected: Int, val actual: Int) : ChannelException(channelId, "swap-in sig count mismatch: expected=$expected actual=$actual") data class HtlcSigCountMismatch (override val channelId: ByteVector32, val expected: Int, val actual: Int) : ChannelException(channelId, "htlc sig count mismatch: expected=$expected actual: $actual") data class ForcedLocalCommit (override val channelId: ByteVector32) : ChannelException(channelId, "forced local commit") data class UnexpectedHtlcId (override val channelId: ByteVector32, val expected: Long, val actual: Long) : ChannelException(channelId, "unexpected htlc id: expected=$expected actual=$actual") diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt index 77f4553f2..f94c8238e 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt @@ -222,7 +222,6 @@ sealed class FundingContributionFailure { data class InputBelowDust(val txId: TxId, val outputIndex: Int, val amount: Satoshi, val dustLimit: Satoshi) : FundingContributionFailure() { override fun toString(): String = "invalid input $txId:$outputIndex (below dust: amount=$amount, dust=$dustLimit)" } data class InputTxTooLarge(val tx: Transaction) : FundingContributionFailure() { override fun toString(): String = "invalid input tx ${tx.txid} (too large)" } data class NotEnoughFunding(val fundingAmount: Satoshi, val nonFundingAmount: Satoshi, val providedAmount: Satoshi) : FundingContributionFailure() { override fun toString(): String = "not enough funds provided (expected at least $fundingAmount + $nonFundingAmount, got $providedAmount)" } - data class NotEnoughFees(val currentFees: Satoshi, val expectedFees: Satoshi) : FundingContributionFailure() { override fun toString(): String = "not enough funds to pay fees (expected at least $expectedFees, got $currentFees)" } data class InvalidFundingBalances(val fundingAmount: Satoshi, val localBalance: MilliSatoshi, val remoteBalance: MilliSatoshi) : FundingContributionFailure() { override fun toString(): String = "invalid balances funding_amount=$fundingAmount local=$localBalance remote=$remoteBalance" } // @formatter:on } @@ -231,10 +230,15 @@ sealed class FundingContributionFailure { data class FundingContributions(val inputs: List, val outputs: List) { companion object { /** Compute our local splice contribution using all the funds available in our wallet. */ - fun computeSpliceContribution(isInitiator: Boolean, commitment: Commitment, walletInputs: List, localOutputs: List, targetFeerate: FeeratePerKw): Satoshi { + fun computeSpliceContribution(isInitiator: Boolean, commitment: Commitment, walletInputs: List, localOutputs: List, isOnTheFlyFunding: Boolean, 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 using on-the-fly funding, we may not have enough funds in our current balance to pay fees. + // It is fine because liquidity fees will be taken from the incoming push amount. + isOnTheFlyFunding && walletInputs.isEmpty() && localOutputs.isEmpty() -> -(fees.min(commitment.localCommit.spec.toLocal.truncateToSatoshi())) + else -> walletInputs.map { it.amount }.sum() - localOutputs.map { it.amount }.sum() - fees + } } /** @@ -271,27 +275,19 @@ data class FundingContributions(val inputs: List, v return Either.Left(FundingContributionFailure.NotEnoughFunding(params.localContribution, localOutputs.map { it.amount }.sum(), totalAmountIn)) } - // We compute the fees that we should pay in the shared transaction. - val fundingPubkeyScript = params.fundingPubkeyScript(channelKeys) - val weightWithoutChange = computeWeightPaid(params.isInitiator, sharedUtxo?.first, fundingPubkeyScript, walletInputs, localOutputs) - val weightWithChange = computeWeightPaid(params.isInitiator, sharedUtxo?.first, fundingPubkeyScript, walletInputs, localOutputs + listOf(TxOut(0.sat, Script.pay2wpkh(Transactions.PlaceHolderPubKey)))) - val feesWithoutChange = totalAmountIn - totalAmountOut - // If we're not the initiator, we don't return an error when we're unable to meet the desired feerate. - if (params.isInitiator && feesWithoutChange < Transactions.weight2fee(params.targetFeerate, weightWithoutChange)) { - return Either.Left(FundingContributionFailure.NotEnoughFees(feesWithoutChange, Transactions.weight2fee(params.targetFeerate, weightWithoutChange))) - } - val nextLocalBalance = (sharedUtxo?.second?.toLocal ?: 0.msat) + params.localContribution.toMilliSatoshi() val nextRemoteBalance = (sharedUtxo?.second?.toRemote ?: 0.msat) + params.remoteContribution.toMilliSatoshi() if (nextLocalBalance < 0.msat || nextRemoteBalance < 0.msat) { return Either.Left(FundingContributionFailure.InvalidFundingBalances(params.fundingAmount, nextLocalBalance, nextRemoteBalance)) } + val fundingPubkeyScript = params.fundingPubkeyScript(channelKeys) val sharedOutput = listOf(InteractiveTxOutput.Shared(0, fundingPubkeyScript, nextLocalBalance, nextRemoteBalance, sharedUtxo?.second?.toHtlcs ?: 0.msat)) val nonChangeOutputs = localOutputs.map { o -> InteractiveTxOutput.Local.NonChange(0, o.amount, o.publicKeyScript) } val changeOutput = when (changePubKey) { null -> listOf() else -> { + val weightWithChange = computeWeightPaid(params.isInitiator, sharedUtxo?.first, fundingPubkeyScript, walletInputs, localOutputs + listOf(TxOut(0.sat, Script.pay2wpkh(Transactions.PlaceHolderPubKey)))) val changeAmount = totalAmountIn - totalAmountOut - Transactions.weight2fee(params.targetFeerate, weightWithChange) if (params.dustLimit <= changeAmount) { listOf(InteractiveTxOutput.Local.Change(0, changeAmount, Script.write(Script.pay2wpkh(changePubKey)).byteVector())) @@ -936,8 +932,10 @@ data class InteractiveTxSession( return InteractiveTxSessionAction.InvalidTxFeerate(fundingParams.channelId, tx.txid, fundingParams.targetFeerate, nextFeerate) } } else { + // We allow the feerate to be lower than requested: when using on-the-fly liquidity, we may not be able to contribute + // as much as we expected, but that's fine because we instead overshoot the feerate and pays liquidity fees accordingly. val minimumFee = Transactions.weight2fee(fundingParams.targetFeerate, tx.weight()) - if (sharedTx.fees < minimumFee) { + if (sharedTx.fees < minimumFee * 0.5) { return InteractiveTxSessionAction.InvalidTxFeerate(fundingParams.channelId, tx.txid, fundingParams.targetFeerate, Transactions.fee2rate(sharedTx.fees, tx.weight())) } } @@ -1163,7 +1161,7 @@ sealed class SpliceStatus { /** Our peer has asked us to stop sending new updates and wait for our updates to be added to the local and remote commitments. */ data class ReceivedStfu(val stfu: Stfu) : QuiescenceNegotiation.NonInitiator() /** Our updates have been added to the local and remote commitments, we wait for our peer to use the now quiescent channel. */ - object NonInitiatorQuiescent : QuiescentSpliceStatus() + data object NonInitiatorQuiescent : QuiescentSpliceStatus() /** We told our peer we want to splice funds in the channel. */ data class Requested(val command: ChannelCommand.Commitment.Splice.Request, val spliceInit: SpliceInit) : QuiescentSpliceStatus() /** We both agreed to splice and are building the splice transaction. */ diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt index cf9fae06f..07f69911a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt @@ -381,19 +381,23 @@ data class Normal( if (!cmd.message.initiator || isChannelOpener) { if (commitments.isQuiescent()) { val parentCommitment = commitments.active.first() + val onTheFlyPreimage = spliceStatus.command.origins.filterIsInstance().map { it.paymentPreimage }.firstOrNull() val fundingContribution = FundingContributions.computeSpliceContribution( isInitiator = true, commitment = parentCommitment, walletInputs = spliceStatus.command.spliceIn?.walletInputs ?: emptyList(), localOutputs = spliceStatus.command.spliceOutputs, + isOnTheFlyFunding = onTheFlyPreimage != null, targetFeerate = spliceStatus.command.feerate ) + val liquidityFees = spliceStatus.command.requestRemoteFunding?.let { it.rate.fees(spliceStatus.command.feerate, it.fundingAmount, it.fundingAmount).total } ?: 0.sat val commitTxFees = when { payCommitTxFees -> Transactions.commitTxFee(commitments.params.remoteParams.dustLimit, parentCommitment.remoteCommit.spec) else -> 0.sat } - if (parentCommitment.localCommit.spec.toLocal + fundingContribution.toMilliSatoshi() < parentCommitment.localChannelReserve(commitments.params).max(commitTxFees)) { - logger.warning { "cannot do splice: insufficient funds" } + if (onTheFlyPreimage == null && parentCommitment.localCommit.spec.toLocal + fundingContribution.toMilliSatoshi() < parentCommitment.localChannelReserve(commitments.params).max(commitTxFees)) { + // Note that we bypass this check when the origin is an off-chain payment: fees will be paid from the HTLC amount. + logger.warning { "cannot do splice: insufficient funds to pay fees (balance=${parentCommitment.localCommit.spec.toLocal}, fundingContribution=${fundingContribution})" } spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds) val actions = buildList { add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceRequest(channelId).message))) @@ -408,9 +412,10 @@ 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() } - logger.warning { "cannot do splice: balance is too low to pay for inbound liquidity (missing=$missing)" } + } else if (onTheFlyPreimage == null && parentCommitment.localCommit.spec.toLocal < liquidityFees) { + // Note that we bypass this check when the origin is an off-chain payment: fees will be paid from the HTLC amount. + val missing = liquidityFees - parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() + logger.warning { "cannot do splice: balance is too low to pay for inbound liquidity (balance=${parentCommitment.localCommit.spec.toLocal}, fees=$liquidityFees, missing=$missing)" } spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds) Pair(this@Normal, emptyList()) } else { @@ -422,6 +427,7 @@ data class Normal( fundingPubkey = channelKeys().fundingPubKey(parentCommitment.fundingTxIndex + 1), pushAmount = spliceStatus.command.pushAmount, requestFunds = spliceStatus.command.requestRemoteFunding?.requestFunds, + preimage = onTheFlyPreimage ) 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))) @@ -771,6 +777,23 @@ data class Normal( Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) } } + is CancelOnTheFlyFunding -> when (spliceStatus) { + is SpliceStatus.Requested -> { + // Our peer won't accept our on-the-fly funding attempt: they didn't receive the preimage in time and failed the corresponding HTLCs. + // We should stop retrying and sending splice_init, they will keep rejecting it anyway. + logger.info { "our peer rejected our splice request: paymentHash=${cmd.message.paymentHash} ascii='${cmd.message.toAscii()}' bin=${cmd.message.data}" } + spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer(cmd.message.toAscii())) + val actions = buildList { + spliceStatus.command.origins.firstOrNull { it is Origin.OffChainPayment }?.let { add(ChannelAction.Storage.StoreIncomingPayment.Cancelled(it as Origin.OffChainPayment)) } + addAll(endQuiescence()) + } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) + } + else -> { + logger.warning { "we received cancel_on_the_fly_funding but we don't have a matching proposed splice: this should not happen (paymentHash=${cmd.message.paymentHash} ascii='${cmd.message.toAscii()}')" } + Pair(this@Normal, listOf()) + } + } is SpliceLocked -> { when (val res = commitments.run { updateRemoteFundingStatus(cmd.message.fundingTxId) }) { is Either.Left -> Pair(this@Normal, emptyList()) @@ -859,10 +882,11 @@ data class Normal( action.fundingTx.signedTx?.let { add(ChannelAction.Blockchain.PublishTx(it, ChannelAction.Blockchain.PublishTx.Type.FundingTx)) } add(ChannelAction.Blockchain.SendWatch(watchConfirmed)) add(ChannelAction.Message.Send(action.localSigs)) - // If we received or sent funds as part of the splice, we will add a corresponding entry to our incoming/outgoing payments db + // If we received or sent funds as part of the splice, we will add a corresponding entry to our incoming/outgoing payments db. + // If there is an origin, we received a payment as part of this splice (swap-in or on-the-fly funding). addAll(origins.map { origin -> ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn( - amount = origin.amount, + amount = origin.amount - origin.fees.total.toMilliSatoshi(), serviceFee = origin.fees.serviceFee.toMilliSatoshi(), miningFee = origin.fees.miningFee, localInputs = action.fundingTx.sharedTx.tx.localInputs.map { it.outPoint }.toSet(), @@ -870,17 +894,7 @@ data class Normal( origin = origin ) }) - // If we added some funds ourselves it's a swap-in - if (action.fundingTx.sharedTx.tx.localInputs.isNotEmpty()) add( - ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn( - amount = action.fundingTx.sharedTx.tx.localInputs.map { i -> i.txOut.amount }.sum().toMilliSatoshi() - action.fundingTx.sharedTx.tx.localFees, - serviceFee = 0.msat, - miningFee = action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi(), - localInputs = action.fundingTx.sharedTx.tx.localInputs.map { it.outPoint }.toSet(), - txId = action.fundingTx.txId, - origin = null - ) - ) + // We never generate change outputs: if we have local outputs, they are outgoing on-chain payments. addAll(action.fundingTx.fundingParams.localOutputs.map { txOut -> ChannelAction.Storage.StoreOutgoingPayment.ViaSpliceOut( amount = txOut.amount, @@ -889,15 +903,14 @@ data class Normal( txId = action.fundingTx.txId ) }) - // If we initiated the splice but there are no new inputs on either side and no new output on our side, it's a cpfp + // If we initiated the splice but there are no new inputs on either side and no new output on our side, it's a cpfp. if (action.fundingTx.fundingParams.isInitiator && action.fundingTx.sharedTx.tx.localInputs.isEmpty() && action.fundingTx.sharedTx.tx.remoteInputs.isEmpty() && action.fundingTx.fundingParams.localOutputs.isEmpty()) { add(ChannelAction.Storage.StoreOutgoingPayment.ViaSpliceCpfp(miningFees = action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi(), txId = action.fundingTx.txId)) } - liquidityLease?.let { lease -> - // The actual mining fees contain the inputs and outputs we paid for in the interactive-tx transaction, - // and what we refunded the remote peer for some of their inputs and outputs via the lease. - val miningFees = action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi() + lease.fees.miningFee - add(ChannelAction.Storage.StoreOutgoingPayment.ViaInboundLiquidityRequest(txId = action.fundingTx.txId, miningFees = miningFees, lease = lease)) + // We may buy liquidity explicitly, outside of the context of an incoming payment where we automatically buy liquidity. + if (liquidityLease != null && origins.isEmpty()) { + val miningFees = action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi() + liquidityLease.fees.miningFee + add(ChannelAction.Storage.StoreOutgoingPayment.ViaInboundLiquidityRequest(txId = action.fundingTx.txId, miningFees = miningFees, lease = liquidityLease)) } if (staticParams.useZeroConf) { logger.info { "channel is using 0-conf, sending splice_locked right away" } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt index 90bcd8d47..aad4bc8e5 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt @@ -6,10 +6,7 @@ import fr.acinq.lightning.ChannelEvents import fr.acinq.lightning.channel.* import fr.acinq.lightning.channel.Helpers.Funding.computeChannelId import fr.acinq.lightning.utils.msat -import fr.acinq.lightning.wire.AcceptDualFundedChannel -import fr.acinq.lightning.wire.Error -import fr.acinq.lightning.wire.LiquidityAds -import fr.acinq.lightning.wire.OpenDualFundedChannel +import fr.acinq.lightning.wire.* /* * We initiated a channel open and are waiting for our peer to accept it. @@ -125,6 +122,15 @@ data class WaitForAcceptChannel( } } } + is CancelOnTheFlyFunding -> { + // Our peer won't accept this on-the-fly funding attempt: they didn't receive the preimage in time and failed the corresponding HTLCs. + // We should stop retrying and sending open_channel, they will keep rejecting it anyway. + val actions = when (channelOrigin) { + is Origin.OffChainPayment -> listOf(ChannelAction.Storage.StoreIncomingPayment.Cancelled(channelOrigin)) + else -> listOf() + } + Pair(Aborted, actions) + } is Error -> handleRemoteError(cmd.message) else -> unhandled(cmd) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForInit.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForInit.kt index cf3dac567..76d1708e3 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForInit.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForInit.kt @@ -4,10 +4,7 @@ import fr.acinq.lightning.blockchain.BITCOIN_FUNDING_DEPTHOK import fr.acinq.lightning.blockchain.BITCOIN_FUNDING_SPENT import fr.acinq.lightning.blockchain.WatchConfirmed import fr.acinq.lightning.blockchain.WatchSpent -import fr.acinq.lightning.channel.ChannelAction -import fr.acinq.lightning.channel.ChannelCommand -import fr.acinq.lightning.channel.Helpers -import fr.acinq.lightning.channel.LocalFundingStatus +import fr.acinq.lightning.channel.* import fr.acinq.lightning.utils.msat import fr.acinq.lightning.wire.ChannelTlv import fr.acinq.lightning.wire.OpenDualFundedChannel @@ -56,6 +53,10 @@ data object WaitForInit : ChannelState() { add(ChannelTlv.ChannelTypeTlv(cmd.channelType)) cmd.requestRemoteFunding?.let { add(it.requestFunds) } if (cmd.pushAmount > 0.msat) add(ChannelTlv.PushAmountTlv(cmd.pushAmount)) + when (cmd.channelOrigin) { + is Origin.OffChainPayment -> add(ChannelTlv.OnTheFlyFundingPreimage(cmd.channelOrigin.paymentPreimage)) + else -> {} + } } ) ) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt index 2777263c8..c07730b52 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt @@ -46,22 +46,33 @@ interface IncomingPaymentsDb { * Mark an incoming payment as received (paid). * Note that this function assumes that there is a matching payment request in the DB, otherwise it will be a no-op. * - * With pay-to-open, there is a delay before we receive the parts, and we may not receive any parts at all if the pay-to-open - * was cancelled due to a disconnection. That is why the payment should not be considered received (and not be displayed to - * the user) if there are no parts. + * With on-the-fly funding, there is a delay before we receive the on-chain funds. + * We may not receive them at all if our peer has cancelled the corresponding HTLCs before receiving the preimage. + * We first mark the on-chain payment as [IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending]. + * If the on-chain transaction is correctly created, we update it to [IncomingPayment.ReceivedWith.OnChainIncomingPayment.Received]. + * If it is irrevocably cancelled, we update it to [IncomingPayment.ReceivedWith.OnChainIncomingPayment.Cancelled]. * - * This method is additive: - * - receivedWith set is appended to the existing set in database. - * - receivedAt must be updated in database. + * This method is called every time a part completes, so we must: + * - update [receivedAt] in the database. + * - when [receivedWith] contains [IncomingPayment.ReceivedWith.LightningPayment] parts, they must be appended to the existing [receivedWith] from the database. + * - when [receivedWith] contains [IncomingPayment.ReceivedWith.OnChainIncomingPayment], we must remove any [IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending] + * from the database and add the new [IncomingPayment.ReceivedWith.OnChainIncomingPayment] part. * * @param receivedWith Is a set containing the payment parts holding the incoming amount. */ suspend fun receivePayment(paymentHash: ByteVector32, receivedWith: List, receivedAt: Long = currentTimestampMillis()) + /** + * List incoming payments that have incomplete on-the-fly funding (they contain an [IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending] part). + * This usually happens when we disconnect before signing the corresponding funding transaction. + * Our peer keeps track of what they owe us, so we can re-send the corresponding open_channel or splice_init message to restart the funding flow. + */ + suspend fun listPendingOnTheFlyPayments(): List> + /** List expired unpaid normal payments created within specified time range (with the most recent payments first). */ suspend fun listExpiredPayments(fromCreatedAt: Long = 0, toCreatedAt: Long = currentTimestampMillis()): List - /** Remove a pending incoming payment.*/ + /** Remove a pending (unpaid) incoming payment. */ suspend fun removeIncomingPayment(paymentHash: ByteVector32): Boolean } @@ -133,12 +144,12 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r * confirmed (if zero-conf is used), but both sides have to agree that the funds are * usable, a.k.a. "locked". */ - override val completedAt: Long? - get() = when { - received == null -> null // payment has not yet been received - received.receivedWith.any { it is ReceivedWith.OnChainIncomingPayment && it.lockedAt == null } -> null // payment has been received, but there is at least one unconfirmed on-chain part - else -> received.receivedAt - } + override val completedAt: Long? = when { + received == null -> null // payment has not yet been received + received.receivedWith.any { it is ReceivedWith.OnChainIncomingPayment.Pending } -> null // on-chain part is still pending + received.receivedWith.any { it is ReceivedWith.OnChainIncomingPayment.Received && it.lockedAt == null } -> null // payment has been received, but there is at least one unconfirmed on-chain part + else -> received.receivedAt + } /** Total fees paid to receive this payment. */ override val fees: MilliSatoshi = received?.fees ?: 0.msat @@ -156,67 +167,92 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r /** DEPRECATED: this is the legacy trusted swap-in, which we keep for backwards-compatibility (previous payments inside the DB). */ data class SwapIn(val address: String?) : Origin() - /** Trustless swap-in (dual-funding or splice-in) */ + /** Trustless swap-in (dual-funding or splice-in). */ data class OnChain(val txId: TxId, val localInputs: Set) : Origin() } data class Received(val receivedWith: List, val receivedAt: Long = currentTimestampMillis()) { - /** Total amount received after applying the fees. */ - val amount: MilliSatoshi = receivedWith.map { it.amount }.sum() - - /** Fees applied to receive this payment. */ - val fees: MilliSatoshi = receivedWith.map { it.fees }.sum() + /** Total amount received after subtracting the fees. */ + val amount: MilliSatoshi = receivedWith.map { + when (it) { + is ReceivedWith.LightningPayment -> it.amount + is ReceivedWith.OnChainIncomingPayment.Received -> it.amount + is ReceivedWith.OnChainIncomingPayment.Cancelled -> 0.msat + is ReceivedWith.OnChainIncomingPayment.Pending -> 0.msat + } + }.sum() + + /** Fees paid to receive this payment. */ + val fees: MilliSatoshi = receivedWith.map { + when (it) { + is ReceivedWith.LightningPayment -> it.fees + is ReceivedWith.OnChainIncomingPayment.Received -> it.fees + is ReceivedWith.OnChainIncomingPayment.Cancelled -> 0.msat + is ReceivedWith.OnChainIncomingPayment.Pending -> 0.msat + } + }.sum() } sealed class ReceivedWith { - /** Amount received for this part after applying the fees. This is the final amount we can use. */ + /** Amount received for this part after subtracting the fees. This is the final amount we can use. */ abstract val amount: MilliSatoshi - /** Fees applied to receive this part. Is zero for Lightning payments. */ + /** Fees paid to receive this part (zero for lightning payments). */ abstract val fees: MilliSatoshi - /** Payment was received via existing lightning channels. */ + /** Payment was received off-chain via existing lightning channels. */ data class LightningPayment(override val amount: MilliSatoshi, val channelId: ByteVector32, val htlcId: Long) : ReceivedWith() { override val fees: MilliSatoshi = 0.msat // with Lightning, the fee is paid by the sender } + /** Payment was received by pushing funds using an on-chain transaction. */ sealed class OnChainIncomingPayment : ReceivedWith() { - abstract val serviceFee: MilliSatoshi - abstract val miningFee: Satoshi - override val fees: MilliSatoshi get() = serviceFee + miningFee.toMilliSatoshi() - abstract val channelId: ByteVector32 - abstract val txId: TxId - abstract val confirmedAt: Long? - abstract val lockedAt: Long? - } + /** An on-chain transaction was initiated for this payment, but isn't guaranteed to complete yet. */ + data class Pending(override val amount: MilliSatoshi) : OnChainIncomingPayment() { + // We don't know the final fees yet, they depend on the feerate we use for the funding transaction. + override val fees: MilliSatoshi = 0.msat + } - /** - * Payment was received via a new channel opened to us. - * - * @param amount Our side of the balance of this channel when it's created. This is the amount pushed to us once the creation fees are applied. - * @param serviceFee Fees paid to Lightning Service Provider to open this channel. - * @param miningFee Feed paid to bitcoin miners for processing the L1 transaction. - * @param channelId The long id of the channel created to receive this payment. May be null if the channel id is not known. - */ - data class NewChannel( - override val amount: MilliSatoshi, - override val serviceFee: MilliSatoshi, - override val miningFee: Satoshi, - override val channelId: ByteVector32, - override val txId: TxId, - override val confirmedAt: Long?, - override val lockedAt: Long? - ) : OnChainIncomingPayment() - - data class SpliceIn( - override val amount: MilliSatoshi, - override val serviceFee: MilliSatoshi, - override val miningFee: Satoshi, - override val channelId: ByteVector32, - override val txId: TxId, - override val confirmedAt: Long?, - override val lockedAt: Long? - ) : OnChainIncomingPayment() + /** + * An on-chain transaction was initiated for this payment but couldn't complete, potentially resulting in a partial payment. + * This usually indicates a cheating attempt or data loss on the service provider end: users should contact support. + */ + data class Cancelled(override val amount: MilliSatoshi) : OnChainIncomingPayment() { + override val fees: MilliSatoshi = 0.msat + } + + sealed class Received : OnChainIncomingPayment() { + abstract val serviceFee: MilliSatoshi + abstract val miningFee: Satoshi + override val fees: MilliSatoshi get() = serviceFee + miningFee.toMilliSatoshi() + abstract val channelId: ByteVector32 + abstract val txId: TxId + abstract val confirmedAt: Long? + abstract val lockedAt: Long? + + /** Payment was received by pushing funds in a new channel opened to us. */ + data class NewChannel( + override val amount: MilliSatoshi, + override val serviceFee: MilliSatoshi, + override val miningFee: Satoshi, + override val channelId: ByteVector32, + override val txId: TxId, + override val confirmedAt: Long?, + override val lockedAt: Long? + ) : Received() + + /** Payment was received by pushing funds during a splice on our existing channel. */ + data class SpliceIn( + override val amount: MilliSatoshi, + override val serviceFee: MilliSatoshi, + override val miningFee: Satoshi, + override val channelId: ByteVector32, + override val txId: TxId, + override val confirmedAt: Long?, + override val lockedAt: Long? + ) : Received() + } + } } /** A payment expires if its origin is [Origin.Invoice] and its invoice has expired. [Origin.KeySend] or [Origin.SwapIn] do not expire. */ diff --git a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index feec19ac1..bf713433c 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -12,11 +12,12 @@ import fr.acinq.lightning.channel.* import fr.acinq.lightning.channel.states.* import fr.acinq.lightning.crypto.noise.* import fr.acinq.lightning.db.* -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.MDCLogger +import fr.acinq.lightning.logging.mdc +import fr.acinq.lightning.logging.withMDC import fr.acinq.lightning.payment.* import fr.acinq.lightning.serialization.Encryption.from import fr.acinq.lightning.serialization.Serialization.DeserializationResult -import fr.acinq.lightning.transactions.Scripts import fr.acinq.lightning.transactions.Transactions import fr.acinq.lightning.utils.* import fr.acinq.lightning.utils.UUID.Companion.randomUUID @@ -48,7 +49,16 @@ data class OpenOrSpliceChannel(val walletInputs: List) : PeerC val totalAmount: Satoshi = walletInputs.map { it.amount }.sum() } -data class PeerConnection(val id: Long, val output: Channel, val logger: MDCLogger) { +/** + * Initiate a channel open or a splice to allow receiving an off-chain payment. + * + * @param paymentAmount total payment amount (including amount that may have been received with HTLCs). + */ +data class OpenOrSplicePayment(val paymentAmount: MilliSatoshi, val preimage: ByteVector32) : PeerCommand() { + val paymentHash: ByteVector32 = Crypto.sha256(preimage).byteVector32() +} + +data class PeerConnection(val id: Long, val output: Channel, val delayedCommands: Channel, val logger: MDCLogger) { fun send(msg: LightningMessage) { // We can safely use trySend because we use unlimited channel buffers. // If the connection was closed, the message will automatically be dropped. @@ -70,7 +80,6 @@ data object Disconnected : PeerCommand() sealed class PaymentCommand : PeerCommand() private data object CheckPaymentsTimeout : PaymentCommand() -data class PayToOpenResponseCommand(val payToOpenResponse: PayToOpenResponse) : PeerCommand() data class SendPayment(val paymentId: UUID, val amount: MilliSatoshi, val recipient: PublicKey, val paymentRequest: PaymentRequest, val trampolineFeesOverride: List? = null) : PaymentCommand() { val paymentHash: ByteVector32 = paymentRequest.paymentHash } @@ -158,7 +167,7 @@ class Peer( val eventsFlow: SharedFlow get() = _eventsFlow.asSharedFlow() // encapsulates logic for validating incoming payments - private val incomingPaymentHandler = IncomingPaymentHandler(nodeParams, db.payments) + private val incomingPaymentHandler = IncomingPaymentHandler(nodeParams, db.payments, leaseRate) // encapsulates logic for sending payments private val outgoingPaymentHandler = OutgoingPaymentHandler(nodeParams, walletParams, db.payments) @@ -316,7 +325,7 @@ class Peer( val session = LightningSession(enc, dec, ck) // TODO use atomic counter instead - val peerConnection = PeerConnection(connectionId, Channel(UNLIMITED), logger) + val peerConnection = PeerConnection(connectionId, Channel(UNLIMITED), Channel(UNLIMITED), logger) // Inform the peer about the new connection. input.send(Connected(peerConnection)) connectionJob = connectionLoop(socket, session, peerConnection, logger) @@ -382,6 +391,16 @@ class Peer( } } + suspend fun processDelayedCommands() { + while (isActive) { + for (cmd in peerConnection.delayedCommands) { + delay(3.seconds) + logger.info { "processing delayed command ${cmd::class.simpleName}" } + input.send(cmd) + } + } + } + suspend fun receiveLoop() { try { while (isActive) { @@ -427,6 +446,7 @@ class Peer( launch(CoroutineName("keep-alive")) { doPing() } launch(CoroutineName("check-payments-timeout")) { checkPaymentsTimeout() } + launch(CoroutineName("process-delayed-commands")) { processDelayedCommands() } launch(CoroutineName("send-loop")) { sendLoop() } val receiveJob = launch(CoroutineName("receive-loop")) { receiveLoop() } // Suspend until the coroutine is cancelled or the socket is closed. @@ -653,6 +673,12 @@ class Peer( peerConnection?.send(message) } + /** Return true if we are currently funding a channel. */ + private fun channelFundingIsInProgress(): Boolean = when (val channel = _channels.values.firstOrNull { it is Normal }) { + is Normal -> channel.spliceStatus != SpliceStatus.None + else -> _channels.values.any { it is WaitForAcceptChannel || it is WaitForFundingCreated || it is WaitForFundingSigned || it is WaitForFundingConfirmed || it is WaitForChannelReady } + } + private suspend fun processActions(channelId: ByteVector32, peerConnection: PeerConnection?, actions: List) { // we peek into the actions to see if the id of the channel is going to change, but we're not processing it yet val actualChannelId = actions.filterIsInstance().firstOrNull()?.channelId ?: channelId @@ -677,7 +703,6 @@ class Peer( null -> logger.debug { "non-final error, more partial payments are still pending: ${action.error.message}" } } } - is ChannelAction.ProcessCmdRes.AddSettledFail -> { val currentTip = currentTipFlow.filterNotNull().first() when (val result = outgoingPaymentHandler.processAddSettled(actualChannelId, action, _channels, currentTip.first)) { @@ -691,7 +716,6 @@ class Peer( null -> logger.debug { "non-final error, more partial payments are still pending: ${action.result}" } } } - is ChannelAction.ProcessCmdRes.AddSettledFulfill -> { when (val result = outgoingPaymentHandler.processAddSettled(action)) { is OutgoingPaymentHandler.Success -> _eventsFlow.emit(PaymentSent(result.request, result.payment)) @@ -699,26 +723,21 @@ class Peer( null -> logger.debug { "unknown payment" } } } - is ChannelAction.Storage.StoreState -> { logger.info { "storing state=${action.data::class.simpleName}" } db.channels.addOrUpdateChannel(action.data) } - is ChannelAction.Storage.RemoveChannel -> { logger.info { "removing channelId=${action.data.channelId} state=${action.data::class.simpleName}" } db.channels.removeChannel(action.data.channelId) } - is ChannelAction.Storage.StoreHtlcInfos -> { action.htlcs.forEach { db.channels.addHtlcInfo(actualChannelId, it.commitmentNumber, it.paymentHash, it.cltvExpiry) } } - is ChannelAction.Storage.StoreIncomingPayment -> { logger.info { "storing incoming payment $action" } incomingPaymentHandler.process(actualChannelId, action) } - is ChannelAction.Storage.StoreOutgoingPayment -> { logger.info { "storing $action" } db.payments.addOutgoingPayment( @@ -774,24 +793,19 @@ class Peer( ) _eventsFlow.emit(ChannelClosing(channelId)) } - is ChannelAction.Storage.SetLocked -> { logger.info { "setting status locked for txid=${action.txId}" } db.payments.setLocked(action.txId) } - is ChannelAction.Storage.GetHtlcInfos -> { val htlcInfos = db.channels.listHtlcInfos(actualChannelId, action.commitmentNumber).map { ChannelAction.Storage.HtlcInfo(actualChannelId, action.commitmentNumber, it.first, it.second) } input.send(WrappedChannelCommand(actualChannelId, ChannelCommand.Closing.GetHtlcInfosResponse(action.revokedCommitTxId, htlcInfos))) } - is ChannelAction.ChannelId.IdAssigned -> { logger.info { "switching channel id from ${action.temporaryChannelId} to ${action.channelId}" } _channels[action.temporaryChannelId]?.let { _channels = _channels + (action.channelId to it) - action.temporaryChannelId } } - is ChannelAction.EmitEvent -> nodeParams._nodeEvents.emit(action.event) - is ChannelAction.Disconnect -> { logger.warning { "channel disconnected due to a protocol error" } disconnect() @@ -801,11 +815,12 @@ class Peer( } } - private suspend fun processIncomingPayment(item: Either) { + private suspend fun processIncomingPayment(item: Either) { val currentBlockHeight = currentTipFlow.filterNotNull().first().first + val currentFeerate = peerFeeratesFlow.filterNotNull().first().fundingFeerate val result = when (item) { - is Either.Right -> incomingPaymentHandler.process(item.value, currentBlockHeight) - is Either.Left -> incomingPaymentHandler.process(item.value, currentBlockHeight) + is Either.Right -> incomingPaymentHandler.process(item.value, currentBlockHeight, currentFeerate) + is Either.Left -> incomingPaymentHandler.process(item.value, currentBlockHeight, currentFeerate) } when (result) { is IncomingPaymentHandler.ProcessAddResult.Accepted -> { @@ -880,7 +895,11 @@ class Peer( is Connected -> { logger.info { "new connection with id=${cmd.peerConnection.id}, sending init $ourInit" } peerConnection = cmd.peerConnection - peerConnection?.send(ourInit) + cmd.peerConnection.send(ourInit) + // Check pending on-the-fly funding requests: we must re-send open_channel or splice_init. + db.payments.listPendingOnTheFlyPayments().forEach { (payment, pending) -> + cmd.peerConnection.delayedCommands.send(OpenOrSplicePayment(pending.amount, payment.preimage)) + } } is MessageReceived -> { if (cmd.connectionId != peerConnection?.id) { @@ -1027,24 +1046,22 @@ class Peer( _channels = _channels + (state.channelId to state1) } } - is PayToOpenRequest -> { - logger.info { "received ${msg::class.simpleName}" } - // If a channel is currently being created, it can't process splices yet. We could accept this payment, but - // it wouldn't be reflected in the user balance until the channel is ready, because we only insert - // the payment in db when we will process the corresponding splice and see the pay-to-open origin. This - // can take a long time depending on the confirmation speed. It is better and simpler to reject the incoming - // payment rather that having the user wonder where their money went. - val channelInitializing = _channels.isNotEmpty() - && !_channels.values.any { it is Normal } // we don't have a channel that can be spliced - && _channels.values.any { it is WaitForFundingSigned || it is WaitForFundingConfirmed || it is WaitForChannelReady } // but we will have one soon - if (channelInitializing) { - val rejected = LiquidityEvents.Rejected(msg.amountMsat, msg.payToOpenFeeSatoshis.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.ChannelInitializing) - logger.info { "rejecting pay-to-open: reason=${rejected.reason}" } - nodeParams._nodeEvents.emit(rejected) - val action = IncomingPaymentHandler.actionForPayToOpenFailure(nodeParams.nodePrivateKey, TemporaryNodeFailure, msg) - input.send(action) + is MaybeAddHtlc -> { + // If we don't support on-the-fly funding, we simply ignore that proposal. + // Our peer will fail the corresponding HTLCs after a small delay. + if (nodeParams.features.hasFeature(Feature.OnTheFlyFundingClient) && nodeParams.liquidityPolicy.value is LiquidityPolicy.Auto) { + // If a channel funding attempt is already in progress, we won't be able to immediately accept the payment. + // Once the channel funding is complete, we may have enough inbound liquidity to receive the payment without + // an on-chain operation, which is more efficient. We thus reject the payment and wait for the sender to retry. + if (channelFundingIsInProgress()) { + val rejected = LiquidityEvents.Rejected(msg.amount, 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.ChannelFundingInProgress) + logger.info { "ignoring maybe_add_htlc: reason=${rejected.reason}" } + nodeParams._nodeEvents.emit(rejected) + } else { + processIncomingPayment(Either.Left(msg)) + } } else { - processIncomingPayment(Either.Left(msg)) + logger.info { "ignoring on-the-fly funding (amount=${msg.amount}): disabled by policy" } } } is PhoenixAndroidLegacyInfo -> { @@ -1129,16 +1146,15 @@ class Peer( // Either there are no channels, or they will never be suitable for a splice-in: we open a new channel. val currentFeerates = peerFeeratesFlow.filterNotNull().first() val requestRemoteFunding = run { - val inboundLiquidityTarget = when (val policy = nodeParams.liquidityPolicy.first()) { - is LiquidityPolicy.Disable -> null - is LiquidityPolicy.Auto -> policy.inboundLiquidityTarget - } // We need our peer to contribute, because they must have enough funds to pay the commitment fees. - // If we don't have an inbound liquidity target set, we use a default amount of 100 000 sat. - LiquidityAds.RequestRemoteFunding(inboundLiquidityTarget ?: 100_000.sat, currentTipFlow.filterNotNull().first().first, leaseRate) + val inboundLiquidityTarget = when (val policy = nodeParams.liquidityPolicy.value) { + is LiquidityPolicy.Disable -> LiquidityPolicy.minInboundLiquidityTarget // we don't disable creating a channel using our own wallet inputs + is LiquidityPolicy.Auto -> policy.inboundLiquidityTarget ?: LiquidityPolicy.minInboundLiquidityTarget + } + LiquidityAds.RequestRemoteFunding(inboundLiquidityTarget, currentTipFlow.filterNotNull().first().first, leaseRate) } val (localFundingAmount, fees) = run { - val dummyFundingScript = Script.write(Scripts.multiSig2of2(Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey)).byteVector() + val dummyFundingScript = Helpers.Funding.makeFundingPubKeyScript(Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey) val localMiningFee = Transactions.weight2fee(currentFeerates.fundingFeerate, FundingContributions.computeWeightPaid(isInitiator = true, null, dummyFundingScript, cmd.walletInputs, emptyList())) // We directly pay the on-chain fees for our inputs/outputs of the transaction. val localFundingAmount = cmd.totalAmount - localMiningFee @@ -1151,7 +1167,7 @@ class Peer( logger.warning { "cannot create channel, not enough funds to pay fees (fees=${fees.total}, available=${cmd.totalAmount})" } swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) } else { - when (val rejected = nodeParams.liquidityPolicy.first().maybeReject(requestRemoteFunding.fundingAmount.toMilliSatoshi(), fees.total.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger)) { + when (val rejected = nodeParams.liquidityPolicy.value.maybeReject(requestRemoteFunding.fundingAmount.toMilliSatoshi(), fees.total.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger)) { is LiquidityEvents.Rejected -> { logger.info { "rejecting channel open: reason=${rejected.reason}" } nodeParams._nodeEvents.emit(rejected) @@ -1185,15 +1201,100 @@ class Peer( } } else { // There are existing channels but not immediately usable (e.g. creating, disconnected), we don't do anything yet. - logger.info { "ignoring request to add utxos to channel, existing channels are not ready for splice-in: ${channels.values.map { it::class.simpleName }}" } + logger.warning { "ignoring request to add utxos to channel, existing channels are not ready for splice-in: ${channels.values.map { it::class.simpleName }}" } swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) } } } } - is PayToOpenResponseCommand -> { - logger.info { "sending ${cmd.payToOpenResponse::class.simpleName}" } - peerConnection?.send(cmd.payToOpenResponse) + is OpenOrSplicePayment -> { + val channel = channels.values.firstOrNull { it is Normal } + val currentFeerates = peerFeeratesFlow.filterNotNull().first() + // We need our peer to contribute, because they must have enough funds to pay the commitment fees. + // They will fund more than what we request to also cover the maybe_add_htlc parts that they will push to us. + // We only pay fees for the additional liquidity we request, not for the maybe_add_htlc amounts. + val remoteFundingAmount = when (val policy = nodeParams.liquidityPolicy.value) { + // We already checked our liquidity policy in the IncomingPaymentHandler before accepting the payment. + // If it is now disabled, it means the user concurrently updated their policy, but we're already committed + // to accepting this payment, which passed the previous policy. + is LiquidityPolicy.Disable -> LiquidityPolicy.minInboundLiquidityTarget + is LiquidityPolicy.Auto -> policy.inboundLiquidityTarget ?: LiquidityPolicy.minInboundLiquidityTarget + } + val requestRemoteFunding = LiquidityAds.RequestRemoteFunding(remoteFundingAmount, currentTipFlow.filterNotNull().first().first, leaseRate) + when { + channelFundingIsInProgress() -> { + logger.warning { "delaying on-the-fly funding, funding is already in progress" } + peerConnection?.delayedCommands?.send(cmd) + } + channel is Normal -> { + // We don't contribute any input or output, but we must pay on-chain fees for the shared input and output. + // We pay those on-chain fees using our current channel balance. + val localBalance = channel.commitments.active.first().localCommit.spec.toLocal + val weight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = channel.commitments.active.first(), walletInputs = listOf(), localOutputs = emptyList()) + val (targetFeerate, localMiningFee) = watcher.client.computeSpliceCpfpFeerate(channel.commitments, currentFeerates.fundingFeerate, spliceWeight = weight, logger) + val fundingFeerate = when { + localBalance <= localMiningFee * 0.75 -> { + // Our current balance is too low to pay the on-chain fees. + // We consume all of it in on-chain fees, and also target a higher feerate. + // This ensures that the resulting feerate won't be too low compared to our target. + // We must cover the shared input and the shared output, which is a lot of weight, so we add 50%. + targetFeerate * 1.5 + } + else -> targetFeerate + } + val leaseFees = leaseRate.fees(fundingFeerate, remoteFundingAmount, remoteFundingAmount) + val totalFees = TransactionFees(miningFee = localMiningFee.min(localBalance.truncateToSatoshi()) + leaseFees.miningFee, serviceFee = leaseFees.serviceFee) + logger.info { "requesting on-the-fly splice for paymentHash=${cmd.paymentHash} feerate=$fundingFeerate fee=${totalFees.total}" } + val spliceCommand = ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = null, + spliceOut = null, + requestRemoteFunding = requestRemoteFunding, + feerate = fundingFeerate, + origins = listOf(Origin.OffChainPayment(cmd.preimage, cmd.paymentAmount, totalFees)) + ) + val (state, actions) = channel.process(spliceCommand) + _channels = _channels + (channel.channelId to state) + processActions(channel.channelId, peerConnection, actions) + } + channels.values.all { it is ShuttingDown || it is Negotiating || it is Closing || it is Closed || it is Aborted } -> { + // We ask our peer to pay the commit tx fees. + val localParams = LocalParams(nodeParams, isChannelOpener = true, payCommitTxFees = false) + val channelFlags = ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = true) + // Since we don't have inputs to contribute, we're unable to pay on-chain fees for the shared output. + // We target a higher feerate so that the effective feerate isn't too low compared to our target. + // We must cover the shared output, which doesn't add too much weight, so we add 25%. + val fundingFeerate = currentFeerates.fundingFeerate * 1.25 + val leaseFees = leaseRate.fees(fundingFeerate, remoteFundingAmount, remoteFundingAmount) + // We don't pay any local on-chain fees, our fee is only for the liquidity lease. + val totalFees = TransactionFees(miningFee = leaseFees.miningFee, serviceFee = leaseFees.serviceFee) + logger.info { "requesting on-the-fly channel for paymentHash=${cmd.paymentHash} feerate=$fundingFeerate fee=${leaseFees.total}" } + val (state, actions) = WaitForInit.process( + ChannelCommand.Init.Initiator( + fundingAmount = 0.sat, // we don't have funds to contribute + pushAmount = 0.msat, + walletInputs = listOf(), + commitTxFeerate = currentFeerates.commitmentFeerate, + fundingTxFeerate = fundingFeerate, + localParams = localParams, + remoteInit = theirInit!!, + channelFlags = channelFlags, + channelConfig = ChannelConfig.standard, + channelType = ChannelType.SupportedChannelType.AnchorOutputsZeroReserve, + requestRemoteFunding = requestRemoteFunding, + channelOrigin = Origin.OffChainPayment(cmd.preimage, cmd.paymentAmount, totalFees), + ) + ) + val msg = actions.filterIsInstance().map { it.message }.filterIsInstance().first() + _channels = _channels + (msg.temporaryChannelId to state) + processActions(msg.temporaryChannelId, peerConnection, actions) + } + else -> { + // There is an existing channel but not immediately usable (e.g. disconnected), we don't do anything yet. + logger.warning { "delaying on-the-fly funding, existing channels are not ready for splice-in: ${channels.values.map { it::class.simpleName }}" } + peerConnection?.delayedCommands?.send(cmd) + } + } } is SendPayment -> { val currentTip = currentTipFlow.filterNotNull().first() diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index fd83e8166..92c374938 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -7,12 +7,16 @@ import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 +import fr.acinq.lightning.LiquidityEvents +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.NodeParams +import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.ChannelAction import fr.acinq.lightning.channel.ChannelCommand import fr.acinq.lightning.channel.Origin import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.IncomingPaymentsDb -import fr.acinq.lightning.io.PayToOpenResponseCommand +import fr.acinq.lightning.io.OpenOrSplicePayment import fr.acinq.lightning.io.PeerCommand import fr.acinq.lightning.io.WrappedChannelCommand import fr.acinq.lightning.logging.MDCLogger @@ -34,14 +38,14 @@ data class HtlcPart(val htlc: UpdateAddHtlc, override val finalPayload: PaymentO override fun toString(): String = "htlc(channelId=${htlc.channelId},id=${htlc.id})" } -data class PayToOpenPart(val payToOpenRequest: PayToOpenRequest, override val finalPayload: PaymentOnion.FinalPayload) : PaymentPart() { - override val amount: MilliSatoshi = payToOpenRequest.amountMsat +data class OnTheFlyFundingPart(val htlc: MaybeAddHtlc, override val finalPayload: PaymentOnion.FinalPayload) : PaymentPart() { + override val amount: MilliSatoshi = htlc.amount override val totalAmount: MilliSatoshi = finalPayload.totalAmount - override val paymentHash: ByteVector32 = payToOpenRequest.paymentHash - override fun toString(): String = "pay-to-open(amount=${payToOpenRequest.amountMsat})" + override val paymentHash: ByteVector32 = htlc.paymentHash + override fun toString(): String = "maybe-htlc(amount=${htlc.amount})" } -class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPaymentsDb) { +class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPaymentsDb, val leaseRate: LiquidityAds.LeaseRate) { sealed class ProcessAddResult { abstract val actions: List @@ -107,13 +111,13 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment /** * Save the "received-with" details of an incoming amount. * - * - for a pay-to-open origin, the payment already exists and we only add a received-with. - * - for a swap-in origin, a new incoming payment must be created. We use a random. + * - for an off-chain origin, the payment already exists and we only add a received-with. + * - for a swap-in origin, a new incoming payment must be created with a random payment hash. */ suspend fun process(channelId: ByteVector32, action: ChannelAction.Storage.StoreIncomingPayment) { val receivedWith = when (action) { is ChannelAction.Storage.StoreIncomingPayment.ViaNewChannel -> - IncomingPayment.ReceivedWith.NewChannel( + IncomingPayment.ReceivedWith.OnChainIncomingPayment.Received.NewChannel( amount = action.amount, serviceFee = action.serviceFee, miningFee = action.miningFee, @@ -123,7 +127,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment lockedAt = null, ) is ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn -> - IncomingPayment.ReceivedWith.SpliceIn( + IncomingPayment.ReceivedWith.OnChainIncomingPayment.Received.SpliceIn( amount = action.amount, serviceFee = action.serviceFee, miningFee = action.miningFee, @@ -132,26 +136,23 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment confirmedAt = null, lockedAt = null, ) + is ChannelAction.Storage.StoreIncomingPayment.Cancelled -> { + logger.warning { "channelId:$channelId on-the-fly funding cancelled by our peer, payment may be partially received" } + val event = LiquidityEvents.Rejected(action.origin.amount, action.origin.fees.total.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.ChannelFundingCancelled(action.origin.paymentHash)) + nodeParams._nodeEvents.emit(event) + IncomingPayment.ReceivedWith.OnChainIncomingPayment.Cancelled(action.origin.amount) + } } when (val origin = action.origin) { - is Origin.OffChainPayment -> - // there already is a corresponding Lightning invoice in the db - db.receivePayment( - paymentHash = origin.paymentHash, - receivedWith = listOf(receivedWith) - ) + is Origin.OffChainPayment -> { + // There already is a corresponding invoice in the db. + db.receivePayment(origin.paymentHash, listOf(receivedWith)) nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(origin.paymentHash, listOf(receivedWith))) } else -> { - // this is a swap, there was no pre-existing invoice, we need to create a fake one - val incomingPayment = db.addIncomingPayment( - preimage = randomBytes32(), // not used, placeholder - origin = IncomingPayment.Origin.OnChain(action.txId, action.localInputs) - ) - db.receivePayment( - paymentHash = incomingPayment.paymentHash, - receivedWith = listOf(receivedWith) - ) + // This is a swap, there was no pre-existing invoice, we need to create a fake one. + val incomingPayment = db.addIncomingPayment(preimage = randomBytes32(), origin = IncomingPayment.Origin.OnChain(action.txId, action.localInputs)) + db.receivePayment(incomingPayment.paymentHash, listOf(receivedWith)) nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(incomingPayment.paymentHash, listOf(receivedWith))) } } @@ -161,11 +162,11 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment * Process an incoming htlc. * Before calling this, the htlc must be committed and ack-ed by both sides. * - * @return A result that indicates whether or not the packet was - * accepted, rejected, or still pending (as the case may be for multipart payments). + * @return A result that indicates whether or not the packet was accepted, + * rejected, or still pending (as the case may be for multipart payments). * Also includes the list of actions to be queued. */ - suspend fun process(htlc: UpdateAddHtlc, currentBlockHeight: Int): ProcessAddResult { + suspend fun process(htlc: UpdateAddHtlc, currentBlockHeight: Int, currentFeerate: FeeratePerKw): ProcessAddResult { // Security note: // There are several checks we could perform before decrypting the onion. // However an error message here would differ from an error message below, @@ -173,27 +174,27 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment // So to prevent any kind of information leakage, we always peel the onion first. return when (val res = toPaymentPart(privateKey, htlc)) { is Either.Left -> res.value - is Either.Right -> processPaymentPart(res.value, currentBlockHeight) + is Either.Right -> processPaymentPart(res.value, currentBlockHeight, currentFeerate) } } /** - * Process an incoming pay-to-open request. + * Process an incoming on-the-fly funding request. * This is very similar to the processing of an htlc. */ - suspend fun process(payToOpenRequest: PayToOpenRequest, currentBlockHeight: Int): ProcessAddResult { - return when (val res = toPaymentPart(privateKey, payToOpenRequest)) { + suspend fun process(htlc: MaybeAddHtlc, currentBlockHeight: Int, currentFeerate: FeeratePerKw): ProcessAddResult { + return when (val res = toPaymentPart(privateKey, htlc, logger)) { is Either.Left -> res.value - is Either.Right -> processPaymentPart(res.value, currentBlockHeight) + is Either.Right -> processPaymentPart(res.value, currentBlockHeight, currentFeerate) } } /** Main payment processing, that handles payment parts. */ - private suspend fun processPaymentPart(paymentPart: PaymentPart, currentBlockHeight: Int): ProcessAddResult { + private suspend fun processPaymentPart(paymentPart: PaymentPart, currentBlockHeight: Int, currentFeerate: FeeratePerKw): ProcessAddResult { val logger = MDCLogger(logger.logger, staticMdc = paymentPart.mdc()) when (paymentPart) { - is HtlcPart -> logger.info { "processing htlc part expiry=${paymentPart.htlc.cltvExpiry}" } - is PayToOpenPart -> logger.info { "processing pay-to-open part amount=${paymentPart.payToOpenRequest.amountMsat} funding=${paymentPart.payToOpenRequest.fundingSatoshis} fees=${paymentPart.payToOpenRequest.payToOpenFeeSatoshis}" } + is HtlcPart -> logger.info { "processing htlc part amount=${paymentPart.htlc.amountMsat} expiry=${paymentPart.htlc.cltvExpiry}" } + is OnTheFlyFundingPart -> logger.info { "processing on-the-fly funding part amount=${paymentPart.htlc.amount} expiry=${paymentPart.htlc.expiry}" } } return when (val validationResult = validatePaymentPart(paymentPart, currentBlockHeight)) { is Either.Left -> validationResult.value @@ -222,10 +223,9 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment ProcessAddResult.Rejected(listOf(action), incomingPayment) } } - is PayToOpenPart -> { - logger.info { "rejecting pay-to-open part for an invoice that has already been paid" } - val action = actionForPayToOpenFailure(privateKey, IncorrectOrUnknownPaymentDetails(paymentPart.totalAmount, currentBlockHeight.toLong()), paymentPart.payToOpenRequest) - ProcessAddResult.Rejected(listOf(action), incomingPayment) + is OnTheFlyFundingPart -> { + logger.info { "ignoring on-the-fly funding part for an invoice that has already been paid" } + ProcessAddResult.Rejected(listOf(), incomingPayment) } } } else { @@ -235,13 +235,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment // Bolt 04: // - SHOULD fail the entire HTLC set if `total_msat` is not the same for all HTLCs in the set. logger.warning { "invalid total_amount_msat: ${paymentPart.totalAmount}, expected ${payment.totalAmount}" } - val actions = payment.parts.map { part -> - val failureMsg = IncorrectOrUnknownPaymentDetails(part.totalAmount, currentBlockHeight.toLong()) - when (part) { - is HtlcPart -> actionForFailureMessage(failureMsg, part.htlc) - is PayToOpenPart -> actionForPayToOpenFailure(privateKey, failureMsg, part.payToOpenRequest) // NB: this will fail all parts, we could only return one - } - } + val actions = payment.parts.filterIsInstance().map { actionForFailureMessage(IncorrectOrUnknownPaymentDetails(it.totalAmount, currentBlockHeight.toLong()), it.htlc) } pending.remove(paymentPart.paymentHash) return ProcessAddResult.Rejected(actions, incomingPayment) } @@ -251,57 +245,71 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment return ProcessAddResult.Pending(incomingPayment, payment) } else -> { - if (payment.parts.filterIsInstance().isNotEmpty()) { - // We consider the total amount received (not only the pay-to-open parts) to evaluate whether or not to accept the payment - val payToOpenFee = payment.parts.filterIsInstance().map { it.payToOpenRequest.payToOpenFeeSatoshis }.sum() - nodeParams.liquidityPolicy.value.maybeReject(payment.amountReceived, payToOpenFee.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, logger)?.let { rejected -> - logger.info { "rejecting pay-to-open: reason=${rejected.reason}" } - nodeParams._nodeEvents.emit(rejected) - val actions = payment.parts.map { part -> - val failureMsg = TemporaryNodeFailure - when (part) { - is HtlcPart -> actionForFailureMessage(failureMsg, part.htlc) - is PayToOpenPart -> actionForPayToOpenFailure(privateKey, failureMsg, part.payToOpenRequest) // NB: this will fail all parts, we could only return one + val htlcParts = payment.parts.filterIsInstance() + val onTheFlyFundingParts = payment.parts.filterIsInstance() + val onTheFlyAmount = onTheFlyFundingParts.map { it.amount }.sum() + val rejected = when { + onTheFlyFundingParts.isNotEmpty() -> { + val policy = nodeParams.liquidityPolicy.value + val fees = when (policy) { + is LiquidityPolicy.Disable -> 0.msat + is LiquidityPolicy.Auto -> { + val requestedAmount = policy.inboundLiquidityTarget ?: LiquidityPolicy.minInboundLiquidityTarget + leaseRate.fees(currentFeerate, requestedAmount, requestedAmount).total.toMilliSatoshi() } } - pending.remove(paymentPart.paymentHash) - return ProcessAddResult.Rejected(actions, incomingPayment) + when { + // We shouldn't initiate an on-the-fly funding if the remaining amount is too low to pay the fees. + onTheFlyAmount < fees * 2 -> LiquidityEvents.Rejected(payment.amountReceived, fees, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow(onTheFlyAmount)) + // We consider the total amount received (not only the on-the-fly funding parts) to evaluate our relative fee policy. + // A side effect is that if a large payment is only missing a small amount to be complete, we may still create a funding transaction for it. + // This makes sense, as the user likely wants to receive this large payment, and will obtain inbound liquidity for future payments. + else -> policy.maybeReject(payment.amountReceived, fees, LiquidityEvents.Source.OffChainPayment, logger) + } } + else -> null } - - when (val paymentMetadata = paymentPart.finalPayload.paymentMetadata) { - null -> logger.info { "payment received (${payment.amountReceived}) without payment metadata" } - else -> logger.info { "payment received (${payment.amountReceived}) with payment metadata ($paymentMetadata)" } - } - val htlcParts = payment.parts.filterIsInstance() - val payToOpenParts = payment.parts.filterIsInstance() - // We only fill the DB with htlc parts, because we cannot be sure yet that our peer will honor the pay-to-open part(s). - // When the payment contains pay-to-open parts, it will be considered received, but the sum of all parts will be smaller - // than the expected amount. The pay-to-open part(s) will be added once we received the corresponding new channel or a splice-in. - val receivedWith = htlcParts.map { part -> - IncomingPayment.ReceivedWith.LightningPayment( - amount = part.amount, - htlcId = part.htlc.id, - channelId = part.htlc.channelId - ) - } - val actions = buildList { - htlcParts.forEach { part -> - val cmd = ChannelCommand.Htlc.Settlement.Fulfill(part.htlc.id, incomingPayment.preimage, true) - add(WrappedChannelCommand(part.htlc.channelId, cmd)) + when (rejected) { + is LiquidityEvents.Rejected -> { + logger.info { "rejecting on-the-fly funding: reason=${rejected.reason}" } + nodeParams._nodeEvents.emit(rejected) + pending.remove(paymentPart.paymentHash) + val actions = htlcParts.map { actionForFailureMessage(TemporaryNodeFailure, it.htlc) } + ProcessAddResult.Rejected(actions, incomingPayment) } - // We avoid sending duplicate pay-to-open responses, since the preimage is the same for every part. - if (payToOpenParts.isNotEmpty()) { - val response = PayToOpenResponse(nodeParams.chainHash, incomingPayment.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage)) - add(PayToOpenResponseCommand(response)) + else -> { + when (val paymentMetadata = paymentPart.finalPayload.paymentMetadata) { + null -> logger.info { "payment received (${payment.amountReceived}) without payment metadata" } + else -> logger.info { "payment received (${payment.amountReceived}) with payment metadata ($paymentMetadata)" } + } + // When the payment involves on-the-fly funding, we reveal the preimage to our peer and trust them to fund a channel accordingly. + // We consider the payment received, but we can only fill the DB with the htlc parts. + // The on-the-fly funding part will be updated once the corresponding channel or splice completes. + val receivedWith = buildList { + htlcParts.forEach { part -> add(IncomingPayment.ReceivedWith.LightningPayment(part.amount, part.htlc.channelId, part.htlc.id)) } + if (onTheFlyFundingParts.isNotEmpty()) add(IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending(onTheFlyAmount)) + } + val actions = buildList { + // If an on-the-fly funding is required, we first ask our peer to initiate the funding process. + // This ensures they get the preimage as soon as possible and can record how much they owe us in case we disconnect. + if (onTheFlyFundingParts.isNotEmpty()) { + add(OpenOrSplicePayment(onTheFlyAmount, incomingPayment.preimage)) + } + htlcParts.forEach { part -> + val cmd = ChannelCommand.Htlc.Settlement.Fulfill(part.htlc.id, incomingPayment.preimage, true) + add(WrappedChannelCommand(part.htlc.channelId, cmd)) + } + } + // We can remove that payment from our in-memory state and store it in our DB. + pending.remove(paymentPart.paymentHash) + val received = IncomingPayment.Received(receivedWith = receivedWith) + db.receivePayment(paymentPart.paymentHash, received.receivedWith) + nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(paymentPart.paymentHash, received.receivedWith)) + // Now that the payment is stored in our DB, we fulfill it. + // If we disconnect before completing those actions, we will read from the DB and retry when reconnecting. + ProcessAddResult.Accepted(actions, incomingPayment.copy(received = received), received) } } - - pending.remove(paymentPart.paymentHash) - val received = IncomingPayment.Received(receivedWith = receivedWith) - db.receivePayment(paymentPart.paymentHash, received.receivedWith) - nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(paymentPart.paymentHash, received.receivedWith)) - return ProcessAddResult.Accepted(actions, incomingPayment.copy(received = received), received) } } } @@ -315,17 +323,17 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment return when { incomingPayment == null -> { logger.warning { "payment for which we don't have a preimage" } - Either.Left(rejectPaymentPart(privateKey, paymentPart, null, currentBlockHeight)) + Either.Left(rejectPaymentPart(paymentPart, null, currentBlockHeight)) } // Payments are rejected for expired invoices UNLESS invoice has already been paid // We must accept payments for already paid invoices, because it could be the channel replaying HTLCs that we already fulfilled incomingPayment.isExpired() && incomingPayment.received == null -> { logger.warning { "the invoice is expired" } - Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight)) + Either.Left(rejectPaymentPart(paymentPart, incomingPayment, currentBlockHeight)) } incomingPayment.origin !is IncomingPayment.Origin.Invoice -> { logger.warning { "unsupported payment type: ${incomingPayment.origin::class}" } - Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight)) + Either.Left(rejectPaymentPart(paymentPart, incomingPayment, currentBlockHeight)) } incomingPayment.origin.paymentRequest.paymentSecret != paymentPart.finalPayload.paymentSecret -> { // BOLT 04: @@ -338,7 +346,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment // // NB: We always include a paymentSecret, and mark the feature as mandatory. logger.warning { "payment with invalid paymentSecret (${paymentPart.finalPayload.paymentSecret})" } - Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight)) + Either.Left(rejectPaymentPart(paymentPart, incomingPayment, currentBlockHeight)) } incomingPayment.origin.paymentRequest.amount != null && paymentPart.totalAmount < incomingPayment.origin.paymentRequest.amount -> { // BOLT 04: @@ -346,7 +354,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment // - MUST fail the HTLC. // - MUST return an incorrect_or_unknown_payment_details error. logger.warning { "invalid amount (underpayment): ${paymentPart.totalAmount}, expected: ${incomingPayment.origin.paymentRequest.amount}" } - Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight)) + Either.Left(rejectPaymentPart(paymentPart, incomingPayment, currentBlockHeight)) } incomingPayment.origin.paymentRequest.amount != null && paymentPart.totalAmount > incomingPayment.origin.paymentRequest.amount * 2 -> { // BOLT 04: @@ -357,11 +365,11 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment // Note: this allows the origin node to reduce information leakage by altering // the amount while not allowing for accidental gross overpayment. logger.warning { "invalid amount (overpayment): ${paymentPart.totalAmount}, expected: ${incomingPayment.origin.paymentRequest.amount}" } - Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight)) + Either.Left(rejectPaymentPart(paymentPart, incomingPayment, currentBlockHeight)) } paymentPart is HtlcPart && paymentPart.htlc.cltvExpiry < minFinalCltvExpiry(incomingPayment.origin.paymentRequest, currentBlockHeight) -> { logger.warning { "payment with expiry too small: ${paymentPart.htlc.cltvExpiry}, min is ${minFinalCltvExpiry(incomingPayment.origin.paymentRequest, currentBlockHeight)}" } - Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight)) + Either.Left(rejectPaymentPart(paymentPart, incomingPayment, currentBlockHeight)) } else -> Either.Right(incomingPayment) } @@ -370,7 +378,6 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment fun checkPaymentsTimeout(currentTimestampSeconds: Long): List { val actions = mutableListOf() val keysToRemove = mutableSetOf() - // BOLT 04: // - MUST fail all HTLCs in the HTLC set after some reasonable timeout. // - SHOULD wait for at least 60 seconds after the initial HTLC. @@ -382,12 +389,11 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment payment.parts.forEach { part -> when (part) { is HtlcPart -> actions += actionForFailureMessage(PaymentTimeout, part.htlc) - is PayToOpenPart -> actions += actionForPayToOpenFailure(privateKey, PaymentTimeout, part.payToOpenRequest) + is OnTheFlyFundingPart -> {} // we don't need to notify our peer, they will automatically fail HTLCs after a delay } } } } - pending.minusAssign(keysToRemove) return actions } @@ -408,11 +414,11 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment /** * If we are disconnected, we must forget pending payment parts. - * Pay-to-open requests will be forgotten by the LSP, so we need to do the same otherwise we will accept outdated ones. + * On-the-fly funding requests will be forgotten by our peer, so we need to do the same otherwise we may accept outdated ones. * Offered HTLCs that haven't been resolved will be re-processed when we reconnect. */ fun purgePendingPayments() { - pending.forEach { (paymentHash, pending) -> logger.info { "purging pending incoming payments for paymentHash=$paymentHash: ${pending.parts.map { it.toString() }.joinToString(", ")}" } } + pending.forEach { (paymentHash, pending) -> logger.info { "purging pending incoming payments for paymentHash=$paymentHash: ${pending.parts.joinToString(", ") { it.toString() }}" } } pending.clear() } @@ -431,27 +437,26 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment } /** - * Convert a incoming pay-to-open request to a payment part abstraction. + * Convert a incoming on-the-fly funding request to a payment part abstraction. * This is very similar to the processing of a htlc, except that we only have a packet, to decrypt into a final payload. */ - private fun toPaymentPart(privateKey: PrivateKey, payToOpenRequest: PayToOpenRequest): Either { - return when (val decrypted = IncomingPaymentPacket.decryptOnion(payToOpenRequest.paymentHash, payToOpenRequest.finalPacket, privateKey)) { + private fun toPaymentPart(privateKey: PrivateKey, htlc: MaybeAddHtlc, logger: MDCLogger): Either { + return when (val decrypted = IncomingPaymentPacket.decryptOnion(htlc.paymentHash, htlc.finalPacket, privateKey)) { is Either.Left -> { - val failureMsg = decrypted.value - val action = actionForPayToOpenFailure(privateKey, failureMsg, payToOpenRequest) - Either.Left(ProcessAddResult.Rejected(listOf(action), null)) + // We simply ignore invalid maybe_add_htlc messages. + logger.warning { "could not decrypt maybe_add_htlc: ${decrypted.value.message}" } + Either.Left(ProcessAddResult.Rejected(listOf(), null)) } - is Either.Right -> Either.Right(PayToOpenPart(payToOpenRequest, decrypted.value)) + is Either.Right -> Either.Right(OnTheFlyFundingPart(htlc, decrypted.value)) } } - private fun rejectPaymentPart(privateKey: PrivateKey, paymentPart: PaymentPart, incomingPayment: IncomingPayment?, currentBlockHeight: Int): ProcessAddResult.Rejected { + private fun rejectPaymentPart(paymentPart: PaymentPart, incomingPayment: IncomingPayment?, currentBlockHeight: Int): ProcessAddResult.Rejected { val failureMsg = IncorrectOrUnknownPaymentDetails(paymentPart.totalAmount, currentBlockHeight.toLong()) - val rejectedAction = when (paymentPart) { - is HtlcPart -> actionForFailureMessage(failureMsg, paymentPart.htlc) - is PayToOpenPart -> actionForPayToOpenFailure(privateKey, failureMsg, paymentPart.payToOpenRequest) + return when (paymentPart) { + is HtlcPart -> ProcessAddResult.Rejected(listOf(actionForFailureMessage(failureMsg, paymentPart.htlc)), incomingPayment) + is OnTheFlyFundingPart -> ProcessAddResult.Rejected(listOf(), incomingPayment) } - return ProcessAddResult.Rejected(listOf(rejectedAction), incomingPayment) } private fun actionForFailureMessage(msg: FailureMessage, htlc: UpdateAddHtlc, commit: Boolean = true): WrappedChannelCommand { @@ -462,15 +467,6 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment return WrappedChannelCommand(htlc.channelId, cmd) } - fun actionForPayToOpenFailure(privateKey: PrivateKey, failure: FailureMessage, payToOpenRequest: PayToOpenRequest): PayToOpenResponseCommand { - val reason = ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(failure) - val encryptedReason = when (val result = OutgoingPaymentPacket.buildHtlcFailure(privateKey, payToOpenRequest.paymentHash, payToOpenRequest.finalPacket, reason)) { - is Either.Right -> result.value - is Either.Left -> null - } - return PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest.chainHash, payToOpenRequest.paymentHash, PayToOpenResponse.Result.Failure(encryptedReason))) - } - private fun minFinalCltvExpiry(paymentRequest: Bolt11Invoice, currentBlockHeight: Int): CltvExpiry { val minFinalCltvExpiryDelta = paymentRequest.minFinalExpiryDelta ?: Bolt11Invoice.DEFAULT_MIN_FINAL_EXPIRY_DELTA return minFinalCltvExpiryDelta.toCltvExpiry(currentBlockHeight.toLong()) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt index b49a9fcaa..c9ed8ac95 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt @@ -5,10 +5,11 @@ import fr.acinq.lightning.LiquidityEvents import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.toMilliSatoshi sealed class LiquidityPolicy { - /** Never initiates swap-ins, never accept pay-to-open */ + /** Never initiates swap-ins, never accept on-the-fly funding requests. */ data object Disable : LiquidityPolicy() /** @@ -38,4 +39,12 @@ sealed class LiquidityPolicy { }?.let { reason -> LiquidityEvents.Rejected(amount, fee, source, reason) } } + companion object { + /** + * We usually need our peer to contribute to channel funding, because they must have enough funds to pay the commitment fees. + * When we don't have an inbound liquidity target set, we use the following default amount. + */ + val minInboundLiquidityTarget: Satoshi = 100_000.sat + } + } \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt index 13346fdff..9a61bce9d 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt @@ -425,6 +425,7 @@ object Deserialization { private fun Input.readChannelOrigin(): Origin = when (val discriminator = read()) { 0x01 -> { + // Note that we've replaced this field by the payment preimage: old entries will be incorrect, but it's not critical. val paymentHash = readByteVector32() val serviceFee = readNumber().msat val miningFee = readNumber().sat @@ -439,7 +440,7 @@ object Deserialization { Origin.OnChainWallet(setOf(), amount, TransactionFees(miningFee, serviceFee.truncateToSatoshi())) } 0x03 -> Origin.OffChainPayment( - paymentHash = readByteVector32(), + paymentPreimage = readByteVector32(), amount = readNumber().msat, fees = TransactionFees(miningFee = readNumber().sat, serviceFee = readNumber().sat), ) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt index ce081974f..2fa338703 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt @@ -464,7 +464,7 @@ object Serialization { private fun Output.writeChannelOrigin(o: Origin) = when (o) { is Origin.OffChainPayment -> { write(0x03) - writeByteVector32(o.paymentHash) + writeByteVector32(o.paymentPreimage) writeNumber(o.amount.toLong()) writeNumber(o.fees.miningFee.toLong()) writeNumber(o.fees.serviceFee.toLong()) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt index 0ca65c36f..91cdf3ffd 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt @@ -127,6 +127,19 @@ sealed class ChannelTlv : Tlv { override fun read(input: Input): PushAmountTlv = PushAmountTlv(LightningCodecs.tu64(input).msat) } } + + /** Preimage of a pending payment that requires on-the-fly funding. The payment amount will be pushed using [PushAmountTlv]. */ + data class OnTheFlyFundingPreimage(val preimage: ByteVector32) : ChannelTlv() { + override val tag: Long get() = OnTheFlyFundingPreimage.tag + + override fun write(out: Output) = LightningCodecs.writeBytes(preimage, out) + + companion object : TlvValueReader { + const val tag: Long = 0x4700000a + + override fun read(input: Input): OnTheFlyFundingPreimage = OnTheFlyFundingPreimage(LightningCodecs.bytes(input, 32).byteVector32()) + } + } } sealed class ChannelReadyTlv : Tlv { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index 542f94a36..a8da114ed 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -10,7 +10,6 @@ import fr.acinq.lightning.* import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.ChannelFlags import fr.acinq.lightning.channel.ChannelType -import fr.acinq.lightning.channel.Origin import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.router.Announcements import fr.acinq.lightning.utils.* @@ -78,8 +77,8 @@ interface LightningMessage { Shutdown.type -> Shutdown.read(stream) ClosingSigned.type -> ClosingSigned.read(stream) OnionMessage.type -> OnionMessage.read(stream) - PayToOpenRequest.type -> PayToOpenRequest.read(stream) - PayToOpenResponse.type -> PayToOpenResponse.read(stream) + MaybeAddHtlc.type -> MaybeAddHtlc.read(stream) + CancelOnTheFlyFunding.type -> CancelOnTheFlyFunding.read(stream) FCMToken.type -> FCMToken.read(stream) UnsetFCMToken.type -> UnsetFCMToken PhoenixAndroidLegacyInfo.type -> PhoenixAndroidLegacyInfo.read(stream) @@ -671,6 +670,7 @@ data class OpenDualFundedChannel( val channelType: ChannelType? get() = tlvStream.get()?.channelType val pushAmount: MilliSatoshi get() = tlvStream.get()?.amount ?: 0.msat val requestFunds: ChannelTlv.RequestFunds? get() = tlvStream.get() + val preimage: ByteVector32? = tlvStream.get()?.preimage override val type: Long get() = OpenDualFundedChannel.type @@ -709,6 +709,7 @@ data class OpenDualFundedChannel( ChannelTlv.RequireConfirmedInputsTlv.tag to ChannelTlv.RequireConfirmedInputsTlv as TlvValueReader, ChannelTlv.RequestFunds.tag to ChannelTlv.RequestFunds as TlvValueReader, ChannelTlv.PushAmountTlv.tag to ChannelTlv.PushAmountTlv.Companion as TlvValueReader, + ChannelTlv.OnTheFlyFundingPreimage.tag to ChannelTlv.OnTheFlyFundingPreimage.Companion as TlvValueReader, ) override fun read(input: Input): OpenDualFundedChannel { @@ -950,14 +951,21 @@ data class SpliceInit( val requireConfirmedInputs: Boolean = tlvStream.get()?.let { true } ?: false val requestFunds: ChannelTlv.RequestFunds? get() = tlvStream.get() val pushAmount: MilliSatoshi = tlvStream.get()?.amount ?: 0.msat + val preimage: ByteVector32? = tlvStream.get()?.preimage - constructor(channelId: ByteVector32, fundingContribution: Satoshi, pushAmount: MilliSatoshi, feerate: FeeratePerKw, lockTime: Long, fundingPubkey: PublicKey, requestFunds: ChannelTlv.RequestFunds?) : this( + constructor(channelId: ByteVector32, fundingContribution: Satoshi, pushAmount: MilliSatoshi, feerate: FeeratePerKw, lockTime: Long, fundingPubkey: PublicKey, requestFunds: ChannelTlv.RequestFunds?, preimage: ByteVector32?) : 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, + preimage?.let { ChannelTlv.OnTheFlyFundingPreimage(it) }, + requestFunds + ) + ) ) override fun write(out: Output) { @@ -977,6 +985,7 @@ data class SpliceInit( ChannelTlv.RequireConfirmedInputsTlv.tag to ChannelTlv.RequireConfirmedInputsTlv as TlvValueReader, ChannelTlv.RequestFunds.tag to ChannelTlv.RequestFunds as TlvValueReader, ChannelTlv.PushAmountTlv.tag to ChannelTlv.PushAmountTlv.Companion as TlvValueReader, + ChannelTlv.OnTheFlyFundingPreimage.tag to ChannelTlv.OnTheFlyFundingPreimage.Companion as TlvValueReader, ) override fun read(input: Input): SpliceInit = SpliceInit( @@ -1594,102 +1603,72 @@ data class OnionMessage( } /** - * When we don't have enough incoming liquidity to receive a payment, our peer may open a channel to us on-the-fly to carry that payment. - * This message contains details that allow us to recalculate the fee that our peer will take in exchange for the new channel. - * This allows us to combine multiple requests for the same payment and figure out the final fee that will be applied. - * - * @param chainHash chain we're on. - * @param fundingSatoshis total capacity of the channel our peer will open to us (some of the funds may be on their side). - * @param amountMsat payment amount covered by this new channel: we will receive push_msat = amountMsat - fees. - * @param payToOpenMinAmountMsat minimum amount for a pay-to-open to be attempted, this should be compared to the total amount in the case of an MPP payment. - * @param payToOpenFeeSatoshis fees that will be deducted from the amount pushed to us (this fee covers the on-chain fees our peer will pay to open the channel). - * @param paymentHash payment hash. - * @param expireAt after the proposal expires, our peer will fail the payment and won't open a channel to us. - * @param finalPacket onion packet that we would have received if there had been a channel to forward the payment to. + * This message is sent when an HTLC couldn't be relayed to our node because we don't have enough inbound liquidity. + * This allows us to treat it as an incoming payment, and request on-the-fly liquidity accordingly if we wish to receive that payment. + * If we accept the payment, we will send an [OpenDualFundedChannel] or [SpliceInit] message containing [ChannelTlv.OnTheFlyFundingPreimage] and [ChannelTlv.RequestFunds]. + * Our peer will then use the payment preimage to settle the HTLCs they received and use [ChannelTlv.PushAmountTlv] to forward us the payment amount. */ -data class PayToOpenRequest( +data class MaybeAddHtlc( override val chainHash: BlockHash, - val fundingSatoshis: Satoshi, - val amountMsat: MilliSatoshi, - val payToOpenMinAmountMsat: MilliSatoshi, - val payToOpenFeeSatoshis: Satoshi, + val amount: MilliSatoshi, val paymentHash: ByteVector32, - val expireAt: Long, + val expiry: CltvExpiry, val finalPacket: OnionRoutingPacket -) : LightningMessage, HasChainHash { - override val type: Long get() = PayToOpenRequest.type +) : ChannelMessage, HasChainHash { + override val type: Long get() = MaybeAddHtlc.type override fun write(out: Output) { LightningCodecs.writeBytes(chainHash.value, out) - LightningCodecs.writeU64(fundingSatoshis.toLong(), out) - LightningCodecs.writeU64(amountMsat.toLong(), out) - LightningCodecs.writeU64(payToOpenMinAmountMsat.toLong(), out) - LightningCodecs.writeU64(payToOpenFeeSatoshis.toLong(), out) + LightningCodecs.writeU64(amount.toLong(), out) LightningCodecs.writeBytes(paymentHash, out) - LightningCodecs.writeU32(expireAt.toInt(), out) + LightningCodecs.writeU32(expiry.toLong().toInt(), out) LightningCodecs.writeU16(finalPacket.payload.size(), out) OnionRoutingPacketSerializer(finalPacket.payload.size()).write(finalPacket, out) } - companion object : LightningMessageReader { - const val type: Long = 35021 + companion object : LightningMessageReader { + const val type: Long = 35027 - override fun read(input: Input): PayToOpenRequest { - return PayToOpenRequest( - chainHash = BlockHash(LightningCodecs.bytes(input, 32)), - fundingSatoshis = Satoshi(LightningCodecs.u64(input)), - amountMsat = MilliSatoshi(LightningCodecs.u64(input)), - payToOpenMinAmountMsat = MilliSatoshi(LightningCodecs.u64(input)), - payToOpenFeeSatoshis = Satoshi(LightningCodecs.u64(input)), - paymentHash = ByteVector32(LightningCodecs.bytes(input, 32)), - expireAt = LightningCodecs.u32(input).toLong(), - finalPacket = OnionRoutingPacketSerializer(LightningCodecs.u16(input)).read(input) - ) - } + override fun read(input: Input): MaybeAddHtlc = MaybeAddHtlc( + chainHash = BlockHash(LightningCodecs.bytes(input, 32)), + amount = LightningCodecs.u64(input).msat, + paymentHash = LightningCodecs.bytes(input, 32).byteVector32(), + expiry = CltvExpiry(LightningCodecs.u32(input).toLong()), + finalPacket = OnionRoutingPacketSerializer(LightningCodecs.u16(input)).read(input), + ) } } -data class PayToOpenResponse(override val chainHash: BlockHash, val paymentHash: ByteVector32, val result: Result) : LightningMessage, HasChainHash { - override val type: Long get() = PayToOpenResponse.type +/** + * We receive this message when our peer permanently rejects our on-the-fly funding request, because they + * have either already completed it or it expired and they failed the corresponding HTLCs. + */ +data class CancelOnTheFlyFunding( + override val channelId: ByteVector32, + val paymentHash: ByteVector32, + val data: ByteVector +) : ChannelMessage, HasChannelId { + constructor(channelId: ByteVector32, paymentHash: ByteVector32, message: String?) : this(channelId, paymentHash, ByteVector(message?.encodeToByteArray() ?: ByteArray(0))) + + override val type: Long get() = CancelOnTheFlyFunding.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 - } + fun toAscii(): String = data.toByteArray().decodeToString() override fun write(out: Output) { - LightningCodecs.writeBytes(chainHash.value, out) + LightningCodecs.writeBytes(channelId, 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.writeU16(data.size(), out) + LightningCodecs.writeBytes(data, out) } - companion object : LightningMessageReader { - const val type: Long = 35003 + companion object : LightningMessageReader { + const val type: Long = 35029 - 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): CancelOnTheFlyFunding = CancelOnTheFlyFunding( + channelId = LightningCodecs.bytes(input, 32).toByteVector32(), + paymentHash = LightningCodecs.bytes(input, 32).toByteVector32(), + data = LightningCodecs.bytes(input, LightningCodecs.u16(input)).toByteVector() + ) } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt index 8efd14986..c979b19c4 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt @@ -36,11 +36,11 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val alice0 = InteractiveTxSession(f.nodeIdB, f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) val bob0 = InteractiveTxSession(f.nodeIdA, f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) - + // 3 swap-in inputs, 2 legacy swap-in inputs, and 2 outputs from Alice // 2 swap-in inputs, 2 legacy swap-in inputs, and 1 output from Bob - // Alice --- tx_add_input --> Bob + // Alice --- tx_add_input --> Bob val (alice1, inputA1) = sendMessage(alice0) assertEquals(0xfffffffdU, inputA1.sequence) // Alice <-- tx_add_input --- Bob @@ -93,7 +93,6 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertEquals(signedTxB.localSigs.swapInServerSigs.size, 2) assertEquals(signedTxB.localSigs.swapInServerPartialSigs.size, 3) - // Alice detects invalid signatures from Bob. val sigsInvalidTxId = signedTxB.localSigs.copy(txId = TxId(randomBytes32())) assertNull(sharedTxA.sharedTx.sign(alice7, f.keyManagerA, f.fundingParamsA, f.localParamsA, f.nodeIdB).addRemoteSigs(f.channelKeysA, f.fundingParamsA, sigsInvalidTxId)) @@ -348,6 +347,49 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertTrue(targetFeerate <= feerate && feerate <= targetFeerate * 1.25, "unexpected feerate (target=$targetFeerate actual=$feerate)") } + @Test + fun `initiator does not contribute -- on-the-fly funding`() { + // When on-the-fly funding is used, the initiator may not contribute to the funding transaction. + // It will receive funds using push_amount, and liquidity fees will be deduced from that amount. + val targetFeerate = FeeratePerKw(5000.sat) + val fundingB = 150_000.sat + val utxosB = listOf(200_000.sat) + val f = createFixture(0.sat, listOf(), listOf(), fundingB, utxosB, listOf(), targetFeerate, 330.sat, 0, nonInitiatorPaysCommitFees = true) + assertEquals(f.fundingParamsA.fundingAmount, fundingB) + + val alice0 = InteractiveTxSession(f.nodeIdB, f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.nodeIdA, f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) + // Alice --- tx_add_output --> Bob + val (alice1, sharedOutput) = sendMessage(alice0) + // Alice <-- tx_add_input --- Bob + val (bob1, inputB) = receiveMessage(bob0, sharedOutput) + // Alice --- tx_complete --> Bob + val (alice2, txCompleteA1) = receiveMessage(alice1, inputB) + // Alice <-- tx_add_output --- Bob + val (bob2, outputB) = receiveMessage(bob1, txCompleteA1) + // Alice --- tx_complete --> Bob + val (alice3, txCompleteA2) = receiveMessage(alice2, outputB) + // Alice <-- tx_complete --- Bob + val (bob3, sharedTxB) = receiveFinalMessage(bob2, txCompleteA2) + assertNotNull(sharedTxB.txComplete) + val (alice4, sharedTxA) = receiveFinalMessage(alice3, sharedTxB.txComplete!!) + assertNull(sharedTxA.txComplete) + + // Alice cannot pay on-chain fees because she doesn't have inputs to contribute. + // She will pay liquidity fees instead that will be taken from the push_amount. + assertEquals(0.msat, sharedTxA.sharedTx.localFees) + assertEquals(0.msat, sharedTxB.sharedTx.remoteFees) + + // Alice signs first since she didn't contribute. + val signedTxA = sharedTxA.sharedTx.sign(alice4, f.keyManagerA, f.fundingParamsA, f.localParamsA, f.nodeIdB) + val signedTxB = sharedTxB.sharedTx.sign(bob3, f.keyManagerB, f.fundingParamsB, f.localParamsB, f.nodeIdA).addRemoteSigs(f.channelKeysB, f.fundingParamsB, signedTxA.localSigs) + assertNotNull(signedTxB) + Transaction.correctlySpends(signedTxB.signedTx, sharedTxB.sharedTx.localInputs.map { it.previousTx }, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + // The feerate is lower than expected since Alice didn't contribute. + val feerate = Transactions.fee2rate(signedTxA.tx.fees, signedTxB.signedTx.weight()) + assertTrue(targetFeerate * 0.5 <= feerate && feerate <= targetFeerate, "unexpected feerate (target=$targetFeerate actual=$feerate)") + } + @Test fun `initiator and non-initiator splice-in`() { val targetFeerate = FeeratePerKw(1000.sat) @@ -655,6 +697,45 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertTrue(targetFeerate <= feerate && feerate <= targetFeerate * 1.25, "unexpected feerate (target=$targetFeerate actual=$feerate)") } + @Test + fun `initiator does not contribute -- on-the-fly splicing`() { + // When on-the-fly funding is used, the initiator may not contribute to the funding transaction. + // It will receive funds using push_amount, and liquidity fees will be deduced from that amount. + val targetFeerate = FeeratePerKw(5000.sat) + val balanceA = 0.msat + val balanceB = 75_000_000.msat + val additionalFundingB = 50_000.sat + val utxosB = listOf(90_000.sat) + val f = createSpliceFixture(balanceA, 0.sat, listOf(), listOf(), balanceB, additionalFundingB, utxosB, listOf(), targetFeerate, 330.sat, 0, nonInitiatorPaysCommitFees = true) + assertEquals(f.fundingParamsA.fundingAmount, 125_000.sat) + + val alice0 = InteractiveTxSession(f.nodeIdB, f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, balanceA, balanceB, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.nodeIdA, f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, balanceB, balanceA, emptySet(), f.fundingContributionsB) + // Alice --- tx_add_input --> Bob + val (alice1, sharedInput) = sendMessage(alice0) + // Alice <-- tx_add_input --- Bob + val (bob1, inputB) = receiveMessage(bob0, sharedInput) + // Alice --- tx_add_output --> Bob + val (alice2, sharedOutput) = receiveMessage(alice1, inputB) + // Alice <-- tx_add_output --- Bob + val (bob2, outputB) = receiveMessage(bob1, sharedOutput) + // Alice --- tx_complete --> Bob + val (alice3, txCompleteA) = receiveMessage(alice2, outputB) + // Alice <-- tx_complete --- Bob + val (bob3, sharedTxB) = receiveFinalMessage(bob2, txCompleteA) + assertNotNull(sharedTxB.txComplete) + val (alice4, sharedTxA) = receiveFinalMessage(alice3, sharedTxB.txComplete!!) + + // Alice signs first since she didn't contribute. + val signedTxA = sharedTxA.sharedTx.sign(alice4, f.keyManagerA, f.fundingParamsA, f.localParamsA, f.nodeIdB) + val signedTxB = sharedTxB.sharedTx.sign(bob3, f.keyManagerB, f.fundingParamsB, f.localParamsB, f.nodeIdA).addRemoteSigs(f.channelKeysB, f.fundingParamsB, signedTxA.localSigs) + assertNotNull(signedTxB) + Transaction.correctlySpends(signedTxB.signedTx, previousOutputs(f.fundingParamsA, sharedTxB.sharedTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + // The feerate is lower than expected since Alice didn't contribute. + val feerate = Transactions.fee2rate(signedTxA.tx.fees, signedTxB.signedTx.weight()) + assertTrue(targetFeerate * 0.25 <= feerate && feerate <= targetFeerate, "unexpected feerate (target=$targetFeerate actual=$feerate)") + } + @Test fun `remove input - output`() { val f = createFixture(100_000.sat, listOf(150_000.sat), listOf(), 0.sat, listOf(), listOf(), FeeratePerKw(2500.sat), 330.sat, 0) @@ -662,7 +743,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val alice0 = InteractiveTxSession(f.nodeIdB, f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) val bob0 = InteractiveTxSession(f.nodeIdA, f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), FundingContributions(listOf(), listOf())) - // Alice --- tx_add_input --> Bob + // Alice --- tx_add_input --> Bob val (alice1, inputA) = sendMessage(alice0) val (_, txCompleteB) = receiveMessage(bob0, inputA) @@ -722,12 +803,6 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertNotNull(result) assertIs(result) } - run { - val previousTx = Transaction(2, listOf(), listOf(TxOut(80_000.sat, Script.pay2wpkh(pubKey)), TxOut(70_001.sat, Script.pay2wpkh(pubKey))), 0) - val result = FundingContributions.create(channelKeys, swapInKeys, fundingParams, listOf(WalletState.Utxo(previousTx.txid, 0, 0, previousTx, WalletState.AddressMeta.Single), WalletState.Utxo(previousTx.txid, 1, 0, previousTx, WalletState.AddressMeta.Single))).left - assertNotNull(result) - assertIs(result) - } } @Test @@ -1184,12 +1259,13 @@ class InteractiveTxTestsCommon : LightningTestSuite() { legacyUtxosB: List, targetFeerate: FeeratePerKw, dustLimit: Satoshi, - lockTime: Long + lockTime: Long, + nonInitiatorPaysCommitFees: Boolean = false, ): Fixture { val channelId = randomBytes32() val fundingTxIndex = 0L - val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = true) - val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = false) + val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = !nonInitiatorPaysCommitFees) + val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = nonInitiatorPaysCommitFees) val channelKeysA = localParamsA.channelKeys(TestConstants.Alice.keyManager) val channelKeysB = localParamsB.channelKeys(TestConstants.Bob.keyManager) val swapInKeysA = TestConstants.Alice.keyManager.swapInOnChainWallet @@ -1216,11 +1292,12 @@ class InteractiveTxTestsCommon : LightningTestSuite() { fundingContributionB: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, - lockTime: Long + lockTime: Long, + nonInitiatorPaysCommitFees: Boolean = false, ): Either { val channelId = randomBytes32() - val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = true) - val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = false) + val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = !nonInitiatorPaysCommitFees) + val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = nonInitiatorPaysCommitFees) val channelKeysA = localParamsA.channelKeys(TestConstants.Alice.keyManager) val channelKeysB = localParamsB.channelKeys(TestConstants.Bob.keyManager) val swapInKeysA = TestConstants.Alice.keyManager.swapInOnChainWallet @@ -1248,12 +1325,13 @@ class InteractiveTxTestsCommon : LightningTestSuite() { outputsB: List, targetFeerate: FeeratePerKw, dustLimit: Satoshi, - lockTime: Long + lockTime: Long, + nonInitiatorPaysCommitFees: Boolean = false, ): Fixture { val channelId = randomBytes32() val fundingTxIndex = 0L - val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = true) - val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = false) + val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = !nonInitiatorPaysCommitFees) + val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = nonInitiatorPaysCommitFees) val channelKeysA = localParamsA.channelKeys(TestConstants.Alice.keyManager) val channelKeysB = localParamsB.channelKeys(TestConstants.Bob.keyManager) val swapInKeysA = TestConstants.Alice.keyManager.swapInOnChainWallet diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt index d3cbf7571..e5e39fff8 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 @@ -191,9 +191,11 @@ class SpliceTestsCommon : LightningTestSuite() { 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), listOf()) + val origin = Origin.OffChainPayment(randomBytes32(), 50_000_000.msat, TransactionFees(250.sat, 400.sat)) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf(origin)) val (alice1, bob1, spliceInit) = reachQuiescent(cmd, alice, bob) assertEquals(spliceInit.requestFunds, liquidityRequest.requestFunds) + assertEquals(spliceInit.preimage, origin.paymentPreimage) // 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. @@ -260,6 +262,43 @@ class SpliceTestsCommon : LightningTestSuite() { } } + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `splice to purchase inbound liquidity -- not enough funds but on-the-fly funding`() { + val (alice, bob) = reachNormal(bobFundingAmount = 0.sat, alicePushAmount = 0.msat, bobPushAmount = 0.msat) + val leaseRate = LiquidityAds.LeaseRate(0, 350, 100, 0.sat, 200, 100.msat) + val liquidityRequest = LiquidityAds.RequestRemoteFunding(100_000.sat, bob.currentBlockHeight, leaseRate) + run { + // If this isn't tied to an on-the-fly funding attempt, we won't have enough funds to pay fees. + 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)) + assertEquals(2, actionsBob2.size) + actionsBob2.hasOutgoingMessage() + actionsBob2.has() + assertTrue(cmd.replyTo.isCompleted) + assertEquals(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds, cmd.replyTo.getCompleted()) + } + run { + // If this is tied to an on-the-fly funding attempt, we can use the payment amount to pay fees. + val origin = Origin.OffChainPayment(randomBytes32(), 25_000_000.msat, TransactionFees(500.sat, 0.sat)) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, 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(origin.paymentPreimage, it.preimage) + } + } + } + @Test fun `reject splice_init`() { val cmd = createSpliceOutRequest(25_000.sat) @@ -274,6 +313,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, 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) @@ -664,7 +714,7 @@ class SpliceTestsCommon : LightningTestSuite() { val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(txSigsBob)) assertIs>(alice5) assertEquals(alice5.state.commitments.active.size, 2) - assertEquals(actionsAlice5.size, 8) + assertEquals(actionsAlice5.size, 7) assertEquals(actionsAlice5.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.FundingTx).txid, spliceTxId) assertEquals(htlcs.bobToAlice.map { it.second }.toSet(), actionsAlice5.filterIsInstance().map { it.add }.toSet()) actionsAlice5.hasWatchConfirmed(spliceTxId) @@ -716,7 +766,7 @@ class SpliceTestsCommon : LightningTestSuite() { val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(txSigsBob)) assertIs>(alice5) assertEquals(alice5.state.commitments.active.size, 2) - assertEquals(actionsAlice5.size, 9) + assertEquals(actionsAlice5.size, 8) assertEquals(actionsAlice5.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.FundingTx).txid, spliceTxId) actionsAlice5.hasWatchConfirmed(spliceTxId) actionsAlice5.has() @@ -1490,7 +1540,7 @@ class SpliceTestsCommon : LightningTestSuite() { assertNotNull(actionsAlice2.filterIsInstance().find { it.add == htlc }) } when { - aliceSpliceStatus.session.fundingParams.localContribution > 0.sat -> actionsAlice2.has() + aliceSpliceStatus.origins.isNotEmpty() -> actionsAlice2.has() aliceSpliceStatus.session.fundingParams.localContribution < 0.sat && aliceSpliceStatus.session.fundingParams.localOutputs.isNotEmpty() -> actionsAlice2.has() aliceSpliceStatus.session.fundingParams.localContribution < 0.sat && aliceSpliceStatus.session.fundingParams.localOutputs.isEmpty() -> actionsAlice2.has() else -> {} @@ -1513,7 +1563,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/db/InMemoryPaymentsDb.kt b/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt index f833b27d7..7f1e848ad 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/db/InMemoryPaymentsDb.kt @@ -30,13 +30,25 @@ class InMemoryPaymentsDb : PaymentsDb { override suspend fun receivePayment(paymentHash: ByteVector32, receivedWith: List, receivedAt: Long) { when (val payment = incoming[paymentHash]) { null -> Unit // no-op - else -> incoming[paymentHash] = run { - payment.copy( - received = IncomingPayment.Received( - receivedWith = (payment.received?.receivedWith ?: emptySet()) + receivedWith, - receivedAt = receivedAt - ) - ) + else -> { + val currentReceivedWith = payment.received?.receivedWith ?: emptySet() + val nextReceivedWith = when { + // When receiving an on-chain part, we must remove the corresponding "pending" placeholder. + receivedWith.any { it is IncomingPayment.ReceivedWith.OnChainIncomingPayment } -> currentReceivedWith.filter { it !is IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending } + receivedWith + else -> currentReceivedWith + receivedWith + } + incoming[paymentHash] = payment.copy(received = IncomingPayment.Received(nextReceivedWith, receivedAt)) + } + } + } + + override suspend fun listPendingOnTheFlyPayments(): List> { + return buildList { + incoming.values.forEach { payment -> + when (val pending = payment.received?.receivedWith?.firstOrNull { it is IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending }) { + is IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending -> add(Pair(payment, pending)) + else -> {} + } } } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt index 86c3c3011..fb69dbd7d 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/db/PaymentsDbTestsCommon.kt @@ -1,6 +1,9 @@ package fr.acinq.lightning.db -import fr.acinq.bitcoin.* +import fr.acinq.bitcoin.Block +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.TxId import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 @@ -8,7 +11,6 @@ import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.channel.TooManyAcceptedHtlcs import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.FinalFailure -import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.tests.utils.runSuspendTest import fr.acinq.lightning.utils.* @@ -57,7 +59,7 @@ class PaymentsDbTestsCommon : LightningTestSuite() { } @Test - fun `receive incoming payment with several parts`() = runSuspendTest { + fun `receive incoming payment with on-chain part`() = runSuspendTest { val (db, preimage, pr) = createFixture() assertNull(db.getIncomingPayment(pr.paymentHash)) @@ -68,23 +70,39 @@ class PaymentsDbTestsCommon : LightningTestSuite() { assertNotNull(pending) assertEquals(incoming, pending) + // The on-chain part is initially pending, until the corresponding on-chain transaction is created. db.receivePayment( - pr.paymentHash, listOf( + pr.paymentHash, + listOf( IncomingPayment.ReceivedWith.LightningPayment(amount = 57_000.msat, channelId = channelId1, htlcId = 1L), IncomingPayment.ReceivedWith.LightningPayment(amount = 43_000.msat, channelId = channelId2, htlcId = 54L), - IncomingPayment.ReceivedWith.NewChannel(amount = 99_000.msat, channelId = channelId3, serviceFee = 1_000.msat, miningFee = 0.sat, txId = TxId(randomBytes32()), confirmedAt = null, lockedAt = null) - ), 110 + IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending(50_000.msat), + ) ) - val received = db.getIncomingPayment(pr.paymentHash) - assertNotNull(received) - assertEquals(199_000.msat, received.amount) - assertEquals(1_000.msat, received.fees) - assertEquals(3, received.received!!.receivedWith.size) - assertEquals(57_000.msat, received.received!!.receivedWith.elementAt(0).amount) - assertEquals(0.msat, received.received!!.receivedWith.elementAt(0).fees) - assertEquals(channelId1, (received.received!!.receivedWith.elementAt(0) as IncomingPayment.ReceivedWith.LightningPayment).channelId) - assertEquals(54L, (received.received!!.receivedWith.elementAt(1) as IncomingPayment.ReceivedWith.LightningPayment).htlcId) - assertEquals(channelId3, (received.received!!.receivedWith.elementAt(2) as IncomingPayment.ReceivedWith.NewChannel).channelId) + run { + val received = db.getIncomingPayment(pr.paymentHash) + assertNotNull(received) + assertEquals(100_000.msat, received.amount) + assertEquals(0.msat, received.fees) + assertNull(received.completedAt) + } + + // The on-chain part completes. + val onChainPart = IncomingPayment.ReceivedWith.OnChainIncomingPayment.Received.NewChannel(45_000.msat, serviceFee = 2_000.msat, miningFee = 3.sat, channelId3, TxId(randomBytes32()), confirmedAt = null, lockedAt = null) + db.receivePayment(pr.paymentHash, listOf(onChainPart)) + run { + val received = db.getIncomingPayment(pr.paymentHash) + assertNotNull(received) + assertEquals(145_000.msat, received.amount) + assertEquals(5_000.msat, received.fees) + assertEquals(3, received.received!!.receivedWith.size) + assertFalse(received.received!!.receivedWith.any { it is IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending }) + assertEquals(57_000.msat, received.received!!.receivedWith[0].amount) + assertEquals(0.msat, received.received!!.receivedWith[0].fees) + assertEquals(channelId1, (received.received!!.receivedWith[0] as IncomingPayment.ReceivedWith.LightningPayment).channelId) + assertEquals(54, (received.received!!.receivedWith[1] as IncomingPayment.ReceivedWith.LightningPayment).htlcId) + assertEquals(channelId3, (received.received!!.receivedWith[2] as IncomingPayment.ReceivedWith.OnChainIncomingPayment.Received.NewChannel).channelId) + } } @Test @@ -143,7 +161,7 @@ class PaymentsDbTestsCommon : LightningTestSuite() { db.addIncomingPayment(preimage, IncomingPayment.Origin.Invoice(pr), 200) db.receivePayment( pr.paymentHash, listOf( - IncomingPayment.ReceivedWith.NewChannel( + IncomingPayment.ReceivedWith.OnChainIncomingPayment.Received.NewChannel( amount = 500_000.msat, serviceFee = 15_000.msat, miningFee = 0.sat, @@ -154,10 +172,10 @@ class PaymentsDbTestsCommon : LightningTestSuite() { ) ), 110 ) - val received1 = db.getIncomingPayment(pr.paymentHash) - assertNotNull(received1?.received) - assertEquals(500_000.msat, received1!!.amount) - assertEquals(15_000.msat, received1.fees) + val received = db.getIncomingPayment(pr.paymentHash) + assertNotNull(received?.received) + assertEquals(500_000.msat, received!!.amount) + assertEquals(15_000.msat, received.fees) } @Test diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt11InvoiceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt11InvoiceTestsCommon.kt index f2f697813..6680ef63e 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt11InvoiceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt11InvoiceTestsCommon.kt @@ -454,7 +454,7 @@ class Bolt11InvoiceTestsCommon : LightningTestSuite() { // This doesn't satisfy the feature dependency graph, but since those aren't invoice features, we should ignore it. val features = Features( mapOf(Feature.VariableLengthOnion to FeatureSupport.Mandatory, Feature.PaymentSecret to FeatureSupport.Mandatory, Feature.AnchorOutputs to FeatureSupport.Mandatory), - setOf(UnknownFeature(121), UnknownFeature(156)) + setOf(UnknownFeature(121), UnknownFeature(256)) ) val pr = Bolt11Invoice.read(createInvoiceUnsafe(features = features).write()).get() assertEquals(pr.features, features) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt index fbbf01458..f02f683dd 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt @@ -3,7 +3,7 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.CltvExpiryDelta -import fr.acinq.lightning.Lightning +import fr.acinq.lightning.Lightning.randomBytes import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.ShortChannelId @@ -12,7 +12,7 @@ import fr.acinq.lightning.crypto.sphinx.Sphinx import fr.acinq.lightning.db.InMemoryPaymentsDb import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.IncomingPaymentsDb -import fr.acinq.lightning.io.PayToOpenResponseCommand +import fr.acinq.lightning.io.OpenOrSplicePayment import fr.acinq.lightning.io.WrappedChannelCommand import fr.acinq.lightning.router.ChannelHop import fr.acinq.lightning.router.NodeHop @@ -131,283 +131,241 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } @Test - fun `receive multipart payment with single HTLC`() = runSuspendTest { + fun `receive payment with single maybe_add_htlc`() = runSuspendTest { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) checkDbPayment(incomingPayment, paymentHandler.db) - val channelId = randomBytes32() - val add = makeUpdateAddHtlc(12, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val add = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) - val expected = ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true) - assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) - - assertEquals(result.incomingPayment.received, result.received) - assertEquals(defaultAmount, result.received.amount) - assertEquals(listOf(IncomingPayment.ReceivedWith.LightningPayment(amount = defaultAmount, channelId = channelId, htlcId = 12)), result.received.receivedWith) - - checkDbPayment(result.incomingPayment, paymentHandler.db) - } + val expected = OpenOrSplicePayment(defaultAmount, incomingPayment.preimage) + assertEquals(listOf(expected), result.actions) - @Test - fun `receive pay-to-open payment with single HTLC`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) - checkDbPayment(incomingPayment, paymentHandler.db) - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - - assertIs(result) - val expected = PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest.chainHash, payToOpenRequest.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage))) - assertEquals(setOf(expected), result.actions.toSet()) - - // the pay-to-open part is not yet inserted in db - assertTrue(result.received.receivedWith.isEmpty()) + // The on-the-fly funding part is pending in the db. + assertTrue(result.received.receivedWith.any { it is IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending }) assertEquals(0.msat, result.received.amount) assertEquals(0.msat, result.received.fees) - // later on, a channel is created + // Later on, a channel is created which completes the payment. val channelId = randomBytes32() - val amountOrigin = ChannelAction.Storage.StoreIncomingPayment.ViaNewChannel( - amount = payToOpenRequest.amountMsat, - serviceFee = payToOpenRequest.payToOpenFeeSatoshis.toMilliSatoshi(), - miningFee = 0.sat, + val action = ChannelAction.Storage.StoreIncomingPayment.ViaNewChannel( + amount = defaultAmount - 3_000_000.msat, + serviceFee = 1_000__000.msat, + miningFee = 2_000.sat, localInputs = emptySet(), txId = TxId(randomBytes32()), - origin = Origin.OffChainPayment(payToOpenRequest.paymentHash, payToOpenRequest.amountMsat, TransactionFees(miningFee = payToOpenRequest.payToOpenFeeSatoshis, serviceFee = 0.sat)) + origin = Origin.OffChainPayment(incomingPayment.preimage, defaultAmount, TransactionFees(miningFee = 2_000.sat, serviceFee = 1_000.sat)) ) - paymentHandler.process(channelId, amountOrigin) - paymentHandler.db.getIncomingPayment(payToOpenRequest.paymentHash).also { dbPayment -> + paymentHandler.process(channelId, action) + paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash).also { dbPayment -> assertNotNull(dbPayment) assertIs(dbPayment.origin) assertNotNull(dbPayment.received) assertEquals(1, dbPayment.received!!.receivedWith.size) dbPayment.received!!.receivedWith.first().also { part -> - assertIs(part) - assertEquals(amountOrigin.amount, part.amount) - assertEquals(amountOrigin.serviceFee, part.serviceFee) - assertEquals(amountOrigin.miningFee, part.miningFee) + assertIs(part) + assertEquals(action.amount, part.amount) + assertEquals(action.serviceFee, part.serviceFee) + assertEquals(action.miningFee, part.miningFee) assertEquals(channelId, part.channelId) assertNull(part.confirmedAt) } - assertEquals(amountOrigin.amount, dbPayment.received?.amount) - assertEquals(amountOrigin.serviceFee, dbPayment.received?.fees) + assertEquals(action.amount, dbPayment.received?.amount) + assertEquals(action.serviceFee + action.miningFee.toMilliSatoshi(), dbPayment.received?.fees) } - } @Test - fun `receive pay-to-open payment with two evenly-split HTLCs`() = runSuspendTest { + fun `receive payment with two evenly-split maybe_add_htlc`() = runSuspendTest { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) checkDbPayment(incomingPayment, paymentHandler.db) - val payToOpenRequest1 = makePayToOpenRequest(incomingPayment, makeMppPayload(50_000.msat, defaultAmount, paymentSecret)) - val payToOpenRequest2 = makePayToOpenRequest(incomingPayment, makeMppPayload(50_000.msat, defaultAmount, paymentSecret)) + val add1 = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(75_000_000.msat, defaultAmount, paymentSecret)) + val add2 = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(75_000_000.msat, defaultAmount, paymentSecret)) - val result1 = paymentHandler.process(payToOpenRequest1, TestConstants.defaultBlockHeight) + val result1 = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) - val result2 = paymentHandler.process(payToOpenRequest2, TestConstants.defaultBlockHeight) + assertTrue(result1.actions.isEmpty()) + val result2 = paymentHandler.process(add2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) + val expected = OpenOrSplicePayment(defaultAmount, incomingPayment.preimage) + assertEquals(listOf(expected), result2.actions) - val expected = PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest1.chainHash, payToOpenRequest1.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage))) - assertEquals(setOf(expected), (result1.actions + result2.actions).toSet()) - - // pay-to-open parts are not yet inserted in db - assertTrue(result2.received.receivedWith.isEmpty()) + // The on-the-fly funding part is pending in the db. + assertEquals(1, result2.received.receivedWith.size) + assertIs(result2.received.receivedWith.first()) + assertEquals(0.msat, result2.received.amount) + assertEquals(0.msat, result2.received.fees) + checkDbPayment(result2.incomingPayment, paymentHandler.db) } @Test - fun `receive pay-to-open payment with two unevenly-split HTLCs`() = runSuspendTest { + fun `receive payment with two unevenly-split maybe_add_htlc`() = runSuspendTest { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) checkDbPayment(incomingPayment, paymentHandler.db) - val payToOpenRequest1 = makePayToOpenRequest(incomingPayment, makeMppPayload(40_000.msat, defaultAmount, paymentSecret)) - val payToOpenRequest2 = makePayToOpenRequest(incomingPayment, makeMppPayload(60_000.msat, defaultAmount, paymentSecret)) + val add1 = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(50_000_000.msat, defaultAmount, paymentSecret)) + val add2 = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(100_000_000.msat, defaultAmount, paymentSecret)) - val result1 = paymentHandler.process(payToOpenRequest1, TestConstants.defaultBlockHeight) + val result1 = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) - assertEquals(emptyList(), result1.actions) - val result2 = paymentHandler.process(payToOpenRequest2, TestConstants.defaultBlockHeight) + assertTrue(result1.actions.isEmpty()) + val result2 = paymentHandler.process(add2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) - val payToOpenResponse = PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest1.chainHash, payToOpenRequest1.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage))) - assertEquals(listOf(payToOpenResponse), result2.actions) + val expected = OpenOrSplicePayment(defaultAmount, incomingPayment.preimage) + assertEquals(listOf(expected), result2.actions) + // The on-the-fly funding part is pending in the db. + assertEquals(1, result2.received.receivedWith.size) + assertIs(result2.received.receivedWith.first()) assertEquals(0.msat, result2.received.amount) assertEquals(0.msat, result2.received.fees) - checkDbPayment(result2.incomingPayment, paymentHandler.db) - } - @Test - fun `receive pay-to-open payment with an unknown payment hash`() = runSuspendTest { - val (paymentHandler, _, _) = createFixture(defaultAmount) - val payToOpenRequest = PayToOpenRequest( - chainHash = BlockHash(ByteVector32.Zeroes), - fundingSatoshis = 100_000.sat, - amountMsat = defaultAmount, - payToOpenMinAmountMsat = 1_000_000.msat, - payToOpenFeeSatoshis = 100.sat, - paymentHash = ByteVector32.One, // <-- not associated to a pending invoice - expireAt = Long.MAX_VALUE, - finalPacket = OutgoingPaymentPacket.buildPacket( - paymentHash = ByteVector32.One, // <-- has to be the same as the one above otherwise encryption fails - hops = channelHops(paymentHandler.nodeParams.nodeId), - finalPayload = makeMppPayload(defaultAmount, defaultAmount, randomBytes32()), - payloadLength = OnionRoutingPacket.PaymentPacketLength - ).third.packet + // Later on, a splice is created which completes the payment. + val channelId = randomBytes32() + val action = ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn( + amount = defaultAmount - 5_000_000.msat, + serviceFee = 0.msat, + miningFee = 5_000.sat, + localInputs = emptySet(), + txId = TxId(randomBytes32()), + origin = Origin.OffChainPayment(incomingPayment.preimage, defaultAmount, TransactionFees(miningFee = 5_000.sat, serviceFee = 0.sat)) ) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) + paymentHandler.process(channelId, action) + paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash).also { dbPayment -> + assertNotNull(dbPayment) + assertIs(dbPayment.origin) + assertNotNull(dbPayment.received) + assertEquals(1, dbPayment.received!!.receivedWith.size) + dbPayment.received!!.receivedWith.first().also { part -> + assertIs(part) + assertEquals(action.amount, part.amount) + assertEquals(action.serviceFee, part.serviceFee) + assertEquals(action.miningFee, part.miningFee) + assertEquals(channelId, part.channelId) + assertNull(part.confirmedAt) + } + assertEquals(action.amount, dbPayment.received?.amount) + assertEquals(5_000_000.msat, dbPayment.received?.fees) + } + } + @Test + fun `receive maybe_add_htlc with an unknown payment hash`() = runSuspendTest { + val (paymentHandler, incomingPayment, _) = createFixture(defaultAmount) + val add = makeMaybeAddHtlc(paymentHandler, randomBytes32(), makeSinglePartPayload(defaultAmount, randomBytes32())) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertNull(result.incomingPayment) - val expected = PayToOpenResponseCommand( - PayToOpenResponse( - payToOpenRequest.chainHash, - payToOpenRequest.paymentHash, - PayToOpenResponse.Result.Failure( - OutgoingPaymentPacket.buildHtlcFailure( - paymentHandler.nodeParams.nodePrivateKey, - payToOpenRequest.paymentHash, - payToOpenRequest.finalPacket, - ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(payToOpenRequest.amountMsat, TestConstants.defaultBlockHeight.toLong())) - ).right!! - ) - ) - ) - assertEquals(setOf(expected), result.actions.toSet()) + assertTrue(result.actions.isEmpty()) + checkDbPayment(incomingPayment, paymentHandler.db) } @Test - fun `receive pay-to-open payment with an incorrect payment secret`() = runSuspendTest { + fun `receive maybe_add_htlc with an incorrect payment secret`() = runSuspendTest { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(defaultAmount, defaultAmount, paymentSecret.reversed())) // <--- wrong secret - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - + val add = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret.reversed())) // <--- wrong secret + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertEquals(incomingPayment, result.incomingPayment) - val expected = PayToOpenResponseCommand( - PayToOpenResponse( - payToOpenRequest.chainHash, - payToOpenRequest.paymentHash, - PayToOpenResponse.Result.Failure( - OutgoingPaymentPacket.buildHtlcFailure( - paymentHandler.nodeParams.nodePrivateKey, - payToOpenRequest.paymentHash, - payToOpenRequest.finalPacket, - ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(payToOpenRequest.amountMsat, TestConstants.defaultBlockHeight.toLong())) - ).right!! - ) - ) + assertTrue(result.actions.isEmpty()) + checkDbPayment(incomingPayment, paymentHandler.db) + } + + @Test + fun `receive maybe_add_htlc with fee too high`() = runSuspendTest { + val inboundLiquidityTarget = 100_000.sat + val expectedFee = 3500.sat + assertEquals(expectedFee, TestConstants.leaseRate.fees(TestConstants.feeratePerKw, inboundLiquidityTarget, inboundLiquidityTarget).total) + val defaultPolicy = LiquidityPolicy.Auto(inboundLiquidityTarget, maxAbsoluteFee = 3500.sat, maxRelativeFeeBasisPoints = 10_000, skipAbsoluteFeeCheck = false) + val testCases = listOf( + // If payment amount is at least twice the fees, we accept the payment. + Triple(defaultPolicy, 7_000_000.msat, true), + // If fee is above our liquidity policy maximum fee, we reject the payment. + Triple(defaultPolicy.copy(maxAbsoluteFee = 3499.sat), 7_000_000.msat, false), + // If we disabled automatic liquidity management, we reject the payment. + Triple(LiquidityPolicy.Disable, 7_000_000.msat, false), + // If payment is too close to the fee, we reject the payment. + Triple(defaultPolicy, 6_999_999.msat, false), ) - assertEquals(setOf(expected), result.actions.toSet()) + testCases.forEach { (policy, paymentAmount, success) -> + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(paymentAmount) + paymentHandler.nodeParams.liquidityPolicy.emit(policy) + val add = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(paymentAmount, paymentAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + if (success) { + assertIs(result) + } else { + assertIs(result) + assertTrue(result.actions.isEmpty()) + } + } } @Test - fun `receive pay-to-open payment with a fee too high`() = runSuspendTest { + fun `receive trampoline payment with maybe_add_htlc`() = runSuspendTest { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)).copy(payToOpenFeeSatoshis = 2_000.sat) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - - assertIs(result) - assertEquals(incomingPayment, result.incomingPayment) - val expected = PayToOpenResponseCommand( - PayToOpenResponse( - payToOpenRequest.chainHash, - payToOpenRequest.paymentHash, - PayToOpenResponse.Result.Failure( - OutgoingPaymentPacket.buildHtlcFailure( - paymentHandler.nodeParams.nodePrivateKey, - payToOpenRequest.paymentHash, - payToOpenRequest.finalPacket, - ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure) - ).right!! - ) - ) + checkDbPayment(incomingPayment, paymentHandler.db) + val trampolineHops = listOf( + NodeHop(TestConstants.Alice.nodeParams.nodeId, TestConstants.Bob.nodeParams.nodeId, CltvExpiryDelta(144), 0.msat) ) - assertEquals(setOf(expected), result.actions.toSet()) + val finalPayload = makeMppPayload(defaultAmount, defaultAmount, paymentSecret) + val (_, _, packetAndSecrets) = OutgoingPaymentPacket.buildPacket(incomingPayment.paymentHash, trampolineHops, finalPayload, payloadLength = null) + assertTrue(packetAndSecrets.packet.payload.size() < 500) + // When our peer is used as trampoline node, they directly send the trampoline onion in maybe_add_htlc instead of wrapping it in a payment onion. + val add = MaybeAddHtlc(Chain.Regtest.chainHash, finalPayload.amount, incomingPayment.paymentHash, finalPayload.expiry, packetAndSecrets.packet) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) + + assertIs(result) + val expected = OpenOrSplicePayment(defaultAmount, incomingPayment.preimage) + assertEquals(listOf(expected), result.actions) + + // The on-the-fly funding part is pending in the db. + assertTrue(result.received.receivedWith.any { it is IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending }) + assertEquals(0.msat, result.received.amount) + assertEquals(0.msat, result.received.fees) } @Test - fun `receive pay-to-open trampoline payment with an incorrect payment secret`() = runSuspendTest { + fun `receive maybe_add_htlc trampoline payment with an incorrect payment secret`() = runSuspendTest { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) val trampolineHops = listOf( NodeHop(TestConstants.Alice.nodeParams.nodeId, TestConstants.Bob.nodeParams.nodeId, CltvExpiryDelta(144), 0.msat) ) - val payToOpenRequest = PayToOpenRequest( - chainHash = BlockHash(ByteVector32.Zeroes), - fundingSatoshis = 100_000.sat, - amountMsat = defaultAmount, - payToOpenMinAmountMsat = 1_000_000.msat, - payToOpenFeeSatoshis = 100.sat, - paymentHash = incomingPayment.paymentHash, - expireAt = Long.MAX_VALUE, - finalPacket = OutgoingPaymentPacket.buildPacket( - paymentHash = incomingPayment.paymentHash, - hops = trampolineHops, - finalPayload = makeMppPayload(defaultAmount, defaultAmount, paymentSecret.reversed()), // <-- wrong secret - payloadLength = 400 - ).third.packet - ) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - + val finalPayload = makeMppPayload(defaultAmount, defaultAmount, paymentSecret.reversed()) // <-- wrong secret + val (_, _, packetAndSecrets) = OutgoingPaymentPacket.buildPacket(incomingPayment.paymentHash, trampolineHops, finalPayload, payloadLength = null) + assertTrue(packetAndSecrets.packet.payload.size() < 500) + // When our peer is used as trampoline node, they directly send the trampoline onion in maybe_add_htlc instead of wrapping it in a payment onion. + val add = MaybeAddHtlc(Chain.Regtest.chainHash, finalPayload.amount, incomingPayment.paymentHash, finalPayload.expiry, packetAndSecrets.packet) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertEquals(incomingPayment, result.incomingPayment) - val expected = PayToOpenResponseCommand( - PayToOpenResponse( - payToOpenRequest.chainHash, - payToOpenRequest.paymentHash, - PayToOpenResponse.Result.Failure( - OutgoingPaymentPacket.buildHtlcFailure( - paymentHandler.nodeParams.nodePrivateKey, - payToOpenRequest.paymentHash, - payToOpenRequest.finalPacket, - ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(payToOpenRequest.amountMsat, TestConstants.defaultBlockHeight.toLong())) - ).right!! - ) - ) - ) - assertEquals(setOf(expected), result.actions.toSet()) + assertTrue(result.actions.isEmpty()) } @Test - fun `receive multipart payment with multiple HTLCs via same channel`() = runSuspendTest { + fun `receive multipart payment with single HTLC`() = runSuspendTest { + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) + checkDbPayment(incomingPayment, paymentHandler.db) val channelId = randomBytes32() - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) - val totalAmount = amount1 + amount2 - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) + val add = makeUpdateAddHtlc(12, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) - // Step 1 of 2: - // - Alice sends first multipart htlc to Bob - // - Bob doesn't accept the MPP set yet - run { - val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) - assertIs(result) - assertNull(result.incomingPayment.received) - assertTrue(result.actions.isEmpty()) - } + assertIs(result) + val expected = ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true) + assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) - // Step 2 of 2: - // - Alice sends second multipart htlc to Bob - // - Bob now accepts the MPP set - run { - val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) - assertIs(result) - val (expectedActions, expectedReceivedWith) = setOf( - // @formatter:off - WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0), - WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 1), - // @formatter:on - ).unzip() - assertEquals(expectedActions.toSet(), result.actions.toSet()) - assertEquals(totalAmount, result.received.amount) - assertEquals(expectedReceivedWith, result.received.receivedWith) - checkDbPayment(result.incomingPayment, paymentHandler.db) - } + assertEquals(result.incomingPayment.received, result.received) + assertEquals(defaultAmount, result.received.amount) + assertEquals(listOf(IncomingPayment.ReceivedWith.LightningPayment(amount = defaultAmount, channelId = channelId, htlcId = 12)), result.received.receivedWith) + + checkDbPayment(result.incomingPayment, paymentHandler.db) } @Test - fun `receive multipart payment with multiple HTLCs via different channels`() = runSuspendTest { - val (channelId1, channelId2) = Pair(randomBytes32(), randomBytes32()) - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) + fun `receive multipart payment with multiple HTLCs`() = runSuspendTest { + val channelId = randomBytes32() + val (amount1, amount2) = Pair(100_000_000.msat, 50_000_000.msat) val totalAmount = amount1 + amount2 val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) @@ -415,9 +373,10 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Alice sends first multipart htlc to Bob // - Bob doesn't accept the MPP set yet run { - val add = makeUpdateAddHtlc(7, channelId1, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) + assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) } @@ -425,13 +384,13 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Alice sends second multipart htlc to Bob // - Bob now accepts the MPP set run { - val add = makeUpdateAddHtlc(5, channelId2, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off - WrappedChannelCommand(channelId1, ChannelCommand.Htlc.Settlement.Fulfill(7, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId1, 7), - WrappedChannelCommand(channelId2, ChannelCommand.Htlc.Settlement.Fulfill(5, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId2, 5), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0), + WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(1, defaultPreimage, commit = true)) to IncomingPayment.ReceivedWith.LightningPayment(amount2, channelId, 1), // @formatter:on ).unzip() assertEquals(expectedActions.toSet(), result.actions.toSet()) @@ -443,8 +402,6 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { @Test fun `receive multipart payment after disconnection`() = runSuspendTest { - // Write exactly the scenario that happened in the witnessed issue. - // Modify purgePayToOpenRequests to purge all pending HTLCs *for the given disconnected node* (to support future multi-node) val channelId = randomBytes32() val (amount1, amount2) = Pair(75_000.msat, 75_000.msat) val totalAmount = amount1 + amount2 @@ -453,7 +410,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 1: Alice sends first multipart htlc to Bob. val add1 = run { val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) @@ -465,7 +422,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 3: on reconnection, the HTLC from step 1 is processed again. run { - val result = paymentHandler.process(add1, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertNull(result.incomingPayment.received) assertTrue(result.actions.isEmpty()) @@ -474,7 +431,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 4: Alice sends second multipart htlc to Bob. run { val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val (expectedActions, expectedReceivedWith) = setOf( // @formatter:off @@ -490,44 +447,9 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } @Test - fun `receive multipart payment via pay-to-open`() = runSuspendTest { - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) - val totalAmount = amount1 + amount2 - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) - - // Step 1 of 2: - // - Alice sends first multipart htlc to Bob - // - Bob doesn't accept the MPP set yet - run { - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - assertIs(result) - assertTrue(result.actions.isEmpty()) - } - - // Step 2 of 2: - // - Alice sends second multipart htlc to Bob - // - Bob now accepts the MPP set - run { - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) - assertIs(result) - - val payToOpenResponse = PayToOpenResponse(payToOpenRequest.chainHash, payToOpenRequest.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage)) - assertEquals(result.actions, listOf(PayToOpenResponseCommand(payToOpenResponse))) - - // pay-to-open parts are not yet provided - assertTrue(result.received.receivedWith.isEmpty()) - assertEquals(0.msat, result.received.fees) - - checkDbPayment(result.incomingPayment, paymentHandler.db) - } - } - - @Test - fun `receive multipart payment with a mix of HTLC and pay-to-open`() = runSuspendTest { + fun `receive multipart payment with a mix of HTLC and maybe_add_htlc`() = runSuspendTest { val channelId = randomBytes32() - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) + val (amount1, amount2) = Pair(100_000_000.msat, 50_000_000.msat) val totalAmount = amount1 + amount2 val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) @@ -536,36 +458,58 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob doesn't accept the MPP set yet run { val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) - assertTrue { result.actions.isEmpty() } + assertTrue(result.actions.isEmpty()) } // Step 2 of 2: - // - Alice sends second multipart htlc to Bob + // - Alice sends second multipart htlc to Bob using maybe_add_htlc // - Bob now accepts the MPP set run { - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) + val add = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertEquals(2, result.actions.size) - assertContains(result.actions, WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, incomingPayment.preimage, commit = true))) - assertContains(result.actions, PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest.chainHash, payToOpenRequest.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage)))) - - // the pay-to-open part is not yet provided - assertEquals(1, result.received.receivedWith.size) - assertContains(result.received.receivedWith, IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0)) + assertEquals(result.actions.first(), OpenOrSplicePayment(amount2, incomingPayment.preimage)) + assertEquals(result.actions.last(), WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(0, incomingPayment.preimage, commit = true))) + + // The on-the-fly funding part is pending in the db, we only mark the HTLC amount as received. + assertEquals(2, result.received.receivedWith.size) + assertEquals(result.received.receivedWith.first(), IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0)) + assertEquals(result.received.receivedWith.last(), IncomingPayment.ReceivedWith.OnChainIncomingPayment.Pending(amount2)) + assertEquals(amount1, result.received.amount) assertEquals(0.msat, result.received.fees) - checkDbPayment(result.incomingPayment, paymentHandler.db) + // The on-the-fly funding part completes with a splice. + val action = ChannelAction.Storage.StoreIncomingPayment.ViaSpliceIn( + amount = amount2 - 2_000_000.msat, + serviceFee = 0.msat, + miningFee = 2_000.sat, + localInputs = emptySet(), + txId = TxId(randomBytes32()), + origin = Origin.OffChainPayment(incomingPayment.preimage, amount2, TransactionFees(miningFee = 2_000.sat, serviceFee = 0.sat)) + ) + paymentHandler.process(channelId, action) + paymentHandler.db.getIncomingPayment(incomingPayment.paymentHash).also { dbPayment -> + assertNotNull(dbPayment) + assertIs(dbPayment.origin) + val receivedWith = dbPayment.received?.receivedWith + assertNotNull(receivedWith) + assertEquals(2, receivedWith.size) + assertEquals(receivedWith.first(), IncomingPayment.ReceivedWith.LightningPayment(amount1, channelId, 0)) + assertEquals(receivedWith.last(), IncomingPayment.ReceivedWith.OnChainIncomingPayment.Received.SpliceIn(action.amount, action.serviceFee, action.miningFee, channelId, action.txId, confirmedAt = null, lockedAt = null)) + assertEquals(148_000_000.msat, dbPayment.received?.amount) + assertEquals(2_000_000.msat, dbPayment.received?.fees) + } } } @Test - fun `receive multipart payment with a mix of HTLC and pay-to-open -- fee too high`() = runSuspendTest { + fun `receive multipart payment with a mix of HTLC and maybe_add_htlc -- fee too high`() = runSuspendTest { val channelId = randomBytes32() - val (amount1, amount2) = Pair(100_000.msat, 50_000.msat) + val (amount1, amount2) = Pair(100_000_000.msat, 50_000_000.msat) val totalAmount = amount1 + amount2 val (paymentHandler, incomingPayment, paymentSecret) = createFixture(totalAmount) @@ -574,39 +518,21 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob doesn't accept the MPP set yet run { val add = makeUpdateAddHtlc(0, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } // Step 2 of 2: - // - Alice sends second multipart htlc to Bob + // - Alice sends second multipart htlc to Bob using maybe_add_htlc // - Bob has received the complete MPP set run { - val payToOpenRequest = makePayToOpenRequest(incomingPayment, makeMppPayload(amount2, totalAmount, paymentSecret)).copy(payToOpenFeeSatoshis = 2_000.sat) - val result = paymentHandler.process(payToOpenRequest, TestConstants.defaultBlockHeight) + paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = null, maxAbsoluteFee = 500.sat, maxRelativeFeeBasisPoints = 100, skipAbsoluteFeeCheck = false)) + val add = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) - val expected = setOf( - WrappedChannelCommand( - channelId, - ChannelCommand.Htlc.Settlement.Fail(0, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure), commit = true) - ), - PayToOpenResponseCommand( - PayToOpenResponse( - payToOpenRequest.chainHash, - payToOpenRequest.paymentHash, - PayToOpenResponse.Result.Failure( - OutgoingPaymentPacket.buildHtlcFailure( - paymentHandler.nodeParams.nodePrivateKey, - payToOpenRequest.paymentHash, - payToOpenRequest.finalPacket, - ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure) - ).right!! - ) - ) - ) - ) - assertEquals(expected, result.actions.toSet()) + val fail = ChannelCommand.Htlc.Settlement.Fail(0, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure), commit = true) + assertEquals(listOf(WrappedChannelCommand(channelId, fail)), result.actions) } } @@ -614,7 +540,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { fun `receive normal single HTLC with amount-less invoice`() = runSuspendTest { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(invoiceAmount = null) val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount, defaultAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = WrappedChannelCommand(add.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true)) @@ -633,7 +559,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob doesn't accept the MPP set yet run { val add = makeUpdateAddHtlc(7, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -643,7 +569,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob now accepts the MPP set run { val add = makeUpdateAddHtlc(11, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = setOf( WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(7, incomingPayment.preimage, commit = true)), @@ -672,7 +598,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Alice sends first 2 multipart htlcs to Bob. // - Bob doesn't accept the MPP set yet listOf(add1, add2).forEach { add -> - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -681,7 +607,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Alice sends third multipart htlc to Bob // - Bob now accepts the MPP set run { - val result = paymentHandler.process(add3, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add3, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = setOf( WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(3, incomingPayment.preimage, commit = true)), @@ -696,7 +622,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { fun `receive normal single HTLC over-payment`() = runSuspendTest { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(150_000.msat) val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeSinglePartPayload(170_000.msat, paymentSecret)).copy(amountMsat = 175_000.msat) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = WrappedChannelCommand(add.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true)) assertEquals(setOf(expected), result.actions.toSet()) @@ -707,7 +633,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeSinglePartPayload(defaultAmount, paymentSecret)) val addGreaterExpiry = add.copy(cltvExpiry = add.cltvExpiry + CltvExpiryDelta(6)) - val result = paymentHandler.process(addGreaterExpiry, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(addGreaterExpiry, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = WrappedChannelCommand(add.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add.id, incomingPayment.preimage, commit = true)) assertEquals(setOf(expected), result.actions.toSet()) @@ -719,18 +645,18 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // We receive a first multipart htlc. val add1 = makeUpdateAddHtlc(3, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount / 2, defaultAmount, paymentSecret)) - val result1 = paymentHandler.process(add1, TestConstants.defaultBlockHeight) + val result1 = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) assertTrue(result1.actions.isEmpty()) // This htlc is reprocessed (e.g. because the wallet restarted). - val result1b = paymentHandler.process(add1, TestConstants.defaultBlockHeight) + val result1b = paymentHandler.process(add1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1b) assertTrue(result1b.actions.isEmpty()) // We receive the second multipart htlc. val add2 = makeUpdateAddHtlc(5, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount / 2, defaultAmount, paymentSecret)) - val result2 = paymentHandler.process(add2, TestConstants.defaultBlockHeight) + val result2 = paymentHandler.process(add2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) assertEquals(defaultAmount, result2.received.amount) val expected = setOf( @@ -740,7 +666,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { assertEquals(expected, result2.actions.toSet()) // The second htlc is reprocessed (e.g. because our peer disconnected before we could send them the preimage). - val result2b = paymentHandler.process(add2, TestConstants.defaultBlockHeight) + val result2b = paymentHandler.process(add2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2b) assertEquals(defaultAmount, result2b.received.amount) assertEquals(listOf(WrappedChannelCommand(add2.channelId, ChannelCommand.Htlc.Settlement.Fulfill(add2.id, incomingPayment.preimage, commit = true))), result2b.actions) @@ -752,7 +678,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // We receive a first multipart htlc. val add = makeUpdateAddHtlc(1, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount / 2, defaultAmount, paymentSecret)) - val result1 = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result1 = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) assertTrue(result1.actions.isEmpty()) @@ -762,7 +688,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { assertEquals(listOf(WrappedChannelCommand(add.channelId, addTimeout)), actions1) // For some reason, the channel was offline, didn't process the failure and retransmits the htlc. - val result2 = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result2 = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) assertTrue(result2.actions.isEmpty()) @@ -772,7 +698,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // The channel was offline again, didn't process the failure and retransmits the htlc, but it is now close to its expiry. val currentBlockHeight = add.cltvExpiry.toLong().toInt() - 3 - val result3 = paymentHandler.process(add, currentBlockHeight) + val result3 = paymentHandler.process(add, currentBlockHeight, TestConstants.feeratePerKw) assertIs(result3) val addExpired = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, currentBlockHeight.toLong())), commit = true) assertEquals(listOf(WrappedChannelCommand(add.channelId, addExpired)), result3.actions) @@ -780,7 +706,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { @Test fun `invoice expired`() = runSuspendTest { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRate) val (incomingPayment, paymentSecret) = makeIncomingPayment( payee = paymentHandler, amount = defaultAmount, @@ -788,7 +714,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { expirySeconds = 3600 // one hour expiration ) val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(10_000.msat, defaultAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) @@ -799,7 +725,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { fun `invoice unknown`() = runSuspendTest { val (paymentHandler, _, _) = createFixture(defaultAmount) val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, randomBytes32(), makeMppPayload(defaultAmount, defaultAmount, randomBytes32())) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) @@ -810,9 +736,9 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { fun `invalid onion`() = runSuspendTest { val (paymentHandler, incomingPayment, _) = createFixture(defaultAmount) val cltvExpiry = CltvExpiryDelta(144).toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) - val badOnion = OnionRoutingPacket(0, ByteVector("0x02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619"), Lightning.randomBytes(OnionRoutingPacket.PaymentPacketLength).toByteVector(), randomBytes32()) + val badOnion = OnionRoutingPacket(0, ByteVector("0x02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619"), randomBytes(OnionRoutingPacket.PaymentPacketLength).toByteVector(), randomBytes32()) val add = UpdateAddHtlc(randomBytes32(), 0, defaultAmount, incomingPayment.paymentHash, cltvExpiry, badOnion) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) // The current flow of error checking within the codebase would be: @@ -829,7 +755,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) val lowExpiry = CltvExpiryDelta(2) val add = makeUpdateAddHtlc(0, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(defaultAmount / 2, defaultAmount, paymentSecret, lowExpiry)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) @@ -847,7 +773,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { ) payloads.forEach { payload -> val add = makeUpdateAddHtlc(3, randomBytes32(), paymentHandler, incomingPayment.paymentHash, payload) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(payload.totalAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) @@ -866,7 +792,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob doesn't accept the MPP set yet run { val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -878,7 +804,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val payload = makeMppPayload(amount2, totalAmount + MilliSatoshi(1), paymentSecret) val add = makeUpdateAddHtlc(2, channelId, paymentHandler, incomingPayment.paymentHash, payload) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = setOf( WrappedChannelCommand( @@ -905,7 +831,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob doesn't accept the MPP set yet run { val add = makeUpdateAddHtlc(1, randomBytes32(), paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -916,7 +842,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val payload = makeMppPayload(amount2, totalAmount, randomBytes32()) // <--- invalid payment secret val add = makeUpdateAddHtlc(1, randomBytes32(), paymentHandler, incomingPayment.paymentHash, payload) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(totalAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) @@ -934,7 +860,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { listOf(1L, 2L).forEach { id -> val add = makeUpdateAddHtlc(id, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(10_000.msat, defaultAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -973,7 +899,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Alice sends single (unfinished) multipart htlc to Bob. run { val add = makeUpdateAddHtlc(1, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -991,7 +917,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Alice tries again, and sends another single (unfinished) multipart htlc to Bob. run { val add = makeUpdateAddHtlc(3, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount1, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) assertTrue(result.actions.isEmpty()) } @@ -1001,7 +927,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Bob accepts htlc set run { val add = makeUpdateAddHtlc(4, channelId, paymentHandler, incomingPayment.paymentHash, makeMppPayload(amount2, totalAmount, paymentSecret)) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = setOf( WrappedChannelCommand(channelId, ChannelCommand.Htlc.Settlement.Fulfill(3, incomingPayment.preimage, commit = true)), @@ -1025,11 +951,11 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 1 of 2: // - Alice receives complete mpp set run { - val result1 = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight) + val result1 = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) assertTrue(result1.actions.isEmpty()) - val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight) + val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) val expected = setOf( @@ -1042,7 +968,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 2 of 2: // - Alice receives local replay of htlc1 for the invoice she already completed. Must be fulfilled. run { - val result = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = WrappedChannelCommand(channelId1, ChannelCommand.Htlc.Settlement.Fulfill(htlc1.id, incomingPayment.preimage, commit = true)) assertEquals(setOf(expected), result.actions.toSet()) @@ -1063,11 +989,11 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // Step 1 of 2: // - Alice receives complete mpp set run { - val result1 = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight) + val result1 = paymentHandler.process(htlc1, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result1) assertTrue(result1.actions.isEmpty()) - val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight) + val result2 = paymentHandler.process(htlc2, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result2) val expected = setOf( @@ -1081,7 +1007,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // - Alice receives an additional htlc (with new id) on channel1 for the invoice she already completed. Must be rejected. run { val add = htlc1.copy(id = 3) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = WrappedChannelCommand( channelId1, ChannelCommand.Htlc.Settlement.Fail( @@ -1097,7 +1023,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { run { val channelId3 = randomBytes32() val add = htlc2.copy(channelId = channelId3) - val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertIs(result) val expected = WrappedChannelCommand( channelId3, ChannelCommand.Htlc.Settlement.Fail( @@ -1112,7 +1038,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { @Test fun `purge expired incoming payments`() = runSuspendTest { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRate) // create incoming payment that has expired and not been paid val expiredInvoice = paymentHandler.createInvoice( @@ -1128,7 +1054,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { ) paymentHandler.db.receivePayment( paidInvoice.paymentHash, - receivedWith = listOf(IncomingPayment.ReceivedWith.NewChannel(amount = 15_000_000.msat, serviceFee = 1_000_000.msat, miningFee = 0.sat, channelId = randomBytes32(), txId = TxId(randomBytes32()), confirmedAt = null, lockedAt = null)), + receivedWith = listOf(IncomingPayment.ReceivedWith.OnChainIncomingPayment.Received.NewChannel(15_000_000.msat, 1_000_000.msat, 0.sat, randomBytes32(), TxId(randomBytes32()), confirmedAt = null, lockedAt = null)), receivedAt = 101 // simulate incoming payment being paid before it expired ) @@ -1152,7 +1078,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { companion object { val defaultPreimage = randomBytes32() val defaultPaymentHash = Crypto.sha256(defaultPreimage).toByteVector32() - val defaultAmount = 100_000.msat + val defaultAmount = 150_000_000.msat private fun channelHops(destination: PublicKey): List { val dummyKey = PrivateKey(ByteVector32("0101010101010101010101010101010101010101010101010101010101010101")).publicKey() @@ -1182,6 +1108,11 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { return UpdateAddHtlc(channelId, id, finalPayload.amount, paymentHash, finalPayload.expiry, packetAndSecrets.packet) } + private fun makeMaybeAddHtlc(destination: IncomingPaymentHandler, paymentHash: ByteVector32, finalPayload: PaymentOnion.FinalPayload): MaybeAddHtlc { + val (_, _, packetAndSecrets) = OutgoingPaymentPacket.buildPacket(paymentHash, channelHops(destination.nodeParams.nodeId), finalPayload, OnionRoutingPacket.PaymentPacketLength) + return MaybeAddHtlc(Chain.Regtest.chainHash, finalPayload.amount, paymentHash, finalPayload.expiry, packetAndSecrets.packet) + } + private fun makeSinglePartPayload( amount: MilliSatoshi, paymentSecret: ByteVector32, @@ -1203,26 +1134,6 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { return PaymentOnion.FinalPayload.createMultiPartPayload(amount, totalAmount, expiry, paymentSecret, null) } - const val payToOpenFeerate = 0.1 - - private fun makePayToOpenRequest(incomingPayment: IncomingPayment, finalPayload: PaymentOnion.FinalPayload): PayToOpenRequest { - return PayToOpenRequest( - chainHash = Block.RegtestGenesisBlock.hash, - fundingSatoshis = 100_000.sat, - amountMsat = finalPayload.amount, - payToOpenMinAmountMsat = 10_000.msat, - payToOpenFeeSatoshis = finalPayload.amount.truncateToSatoshi() * payToOpenFeerate, // 10% - paymentHash = incomingPayment.paymentHash, - expireAt = Long.MAX_VALUE, - finalPacket = OutgoingPaymentPacket.buildPacket( - paymentHash = incomingPayment.paymentHash, - hops = channelHops(TestConstants.Bob.nodeParams.nodeId), - finalPayload = finalPayload, - payloadLength = OnionRoutingPacket.PaymentPacketLength - ).third.packet - ) - } - private suspend fun makeIncomingPayment(payee: IncomingPaymentHandler, amount: MilliSatoshi?, expirySeconds: Long? = null, timestamp: Long = currentTimestampSeconds()): Pair { val paymentRequest = payee.createInvoice(defaultPreimage, amount, Either.Left("unit test"), listOf(), expirySeconds, timestamp) assertNotNull(paymentRequest.paymentMetadata) @@ -1239,7 +1150,9 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } private suspend fun createFixture(invoiceAmount: MilliSatoshi?): Triple { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRate) + // We use a liquidity policy that accepts payment values used by default in this test file. + paymentHandler.nodeParams.liquidityPolicy.emit(LiquidityPolicy.Auto(inboundLiquidityTarget = null, maxAbsoluteFee = 5_000.sat, maxRelativeFeeBasisPoints = 500, skipAbsoluteFeeCheck = false)) val (incomingPayment, paymentSecret) = makeIncomingPayment(paymentHandler, invoiceAmount) return Triple(paymentHandler, incomingPayment, paymentSecret) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt index 868530494..bbb0d7dd6 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt @@ -454,7 +454,7 @@ class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { val outgoingPaymentHandler = OutgoingPaymentHandler(TestConstants.Alice.nodeParams, defaultWalletParams, InMemoryPaymentsDb()) // The invoice comes from Bob, our direct peer (and trampoline node). val preimage = randomBytes32() - val incomingPaymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val incomingPaymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRate) val invoice = incomingPaymentHandler.createInvoice(preimage, amount = null, Either.Left("phoenix to phoenix"), listOf()) val payment = SendPayment(UUID.randomUUID(), 300_000.msat, invoice.nodeId, invoice) @@ -473,9 +473,9 @@ class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { } // Bob receives these 2 HTLCs. - val process1 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[0].first, adds[0].second, 3), TestConstants.defaultBlockHeight) + val process1 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[0].first, adds[0].second, 3), TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertTrue(process1 is IncomingPaymentHandler.ProcessAddResult.Pending) - val process2 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[1].first, adds[1].second, 5), TestConstants.defaultBlockHeight) + val process2 = incomingPaymentHandler.process(makeUpdateAddHtlc(adds[1].first, adds[1].second, 5), TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) assertTrue(process2 is IncomingPaymentHandler.ProcessAddResult.Accepted) val fulfills = process2.actions.filterIsInstance().mapNotNull { it.channelCommand as? ChannelCommand.Htlc.Settlement.Fulfill } assertEquals(2, fulfills.size) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt b/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt index db8a221f8..78d77fbb9 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt @@ -78,7 +78,6 @@ object TestConstants { Feature.PaymentMetadata to FeatureSupport.Optional, Feature.ExperimentalTrampolinePayment to FeatureSupport.Optional, Feature.WakeUpNotificationProvider to FeatureSupport.Optional, - Feature.PayToOpenProvider to FeatureSupport.Optional, Feature.ChannelBackupProvider to FeatureSupport.Optional, ), dustLimit = 1_100.sat, diff --git a/src/commonTest/kotlin/fr/acinq/lightning/tests/io/peer/builders.kt b/src/commonTest/kotlin/fr/acinq/lightning/tests/io/peer/builders.kt index 29e06ccf5..0e4bade22 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/tests/io/peer/builders.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/tests/io/peer/builders.kt @@ -60,8 +60,8 @@ suspend fun connect( automateMessaging: Boolean = true ): PeerTuple { val logger = MDCLogger(testLoggerFactory.newLogger("PeerConnection")) - val aliceConnection = PeerConnection(connectionId, Channel(Channel.UNLIMITED), logger) - val bobConnection = PeerConnection(connectionId, Channel(Channel.UNLIMITED), logger) + val aliceConnection = PeerConnection(connectionId, Channel(Channel.UNLIMITED), Channel(Channel.UNLIMITED), logger) + val bobConnection = PeerConnection(connectionId, Channel(Channel.UNLIMITED), Channel(Channel.UNLIMITED), logger) alice.send(Connected(aliceConnection)) bob.send(Connected(bobConnection)) @@ -142,7 +142,7 @@ suspend fun CoroutineScope.newPeer( val peer = buildPeer(this, nodeParams, walletParams, db) val logger = MDCLogger(nodeParams.loggerFactory.newLogger("PeerConnection")) - val connection = PeerConnection(0, Channel(Channel.UNLIMITED), logger) + val connection = PeerConnection(0, Channel(Channel.UNLIMITED), Channel(Channel.UNLIMITED), logger) peer.send(Connected(connection)) remotedNodeChannelState?.let { state -> @@ -197,7 +197,7 @@ suspend fun buildPeer( fastFeerate = FeeratePerKw(FeeratePerByte(50.sat)) ) val logger = MDCLogger(nodeParams.loggerFactory.newLogger("PeerConnection")) - val connection = PeerConnection(0, Channel(Channel.UNLIMITED), logger) + val connection = PeerConnection(0, Channel(Channel.UNLIMITED), Channel(Channel.UNLIMITED), logger) peer.send(Connected(connection)) return peer diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt index 8cc9475af..e4bdf1735 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -292,12 +292,14 @@ class LightningCodecsTestsCommon : LightningTestSuite() { // @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 defaultEncoded = ByteVector("0040 0000000000000000000000000000000000000000000000000000000000000000 0100000000000000000000000000000000000000000000000000000000000000 00001388 00000fa0 000000000003d090 00000000000001f4 000000000000c350 000000000000000f 0090 01e3 0009eb10 031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f 024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766 02531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337 03462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b 0362c0a046dacce86ddd0343c6d3c7c79c2208ba0d9c9cf24a6d046d21d21f90f7 03f006a18d5653c4edf5391ff23a61f03ff83d237e880ee61187fa9f379a028e0a 02989c0b76cb563971fdc9bef31ec06c3560f3249d6ee9e5d83c57625596e05f6f 00") + val preimage = ByteVector32.fromValidHex("3fb4b5b04e3baca4e2beab9cc096180024b324b6bbb939c7e9a7bc9c61ee4bf5") 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.RequestFunds(50_000.sat, 0, 500_000), ChannelTlv.OnTheFlyFundingPreimage(preimage))) to (defaultEncoded + ByteVector("0103101000 fd05390e000000000000c35000000007a120 fe4700000a203fb4b5b04e3baca4e2beab9cc096180024b324b6bbb939c7e9a7bc9c61ee4bf5")), 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")), ) // @formatter:on @@ -479,15 +481,17 @@ class LightningCodecsTestsCommon : LightningTestSuite() { val channelId = ByteVector32("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") val fundingTxId = TxId(TxHash("24e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566")) val fundingPubkey = PublicKey(ByteVector.fromHex("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798")) + val preimage = ByteVector32.fromValidHex("3fb4b5b04e3baca4e2beab9cc096180024b324b6bbb939c7e9a7bc9c61ee4bf5") val testCases = listOf( // @formatter:off Stfu(channelId, false) to ByteVector("0002 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00"), Stfu(channelId, true) to ByteVector("0002 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 01"), SpliceInit(channelId, 100_000.sat, FeeratePerKw(2500.sat), 100, fundingPubkey) to ByteVector("9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), - 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, 150_000.sat, 25_000_000.msat, FeeratePerKw(2500.sat), 100, fundingPubkey, null, 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, ChannelTlv.RequestFunds(100_000.sat, 4000, 850_000), null) to ByteVector("9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd05390e00000000000186a00fa0000cf850"), + SpliceInit(channelId, 100_000.sat, FeeratePerKw(2500.sat), 100, fundingPubkey, TlvStream(ChannelTlv.RequestFunds(100_000.sat, 0, 850_000), ChannelTlv.OnTheFlyFundingPreimage(preimage))) to ByteVector("9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd05390e00000000000186a00000000cf850 fe4700000a203fb4b5b04e3baca4e2beab9cc096180024b324b6bbb939c7e9a7bc9c61ee4bf5"), 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"), @@ -770,19 +774,41 @@ class LightningCodecsTestsCommon : LightningTestSuite() { } @Test - fun `encode - decode pay-to-open messages`() { + fun `encode - decode maybe_add_htlc`() { + // @formatter:off + val paymentOnion = OnionRoutingPacket(0, ByteVector("03462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b"), ByteVector("9149ce01cce1709194109ab594037113e897ab6120025c770527dd8537997e2528082b984fe078a5667978a573abeaf7977d9b8b6ee4f124d3352f7eea52cc66c0e76b8f6d7a25d4501a04ae190b17baff8e6378b36f165815f714559dfef275278eba897f5f229be70fc8a1980cf859d1c25fe90c77f006419770e19d29ba80be8f613d039dd05600734e0d1e218af441fe30877e717a26b7b37c2c071d62bf6d61dd17f7abfb81546d2c722c9a6dc581aa97fb6f3b513e5fbaf0d669fbf0714b2b016a0a8e356d55f267fa144f7501792f2a59269c5a22e555a914e2eb71eba5af519564f246cf58983ea3fa2674e3ab7d9969d8dffbb2bda2b2752657417937d46601eb8ebf1837221d4bdf55a4d6a97ecffde5a09bd409717fa19e440e55d775890aed89f72e65af515757e94a9b501e6bad048af55e1583adb2960a84f60fb5efd0352e77a34045fc6b221498f62810bd8294d995d9f513696f8633f29faaa9668d0c6fa0d0dd7fa13e2c185572485762bd2810dc12187f521fbefa9c320762ac1e107f7988d81c6ee201ab68a95d45d578027e271b6526154317877037dca17134ccd955a22a8481b8e1996d896fc4bf006154ed18ef279d4f255e3f1233d037aea2560011069a0ae56d6bfdd8327054ded12d85d120b8982bff970986db333baae7c95f85677726a8f74cc8bd1e5aca3d433c113048305ecce8e35caf0485a53df00284b52b42291a9ffe686b96442135b3107d8856bc652d674ee9a148e79b02c9972d3ca8c2b02609f3b358c4a67c540ba6769c4d83169bceda640b1d18b74d12b6df605b417dacf6f82d79d43bb40920898f818dc8344c036ae9c8bbf9ef52ea1ccf225c8825a4d8503df190b999e15a4be34c9d7bbf60d3b93bb7d6559f4a5916f5e40c3defeeca9337ccd1280e46d6727c5c91c2d898b685543d4ca7cfee23981323c43260b6387e7febb0fffb200a8c974ef36b3253d0fb9fe0c1c6017f2dbbdc169f3f061d9781521e8118164aeec31c3e59c199016f1025c295d8f7bdeb627d357105a2708c4c3a856b9e83ff37ed69f59f2d2e464ed1db5882925ebe2493a7ddb707e1a308fa445172a24b3ea60732f75f5c69b41fc11467ee93f37c9a6f7285ba42f716e2a0e30909056ea3e4f7985d14ca9ab280cc184ce98e2a0722d0447aa1a2eedc5e53ddfa53731df7eced406b10627b0bebd768a30bde0d470c0f1d10adc070f8d3029cacceec74e4833f4dc8c52c3f41733f5f896fceb425d0737e717a63bfb033df46286d99594dd01e2bd0a942ab792874177b32842f4833bc0340ddb74852e9cd6f29f1d997a4a4bf05dd5d12011f95e6ce18928e3a9b83b24d15f989bdf43370bcc657c3ac6601eaf5e951efdbd7ee69b1623dc5039b2dfc640692378ef032f17bc36cc00293ad90b7e18f5feb8f287a7061ed9713929aed9b14b8d566199fc7822b1c38daa16b6d83077b10af0e2b6e531ccc34ea248ea593128c9ff17defcee6618c29cd2d93cfed99b90319104b1fdcfea91e98b41d792782840fb7b25280d8565b0bcd874e79b1b323139e7fc88eb5f80f690ce30fcd81111076adb31de6aeced879b538c0b5f2b74c027cc582a540133952cb021424510312f13e15d403f700f3e15b41d677c5a1e7c4e692c5880cb4522c48e993381996a29615d2956781509cd74aec6a3c73b8536d1817e473dad4cbb1787e046606b692a44e5d21ef6b5219658b002f674367e90a2b610924e9ac543362257d4567728f2e61f61231cb5d7816e100bb6f6bd9a42329b728b18d7a696711650c16fd476e2f471f38af0f6b00d45c6e"), ByteVector32("1fa492cc7962814953ab6ad1ce3d3f3dc950e64d18a8fdce6aabc14321576f06")) + val trampolineOnion = OnionRoutingPacket(0, ByteVector("02531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337"), ByteVector("1860c0749bfd613056cfc5718beecc25a2f255fc7abbea3cd75ff820e9d30807d19b30f33626452fa54bb2d822e918558ed3e6714deb3f9a2a10895e7553c6f088c9a852043530dbc9abcc486030894364b205f5de60171b451ff462664ebce23b672579bf2a444ebfe0a81875c26d2fa16d426795b9b02ccbc4bdf909c583f0c2ebe9136510645917153ecb05181ca0c1b207824578ee841804a148f4c3df7306"), ByteVector32("dcea52d94222907c9187bc31c0880fc084f0d88716e195c0abe7672d15217623")) + val paymentHash = ByteVector32.fromValidHex("a04f0e1256ac502c6c878f473a98fbf4e2adbf1921eb076c3f40e99ec3956d8a") val testCases = listOf( - PayToOpenRequest(BlockHash(randomBytes32()), 10_000.sat, 5_000.msat, 100.msat, 10.sat, randomBytes32(), 100, OnionRoutingPacket(0, randomKey().publicKey().value, ByteVector("0102030405"), randomBytes32())), - PayToOpenResponse(BlockHash(randomBytes32()), randomBytes32(), PayToOpenResponse.Result.Success(randomBytes32())), - PayToOpenResponse(BlockHash(randomBytes32()), randomBytes32(), PayToOpenResponse.Result.Failure(null)), - PayToOpenResponse(BlockHash(randomBytes32()), randomBytes32(), PayToOpenResponse.Result.Failure(ByteVector("deadbeef"))), + Pair(MaybeAddHtlc(Chain.Mainnet.chainHash, 500.msat, paymentHash, CltvExpiry(1105), paymentOnion), Hex.decode("88d3 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 00000000000001f4 a04f0e1256ac502c6c878f473a98fbf4e2adbf1921eb076c3f40e99ec3956d8a 00000451 0514 0003462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b9149ce01cce1709194109ab594037113e897ab6120025c770527dd8537997e2528082b984fe078a5667978a573abeaf7977d9b8b6ee4f124d3352f7eea52cc66c0e76b8f6d7a25d4501a04ae190b17baff8e6378b36f165815f714559dfef275278eba897f5f229be70fc8a1980cf859d1c25fe90c77f006419770e19d29ba80be8f613d039dd05600734e0d1e218af441fe30877e717a26b7b37c2c071d62bf6d61dd17f7abfb81546d2c722c9a6dc581aa97fb6f3b513e5fbaf0d669fbf0714b2b016a0a8e356d55f267fa144f7501792f2a59269c5a22e555a914e2eb71eba5af519564f246cf58983ea3fa2674e3ab7d9969d8dffbb2bda2b2752657417937d46601eb8ebf1837221d4bdf55a4d6a97ecffde5a09bd409717fa19e440e55d775890aed89f72e65af515757e94a9b501e6bad048af55e1583adb2960a84f60fb5efd0352e77a34045fc6b221498f62810bd8294d995d9f513696f8633f29faaa9668d0c6fa0d0dd7fa13e2c185572485762bd2810dc12187f521fbefa9c320762ac1e107f7988d81c6ee201ab68a95d45d578027e271b6526154317877037dca17134ccd955a22a8481b8e1996d896fc4bf006154ed18ef279d4f255e3f1233d037aea2560011069a0ae56d6bfdd8327054ded12d85d120b8982bff970986db333baae7c95f85677726a8f74cc8bd1e5aca3d433c113048305ecce8e35caf0485a53df00284b52b42291a9ffe686b96442135b3107d8856bc652d674ee9a148e79b02c9972d3ca8c2b02609f3b358c4a67c540ba6769c4d83169bceda640b1d18b74d12b6df605b417dacf6f82d79d43bb40920898f818dc8344c036ae9c8bbf9ef52ea1ccf225c8825a4d8503df190b999e15a4be34c9d7bbf60d3b93bb7d6559f4a5916f5e40c3defeeca9337ccd1280e46d6727c5c91c2d898b685543d4ca7cfee23981323c43260b6387e7febb0fffb200a8c974ef36b3253d0fb9fe0c1c6017f2dbbdc169f3f061d9781521e8118164aeec31c3e59c199016f1025c295d8f7bdeb627d357105a2708c4c3a856b9e83ff37ed69f59f2d2e464ed1db5882925ebe2493a7ddb707e1a308fa445172a24b3ea60732f75f5c69b41fc11467ee93f37c9a6f7285ba42f716e2a0e30909056ea3e4f7985d14ca9ab280cc184ce98e2a0722d0447aa1a2eedc5e53ddfa53731df7eced406b10627b0bebd768a30bde0d470c0f1d10adc070f8d3029cacceec74e4833f4dc8c52c3f41733f5f896fceb425d0737e717a63bfb033df46286d99594dd01e2bd0a942ab792874177b32842f4833bc0340ddb74852e9cd6f29f1d997a4a4bf05dd5d12011f95e6ce18928e3a9b83b24d15f989bdf43370bcc657c3ac6601eaf5e951efdbd7ee69b1623dc5039b2dfc640692378ef032f17bc36cc00293ad90b7e18f5feb8f287a7061ed9713929aed9b14b8d566199fc7822b1c38daa16b6d83077b10af0e2b6e531ccc34ea248ea593128c9ff17defcee6618c29cd2d93cfed99b90319104b1fdcfea91e98b41d792782840fb7b25280d8565b0bcd874e79b1b323139e7fc88eb5f80f690ce30fcd81111076adb31de6aeced879b538c0b5f2b74c027cc582a540133952cb021424510312f13e15d403f700f3e15b41d677c5a1e7c4e692c5880cb4522c48e993381996a29615d2956781509cd74aec6a3c73b8536d1817e473dad4cbb1787e046606b692a44e5d21ef6b5219658b002f674367e90a2b610924e9ac543362257d4567728f2e61f61231cb5d7816e100bb6f6bd9a42329b728b18d7a696711650c16fd476e2f471f38af0f6b00d45c6e1fa492cc7962814953ab6ad1ce3d3f3dc950e64d18a8fdce6aabc14321576f06")), + Pair(MaybeAddHtlc(Chain.Mainnet.chainHash, 500_000_000.msat, paymentHash, CltvExpiry(1729), trampolineOnion), Hex.decode("88d3 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 000000001dcd6500 a04f0e1256ac502c6c878f473a98fbf4e2adbf1921eb076c3f40e99ec3956d8a 000006c1 00a1 0002531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe3371860c0749bfd613056cfc5718beecc25a2f255fc7abbea3cd75ff820e9d30807d19b30f33626452fa54bb2d822e918558ed3e6714deb3f9a2a10895e7553c6f088c9a852043530dbc9abcc486030894364b205f5de60171b451ff462664ebce23b672579bf2a444ebfe0a81875c26d2fa16d426795b9b02ccbc4bdf909c583f0c2ebe9136510645917153ecb05181ca0c1b207824578ee841804a148f4c3df7306dcea52d94222907c9187bc31c0880fc084f0d88716e195c0abe7672d15217623")), ) + // @formatter:on + testCases.forEach { + val decoded = LightningMessage.decode(it.second) + assertNotNull(decoded) + assertEquals(it.first, decoded) + val encoded = LightningMessage.encode(decoded) + assertArrayEquals(it.second, encoded) + } + } + @Test + fun `encode - decode cancel_on_the_fly_funding`() { + val channelId = ByteVector32.fromValidHex("7254d3485152e694c57407f7954bb12ecf0a276ca48e72a49eef105507d49a7b") + val paymentHash = ByteVector32.fromValidHex("f71a0725d6d922ac29156af8d45492a9f2c5e1980e90661e0345a4f65853de99") + val testCases = listOf( + // @formatter:off + Pair(CancelOnTheFlyFunding(channelId, paymentHash, ByteVector("deadbeef")), Hex.decode("88d5 7254d3485152e694c57407f7954bb12ecf0a276ca48e72a49eef105507d49a7b f71a0725d6d922ac29156af8d45492a9f2c5e1980e90661e0345a4f65853de99 0004deadbeef")), + Pair(CancelOnTheFlyFunding(ByteVector32.Zeroes, paymentHash, ByteVector("deadbeef")), Hex.decode("88d5 0000000000000000000000000000000000000000000000000000000000000000 f71a0725d6d922ac29156af8d45492a9f2c5e1980e90661e0345a4f65853de99 0004deadbeef")), + // @formatter:on + ) testCases.forEach { - val encoded = LightningMessage.encode(it) - val decoded = LightningMessage.decode(encoded) + val decoded = LightningMessage.decode(it.second) assertNotNull(decoded) - assertEquals(it, decoded) + assertEquals(it.first, decoded) + val encoded = LightningMessage.encode(decoded) + assertArrayEquals(it.second, encoded) } } From 1b0f6ec129477f5da6306820c695472b8d7c623c Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 15 Mar 2024 11:02:30 +0100 Subject: [PATCH 4/6] Use a list of liquidity ads rates The liquidity seller may adapt their lease rates to the funding amount, to account for the fact that higher amounts will require more inputs, and thus a larger `funding_weight`. --- .../kotlin/fr/acinq/lightning/io/Peer.kt | 10 ++++++---- .../lightning/payment/IncomingPaymentHandler.kt | 3 ++- .../fr/acinq/lightning/wire/LiquidityAds.kt | 16 ++++++++++++++++ .../payment/IncomingPaymentHandlerTestsCommon.kt | 6 +++--- .../payment/OutgoingPaymentHandlerTestsCommon.kt | 2 +- .../fr/acinq/lightning/tests/TestConstants.kt | 2 ++ .../fr/acinq/lightning/tests/io/peer/builders.kt | 2 +- 7 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index bf713433c..8873daf91 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -110,7 +110,7 @@ data class PhoenixAndroidLegacyInfoEvent(val info: PhoenixAndroidLegacyInfo) : P * @param walletParams High level parameters for our node. It especially contains the Peer's [NodeUri]. * @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 leaseRate Rate at which our peer sells their liquidity. + * @param leaseRates Rates at which our peer sells their liquidity. * @param socketBuilder Builds the TCP socket used to connect to the Peer. * @param initTlvStream Optional stream of TLV for the [Init] message we send to this Peer after connection. Empty by default. */ @@ -120,7 +120,7 @@ class Peer( val walletParams: WalletParams, val watcher: ElectrumWatcher, val db: Databases, - val leaseRate: LiquidityAds.LeaseRate, + val leaseRates: List, socketBuilder: TcpSocket.Builder?, scope: CoroutineScope, private val initTlvStream: TlvStream = TlvStream.empty() @@ -167,7 +167,7 @@ class Peer( val eventsFlow: SharedFlow get() = _eventsFlow.asSharedFlow() // encapsulates logic for validating incoming payments - private val incomingPaymentHandler = IncomingPaymentHandler(nodeParams, db.payments, leaseRate) + private val incomingPaymentHandler = IncomingPaymentHandler(nodeParams, db.payments, leaseRates) // encapsulates logic for sending payments private val outgoingPaymentHandler = OutgoingPaymentHandler(nodeParams, walletParams, db.payments) @@ -1151,6 +1151,7 @@ class Peer( is LiquidityPolicy.Disable -> LiquidityPolicy.minInboundLiquidityTarget // we don't disable creating a channel using our own wallet inputs is LiquidityPolicy.Auto -> policy.inboundLiquidityTarget ?: LiquidityPolicy.minInboundLiquidityTarget } + val leaseRate = LiquidityAds.chooseLeaseRate(inboundLiquidityTarget, leaseRates) LiquidityAds.RequestRemoteFunding(inboundLiquidityTarget, currentTipFlow.filterNotNull().first().first, leaseRate) } val (localFundingAmount, fees) = run { @@ -1158,7 +1159,7 @@ class Peer( val localMiningFee = Transactions.weight2fee(currentFeerates.fundingFeerate, FundingContributions.computeWeightPaid(isInitiator = true, null, dummyFundingScript, cmd.walletInputs, emptyList())) // We directly pay the on-chain fees for our inputs/outputs of the transaction. val localFundingAmount = cmd.totalAmount - localMiningFee - val leaseFees = leaseRate.fees(currentFeerates.fundingFeerate, requestRemoteFunding.fundingAmount, requestRemoteFunding.fundingAmount) + val leaseFees = requestRemoteFunding.rate.fees(currentFeerates.fundingFeerate, requestRemoteFunding.fundingAmount, requestRemoteFunding.fundingAmount) // We also refund the liquidity provider for some of the on-chain fees they will pay for their inputs/outputs of the transaction. val totalFees = TransactionFees(miningFee = localMiningFee + leaseFees.miningFee, serviceFee = leaseFees.serviceFee) Pair(localFundingAmount, totalFees) @@ -1220,6 +1221,7 @@ class Peer( is LiquidityPolicy.Disable -> LiquidityPolicy.minInboundLiquidityTarget is LiquidityPolicy.Auto -> policy.inboundLiquidityTarget ?: LiquidityPolicy.minInboundLiquidityTarget } + val leaseRate = LiquidityAds.chooseLeaseRate(remoteFundingAmount, leaseRates) val requestRemoteFunding = LiquidityAds.RequestRemoteFunding(remoteFundingAmount, currentTipFlow.filterNotNull().first().first, leaseRate) when { channelFundingIsInProgress() -> { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index 92c374938..0c992f043 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -45,7 +45,7 @@ data class OnTheFlyFundingPart(val htlc: MaybeAddHtlc, override val finalPayload override fun toString(): String = "maybe-htlc(amount=${htlc.amount})" } -class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPaymentsDb, val leaseRate: LiquidityAds.LeaseRate) { +class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPaymentsDb, val leaseRates: List) { sealed class ProcessAddResult { abstract val actions: List @@ -255,6 +255,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment is LiquidityPolicy.Disable -> 0.msat is LiquidityPolicy.Auto -> { val requestedAmount = policy.inboundLiquidityTarget ?: LiquidityPolicy.minInboundLiquidityTarget + val leaseRate = LiquidityAds.chooseLeaseRate(requestedAmount, leaseRates) leaseRate.fees(currentFeerate, requestedAmount, requestedAmount).total.toMilliSatoshi() } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt index 64f75e819..e5a3e3cc8 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt @@ -82,6 +82,22 @@ object LiquidityAds { } } + /** + * We may want to use different lease rates based on the amount that is purchased. + * There is an ongoing discussion to directly include this information in the advertised lease rate: https://github.com/lightning/bolts/pull/1145#discussion_r1526005244 + */ + data class BoundedLeaseRate(val minAmount: Satoshi, val maxAmount: Satoshi, val leaseRate: LeaseRate) + + fun chooseLeaseRate(fundingAmount: Satoshi, rates: List): LeaseRate { + val sortedRates = rates.sortedBy { it.minAmount } + val matchingRate = sortedRates.firstOrNull { it.minAmount <= fundingAmount && fundingAmount <= it.maxAmount } + return when { + matchingRate != null -> matchingRate.leaseRate + fundingAmount <= sortedRates.first().minAmount -> sortedRates.first().leaseRate + else -> sortedRates.last().leaseRate + } + } + /** 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 diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt index f02f683dd..c1116faeb 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt @@ -706,7 +706,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { @Test fun `invoice expired`() = runSuspendTest { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRate) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRates) val (incomingPayment, paymentSecret) = makeIncomingPayment( payee = paymentHandler, amount = defaultAmount, @@ -1038,7 +1038,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { @Test fun `purge expired incoming payments`() = runSuspendTest { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRate) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRates) // create incoming payment that has expired and not been paid val expiredInvoice = paymentHandler.createInvoice( @@ -1150,7 +1150,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { } private suspend fun createFixture(invoiceAmount: MilliSatoshi?): Triple { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRate) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRates) // 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) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt index bbb0d7dd6..0bbac3cfe 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt @@ -454,7 +454,7 @@ class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { val outgoingPaymentHandler = OutgoingPaymentHandler(TestConstants.Alice.nodeParams, defaultWalletParams, InMemoryPaymentsDb()) // The invoice comes from Bob, our direct peer (and trampoline node). val preimage = randomBytes32() - val incomingPaymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRate) + val incomingPaymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRates) val invoice = incomingPaymentHandler.createInvoice(preimage, amount = null, Either.Left("phoenix to phoenix"), listOf()) val payment = SendPayment(UUID.randomUUID(), 300_000.msat, invoice.nodeId, invoice) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt b/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt index 78d77fbb9..8f16673c1 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt @@ -50,6 +50,8 @@ object TestConstants { maxRelayFeeBase = 1_000.msat, ) + val leaseRates = listOf(LiquidityAds.BoundedLeaseRate(0.sat, 100_000_000.sat, leaseRate)) + const val aliceSwapInServerXpub = "tpubDCvYeHUZisCMVTSfWDa1yevTf89NeF6TWxXUQwqkcmFrNvNdNvZQh1j4m4uTA4QcmPEwcrKVF8bJih1v16zDZacRr4j9MCAFQoSydKKy66q" const val bobSwapInServerXpub = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41" 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 0e4bade22..6d59989bd 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 @@ -188,7 +188,7 @@ suspend fun buildPeer( ): Peer { val electrum = ElectrumClient(scope, nodeParams.loggerFactory) val watcher = ElectrumWatcher(electrum, scope, nodeParams.loggerFactory) - val peer = Peer(nodeParams, walletParams, watcher, databases, TestConstants.leaseRate, TcpSocket.Builder(), scope) + val peer = Peer(nodeParams, walletParams, watcher, databases, TestConstants.leaseRates, TcpSocket.Builder(), scope) peer.currentTipFlow.value = currentTip peer.onChainFeeratesFlow.value = OnChainFeerates( fundingFeerate = FeeratePerKw(FeeratePerByte(5.sat)), From 3c20b3f2c03406099bf07b30726ff0c714c5fc5e Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 15 Mar 2024 17:02:48 +0100 Subject: [PATCH 5/6] Add on-the-fly funding node events Add more node events for on-the-fly funding operations. This is mostly useful for tests to wait until specific steps have been reached into the protocol execution. --- .../kotlin/fr/acinq/lightning/NodeEvents.kt | 15 ++++++++++++++- .../fr/acinq/lightning/channel/ChannelAction.kt | 6 +++--- .../fr/acinq/lightning/channel/states/Normal.kt | 10 +++++++--- .../channel/states/WaitForFundingSigned.kt | 10 +++++++--- .../kotlin/fr/acinq/lightning/io/Peer.kt | 2 ++ .../states/WaitForFundingSignedTestsCommon.kt | 12 ++++++++---- 6 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt index 164775c86..864c8cf01 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt @@ -1,9 +1,12 @@ package fr.acinq.lightning import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.OutPoint import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.blockchain.electrum.WalletState import fr.acinq.lightning.channel.InteractiveTxParams import fr.acinq.lightning.channel.SharedFundingInput +import fr.acinq.lightning.channel.TransactionFees import fr.acinq.lightning.channel.states.ChannelStateWithCommitments import fr.acinq.lightning.channel.states.Normal import fr.acinq.lightning.channel.states.WaitForFundingCreated @@ -12,6 +15,15 @@ import fr.acinq.lightning.utils.sum sealed interface NodeEvents +sealed interface SwapInEvents : NodeEvents { + data class Requested(val walletInputs: List) : SwapInEvents { + val totalAmount: Satoshi = walletInputs.map { it.amount }.sum() + } + data class Accepted(val inputs: Set, val amount: Satoshi, val fees: TransactionFees) : SwapInEvents { + val receivedAmount: Satoshi = amount - fees.serviceFee - fees.miningFee + } +} + sealed interface ChannelEvents : NodeEvents { data class Creating(val state: WaitForFundingCreated) : ChannelEvents data class Created(val state: ChannelStateWithCommitments) : ChannelEvents @@ -19,6 +31,7 @@ sealed interface ChannelEvents : NodeEvents { } sealed interface LiquidityEvents : NodeEvents { + /** Amount of the liquidity event, before fees are paid. */ val amount: MilliSatoshi val fee: MilliSatoshi val source: Source @@ -36,6 +49,7 @@ sealed interface LiquidityEvents : NodeEvents { data class ChannelFundingCancelled(val paymentHash: ByteVector32) : Reason() } } + 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. */ @@ -48,7 +62,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. */ diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt index 53bc1619b..ae7cb8a95 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt @@ -1,9 +1,9 @@ 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 @@ -134,8 +134,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/states/Normal.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt index 07f69911a..fe679b380 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 @@ -912,6 +910,12 @@ data class Normal( val miningFees = action.fundingTx.sharedTx.tx.localFees.truncateToSatoshi() + liquidityLease.fees.miningFee add(ChannelAction.Storage.StoreOutgoingPayment.ViaInboundLiquidityRequest(txId = action.fundingTx.txId, miningFees = miningFees, lease = liquidityLease)) } + addAll(origins.map { origin -> + when (origin) { + is Origin.OffChainPayment -> ChannelAction.EmitEvent(LiquidityEvents.Accepted(origin.amount, origin.fees.total.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment)) + is Origin.OnChainWallet -> ChannelAction.EmitEvent(SwapInEvents.Accepted(origin.inputs, origin.amount.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/WaitForFundingSigned.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSigned.kt index c4eebe328..64cff8bda 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSigned.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSigned.kt @@ -4,9 +4,7 @@ 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.* @@ -131,6 +129,12 @@ data class WaitForFundingSigned( origin = channelOrigin ) ) + channelOrigin?.let { + when (it) { + is Origin.OffChainPayment -> add(ChannelAction.EmitEvent(LiquidityEvents.Accepted(it.amount, it.fees.total.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment))) + is Origin.OnChainWallet -> add(ChannelAction.EmitEvent(SwapInEvents.Accepted(it.inputs, it.amount.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/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index 8873daf91..5dc47b094 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -1138,6 +1138,7 @@ class Peer( } } input.send(WrappedChannelCommand(channel.channelId, spliceCommand)) + nodeParams._nodeEvents.emit(SwapInEvents.Requested(cmd.walletInputs)) } } } @@ -1197,6 +1198,7 @@ class Peer( 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)) } } } 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 4284d6ec3..1bcb4754d 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSignedTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSignedTestsCommon.kt @@ -139,7 +139,7 @@ class WaitForFundingSignedTestsCommon : LightningTestSuite() { 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, 5) + assertEquals(actionsAlice1.size, 6) actionsAlice1.hasOutgoingMessage() actionsAlice1.has() actionsAlice1.find().also { @@ -148,7 +148,9 @@ class WaitForFundingSignedTestsCommon : LightningTestSuite() { assertEquals(alice1.commitments.latest.fundingTxId, it.txId) } actionsAlice1.hasWatch() - actionsAlice1.has() + val events = actionsAlice1.filterIsInstance().map { it.event } + assertTrue(events.any { it is ChannelEvents.Created }) + assertTrue(events.any { it is LiquidityEvents.Accepted }) } @Test @@ -157,7 +159,7 @@ class WaitForFundingSignedTestsCommon : LightningTestSuite() { 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, 5) + assertEquals(actionsAlice1.size, 6) actionsAlice1.hasOutgoingMessage() actionsAlice1.has() actionsAlice1.find().also { @@ -166,7 +168,9 @@ class WaitForFundingSignedTestsCommon : LightningTestSuite() { assertTrue(it.localInputs.isNotEmpty()) } actionsAlice1.hasWatch() - actionsAlice1.has() + val events = actionsAlice1.filterIsInstance().map { it.event } + assertTrue(events.any { it is ChannelEvents.Created }) + assertTrue(events.any { it is SwapInEvents.Accepted }) } @Test From e54d9f8c603568485f092b8b20b58211ae9b13d7 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 27 Mar 2024 15:35:25 +0100 Subject: [PATCH 6/6] Add on-the-fly fee credit feature We add an optional feature that lets on-the-fly funding clients accept payments that are too small to pay the fees for an on-the-fly funding. When that happens, the payment amount is added as "fee credit" without performing an on-chain operation. Once enough fee credit has been obtained, we can initiate an on-chain operation to create a channel or a splice. This feature makes more efficient use of on-chain transactions by trusting that our peer will honor our fee credit in the future. --- .../kotlin/fr/acinq/lightning/Features.kt | 9 +++ .../fr/acinq/lightning/db/PaymentsDb.kt | 11 ++++ .../kotlin/fr/acinq/lightning/io/Peer.kt | 46 +++++++++++++- .../payment/IncomingPaymentHandler.kt | 31 +++++----- .../kotlin/fr/acinq/lightning/wire/InitTlv.kt | 22 +++++++ .../acinq/lightning/wire/LightningMessages.kt | 60 +++++++++++++++++++ .../IncomingPaymentHandlerTestsCommon.kt | 37 +++++++----- .../wire/LightningCodecsTestsCommon.kt | 22 +++++++ 8 files changed, 205 insertions(+), 33 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/Features.kt b/src/commonMain/kotlin/fr/acinq/lightning/Features.kt index a864b0ff0..f3c81e8e4 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/Features.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/Features.kt @@ -272,6 +272,14 @@ sealed class Feature { override val scopes: Set get() = setOf(FeatureScope.Init, FeatureScope.Node) } + /** This feature bit should be activated when a node accepts exchanging payment preimages for a fee credit using the [AddFeeCredit] message. */ + @Serializable + object OnTheFlyFundingFeeCredit : Feature() { + override val rfcName: String = "on_the_fly_fee_credit" + override val mandatory get() = 160 + override val scopes: Set get() = setOf(FeatureScope.Init) + } + } @Serializable @@ -355,6 +363,7 @@ data class Features(val activated: Map, val unknown: Se Feature.ExperimentalSplice, Feature.OnTheFlyFundingClient, Feature.OnTheFlyFundingProvider, + Feature.OnTheFlyFundingFeeCredit, ) operator fun invoke(bytes: ByteVector): Features = invoke(bytes.toByteArray()) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt index c07730b52..7778d4d34 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt @@ -177,6 +177,7 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r when (it) { is ReceivedWith.LightningPayment -> it.amount is ReceivedWith.OnChainIncomingPayment.Received -> it.amount + is ReceivedWith.OnChainIncomingPayment.AddedToFeeCredit -> it.amount is ReceivedWith.OnChainIncomingPayment.Cancelled -> 0.msat is ReceivedWith.OnChainIncomingPayment.Pending -> 0.msat } @@ -187,6 +188,7 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r when (it) { is ReceivedWith.LightningPayment -> it.fees is ReceivedWith.OnChainIncomingPayment.Received -> it.fees + is ReceivedWith.OnChainIncomingPayment.AddedToFeeCredit -> 0.msat is ReceivedWith.OnChainIncomingPayment.Cancelled -> 0.msat is ReceivedWith.OnChainIncomingPayment.Pending -> 0.msat } @@ -213,6 +215,15 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r override val fees: MilliSatoshi = 0.msat } + /** + * Payment was added to our fee credit for future on-chain operations (see [Feature.OnTheFlyFundingFeeCredit]). + * We didn't really receive this amount yet, but we trust our peer to include it in a future on-chain operation. + */ + data class AddedToFeeCredit(override val amount: MilliSatoshi) : OnChainIncomingPayment() { + // Adding to the fee credit doesn't cost any fees. + override val fees: MilliSatoshi = 0.msat + } + /** * An on-chain transaction was initiated for this payment but couldn't complete, potentially resulting in a partial payment. * This usually indicates a cheating attempt or data loss on the service provider end: users should contact support. diff --git a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index 5dc47b094..f4f78428f 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -180,6 +180,7 @@ class Peer( val currentTipFlow = MutableStateFlow?>(null) val onChainFeeratesFlow = MutableStateFlow(null) val peerFeeratesFlow = MutableStateFlow(null) + val feeCreditFlow = MutableStateFlow(0.msat) private val _channelLogger = nodeParams.loggerFactory.newLogger(ChannelState::class) private suspend fun ChannelState.process(cmd: ChannelCommand): Pair> { @@ -673,9 +674,13 @@ class Peer( peerConnection?.send(message) } - /** Return true if we are currently funding a channel. */ + /** + * Return true if we are currently funding a channel. + * Note that we also return true if we haven't yet received the remote [TxSignatures] for the latest splice transaction. + * Since our peer sends [CurrentFeeCredit] before [TxSignatures], this ensures that we never over-estimate our fee credit when initiating a funding flow. + */ private fun channelFundingIsInProgress(): Boolean = when (val channel = _channels.values.firstOrNull { it is Normal }) { - is Normal -> channel.spliceStatus != SpliceStatus.None + is Normal -> channel.spliceStatus != SpliceStatus.None || channel.commitments.latest.localFundingStatus.signedTx == null else -> _channels.values.any { it is WaitForAcceptChannel || it is WaitForFundingCreated || it is WaitForFundingSigned || it is WaitForFundingConfirmed || it is WaitForChannelReady } } @@ -930,6 +935,22 @@ class Peer( } } } + is CurrentFeeCredit -> { + feeCreditFlow.value = msg.amount + if (msg.latestPayments.isNotEmpty()) { + // We may have sent add_fee_credit and disconnected before receiving their current_fee_credit. + // If they have received it, they will include the corresponding preimage in current_fee_credit, + // which completes the payment. + db.payments.listPendingOnTheFlyPayments().forEach { (payment, pending) -> + if (msg.latestPayments.contains(payment.preimage)) { + logger.info { "${pending.amount} successfully added to fee credit for payment_hash=${payment.paymentHash}" } + val receivedWith = IncomingPayment.ReceivedWith.OnChainIncomingPayment.AddedToFeeCredit(pending.amount) + db.payments.receivePayment(payment.paymentHash, listOf(receivedWith)) + nodeParams._nodeEvents.emit(PaymentEvents.PaymentReceived(payment.paymentHash, listOf(receivedWith))) + } + } + } + } is Ping -> { val pong = Pong(ByteVector(ByteArray(msg.pongLength))) peerConnection?.send(pong) @@ -1026,6 +1047,14 @@ class Peer( is HasChannelId -> { if (msg is Error && msg.channelId == ByteVector32.Zeroes) { logger.error { "connection error: ${msg.toAscii()}" } + } else if (msg is CancelOnTheFlyFunding && msg.channelId == ByteVector32.Zeroes) { + db.payments.listPendingOnTheFlyPayments().forEach { (payment, pending) -> + if (msg.paymentHash == payment.paymentHash) { + logger.warning { "on-the-fly fee credit for payment_hash=${payment.paymentHash} rejected by our peer, payment may be partially received" } + val receivedWith = IncomingPayment.ReceivedWith.OnChainIncomingPayment.Cancelled(pending.amount) + db.payments.receivePayment(payment.paymentHash, listOf(receivedWith)) + } + } } else { _channels[msg.channelId]?.let { state -> val event1 = ChannelCommand.MessageReceived(msg) @@ -1213,6 +1242,7 @@ class Peer( is OpenOrSplicePayment -> { val channel = channels.values.firstOrNull { it is Normal } val currentFeerates = peerFeeratesFlow.filterNotNull().first() + val currentFeeCredit = feeCreditFlow.first() // We need our peer to contribute, because they must have enough funds to pay the commitment fees. // They will fund more than what we request to also cover the maybe_add_htlc parts that they will push to us. // We only pay fees for the additional liquidity we request, not for the maybe_add_htlc amounts. @@ -1225,11 +1255,23 @@ class Peer( } val leaseRate = LiquidityAds.chooseLeaseRate(remoteFundingAmount, leaseRates) val requestRemoteFunding = LiquidityAds.RequestRemoteFunding(remoteFundingAmount, currentTipFlow.filterNotNull().first().first, leaseRate) + val useFeeCredit = run { + val featureActivated = nodeParams.features.hasFeature(Feature.OnTheFlyFundingFeeCredit) + // We use an arbitrary threshold that is higher than just the current liquidity fees. + // This reduces the frequency of on-chain operations for payments that are about the size of the fees. + val minAmount = leaseRate.fees(currentFeerates.fundingFeerate, remoteFundingAmount, remoteFundingAmount).total * 5 + val paymentAmountBelowMin = (cmd.paymentAmount + currentFeeCredit) < minAmount + featureActivated && paymentAmountBelowMin + } when { channelFundingIsInProgress() -> { logger.warning { "delaying on-the-fly funding, funding is already in progress" } peerConnection?.delayedCommands?.send(cmd) } + useFeeCredit -> { + logger.info { "requesting ${cmd.paymentAmount} of additional fee credit for paymentHash=${cmd.paymentHash} (currentFeeCredit=$currentFeeCredit, feerate=${currentFeerates.fundingFeerate})" } + peerConnection?.send(AddFeeCredit(nodeParams.chainHash, cmd.preimage)) + } channel is Normal -> { // We don't contribute any input or output, but we must pay on-chain fees for the shared input and output. // We pay those on-chain fees using our current channel balance. diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index 0c992f043..72665255d 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -7,9 +7,6 @@ import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 -import fr.acinq.lightning.LiquidityEvents -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.NodeParams import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.ChannelAction import fr.acinq.lightning.channel.ChannelCommand @@ -249,23 +246,25 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment val onTheFlyFundingParts = payment.parts.filterIsInstance() val onTheFlyAmount = onTheFlyFundingParts.map { it.amount }.sum() val rejected = when { - onTheFlyFundingParts.isNotEmpty() -> { - val policy = nodeParams.liquidityPolicy.value - val fees = when (policy) { - is LiquidityPolicy.Disable -> 0.msat - is LiquidityPolicy.Auto -> { + onTheFlyFundingParts.isNotEmpty() -> when (val policy = nodeParams.liquidityPolicy.value) { + is LiquidityPolicy.Disable -> LiquidityEvents.Rejected(payment.amountReceived, 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.PolicySetToDisabled) + is LiquidityPolicy.Auto -> { + val fees = run { val requestedAmount = policy.inboundLiquidityTarget ?: LiquidityPolicy.minInboundLiquidityTarget val leaseRate = LiquidityAds.chooseLeaseRate(requestedAmount, leaseRates) leaseRate.fees(currentFeerate, requestedAmount, requestedAmount).total.toMilliSatoshi() } - } - when { - // We shouldn't initiate an on-the-fly funding if the remaining amount is too low to pay the fees. - onTheFlyAmount < fees * 2 -> LiquidityEvents.Rejected(payment.amountReceived, fees, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow(onTheFlyAmount)) - // We consider the total amount received (not only the on-the-fly funding parts) to evaluate our relative fee policy. - // A side effect is that if a large payment is only missing a small amount to be complete, we may still create a funding transaction for it. - // This makes sense, as the user likely wants to receive this large payment, and will obtain inbound liquidity for future payments. - else -> policy.maybeReject(payment.amountReceived, fees, LiquidityEvents.Source.OffChainPayment, logger) + when { + // We never reject if we're using the fee credit feature. + // We instead add payments to our fee credit until making an on-chain operation becomes acceptable. + nodeParams.features.hasFeature(Feature.OnTheFlyFundingFeeCredit) -> null + // We shouldn't initiate an on-the-fly funding if the remaining amount is too low to pay the fees. + onTheFlyAmount < fees * 2 -> LiquidityEvents.Rejected(payment.amountReceived, fees, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow(onTheFlyAmount)) + // We consider the total amount received (not only the on-the-fly funding parts) to evaluate our relative fee policy. + // A side effect is that if a large payment is only missing a small amount to be complete, we may still create a funding transaction for it. + // This makes sense, as the user likely wants to receive this large payment, and will obtain inbound liquidity for future payments. + else -> policy.maybeReject(payment.amountReceived, fees, LiquidityEvents.Source.OffChainPayment, logger) + } } } else -> null diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt index a4ae87672..3584515f8 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/InitTlv.kt @@ -3,6 +3,7 @@ package fr.acinq.lightning.wire import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.ByteVector64 import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.byteVector32 import fr.acinq.bitcoin.io.Input import fr.acinq.bitcoin.io.Output @@ -69,3 +70,24 @@ sealed class InitTlv : Tlv { } } } + +sealed class CurrentFeeCreditTlv : Tlv { + /** Latest payments that were used as fee credit. */ + data class LatestPayments(val preimages: List) : CurrentFeeCreditTlv() { + override val tag: Long get() = LatestPayments.tag + + override fun write(out: Output) { + preimages.forEach { LightningCodecs.writeBytes(it, out) } + } + + companion object : TlvValueReader { + const val tag: Long = 1 + + override fun read(input: Input): LatestPayments { + val count = input.availableBytes / 32 + val preimages = (0 until count).map { LightningCodecs.bytes(input, 32).byteVector32() } + return LatestPayments(preimages) + } + } + } +} diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index a8da114ed..85d157305 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -79,6 +79,8 @@ interface LightningMessage { OnionMessage.type -> OnionMessage.read(stream) MaybeAddHtlc.type -> MaybeAddHtlc.read(stream) CancelOnTheFlyFunding.type -> CancelOnTheFlyFunding.read(stream) + CurrentFeeCredit.type -> CurrentFeeCredit.read(stream) + AddFeeCredit.type -> AddFeeCredit.read(stream) FCMToken.type -> FCMToken.read(stream) UnsetFCMToken.type -> UnsetFCMToken PhoenixAndroidLegacyInfo.type -> PhoenixAndroidLegacyInfo.read(stream) @@ -1672,6 +1674,64 @@ data class CancelOnTheFlyFunding( } } +/** + * This message contains our current fee credit: our peer is the source of truth for that value. + * They will automatically use the fee credit whenever we perform an on-chain operation. + */ +data class CurrentFeeCredit( + override val chainHash: BlockHash, + val amount: MilliSatoshi, + val tlvStream: TlvStream = TlvStream.empty() +) : LightningMessage, HasChainHash { + override val type: Long get() = CurrentFeeCredit.type + + val latestPayments: Set = tlvStream.get()?.preimages?.toSet() ?: setOf() + + override fun write(out: Output) { + LightningCodecs.writeBytes(chainHash.value, out) + LightningCodecs.writeU64(amount.toLong(), out) + TlvStreamSerializer(false, readers).write(tlvStream, out) + } + + companion object : LightningMessageReader { + const val type: Long = 35031 + + @Suppress("UNCHECKED_CAST") + val readers = mapOf( + CurrentFeeCreditTlv.LatestPayments.tag to CurrentFeeCreditTlv.LatestPayments.Companion as TlvValueReader, + ) + + override fun read(input: Input): CurrentFeeCredit = CurrentFeeCredit( + chainHash = BlockHash(LightningCodecs.bytes(input, 32)), + amount = LightningCodecs.u64(input).msat, + tlvStream = TlvStreamSerializer(false, readers).read(input), + ) + } +} + +/** + * This message is used to reveal the preimage of a small payment for which it isn't economical to perform an on-chain + * transaction. The amount of the payment will be added to our fee credit, which can be used when a future on-chain + * transaction is needed. This message requires the [Feature.OnTheFlyFundingFeeCredit] feature. + */ +data class AddFeeCredit(override val chainHash: BlockHash, val preimage: ByteVector32) : LightningMessage, HasChainHash { + override val type: Long get() = AddFeeCredit.type + + override fun write(out: Output) { + LightningCodecs.writeBytes(chainHash.value, out) + LightningCodecs.writeBytes(preimage, out) + } + + companion object : LightningMessageReader { + const val type: Long = 35033 + + override fun read(input: Input): AddFeeCredit = AddFeeCredit( + chainHash = BlockHash(LightningCodecs.bytes(input, 32)), + preimage = LightningCodecs.bytes(input, 32).byteVector32(), + ) + } +} + data class FCMToken(val token: ByteVector) : LightningMessage { constructor(token: String) : this(ByteVector(token.encodeToByteArray())) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt index c1116faeb..97cc040c4 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt @@ -2,11 +2,9 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either -import fr.acinq.lightning.CltvExpiryDelta +import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes import fr.acinq.lightning.Lightning.randomBytes32 -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.ShortChannelId import fr.acinq.lightning.channel.* import fr.acinq.lightning.crypto.sphinx.Sphinx import fr.acinq.lightning.db.InMemoryPaymentsDb @@ -273,26 +271,31 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { @Test fun `receive maybe_add_htlc with fee too high`() = runSuspendTest { + data class TestCase(val policy: LiquidityPolicy, val paymentAmount: MilliSatoshi, val allowFeeCredit: Boolean, val success: Boolean) + val inboundLiquidityTarget = 100_000.sat val expectedFee = 3500.sat assertEquals(expectedFee, TestConstants.leaseRate.fees(TestConstants.feeratePerKw, inboundLiquidityTarget, inboundLiquidityTarget).total) val defaultPolicy = LiquidityPolicy.Auto(inboundLiquidityTarget, maxAbsoluteFee = 3500.sat, maxRelativeFeeBasisPoints = 10_000, skipAbsoluteFeeCheck = false) val testCases = listOf( // If payment amount is at least twice the fees, we accept the payment. - Triple(defaultPolicy, 7_000_000.msat, true), + TestCase(defaultPolicy, 7_000_000.msat, allowFeeCredit = false, success = true), // If fee is above our liquidity policy maximum fee, we reject the payment. - Triple(defaultPolicy.copy(maxAbsoluteFee = 3499.sat), 7_000_000.msat, false), - // If we disabled automatic liquidity management, we reject the payment. - Triple(LiquidityPolicy.Disable, 7_000_000.msat, false), + TestCase(defaultPolicy.copy(maxAbsoluteFee = 3499.sat), 7_000_000.msat, allowFeeCredit = false, success = false), + // If fee is above our liquidity policy maximum fee, but we can use fee credit, we accept the payment. + TestCase(defaultPolicy.copy(maxAbsoluteFee = 3499.sat), 7_000_000.msat, allowFeeCredit = true, success = true), + // If we disabled automatic liquidity management, we reject the payment, even with fee credit. + TestCase(LiquidityPolicy.Disable, 7_000_000.msat, allowFeeCredit = false, success = false), + TestCase(LiquidityPolicy.Disable, 7_000_000.msat, allowFeeCredit = true, success = false), // If payment is too close to the fee, we reject the payment. - Triple(defaultPolicy, 6_999_999.msat, false), + TestCase(defaultPolicy, 6_999_999.msat, allowFeeCredit = false, success = false), ) - testCases.forEach { (policy, paymentAmount, success) -> - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(paymentAmount) - paymentHandler.nodeParams.liquidityPolicy.emit(policy) - val add = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(paymentAmount, paymentAmount, paymentSecret)) + testCases.forEach { + val (paymentHandler, incomingPayment, paymentSecret) = createFixture(it.paymentAmount, it.allowFeeCredit) + paymentHandler.nodeParams.liquidityPolicy.emit(it.policy) + val add = makeMaybeAddHtlc(paymentHandler, incomingPayment.paymentHash, makeMppPayload(it.paymentAmount, it.paymentAmount, paymentSecret)) val result = paymentHandler.process(add, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw) - if (success) { + if (it.success) { assertIs(result) } else { assertIs(result) @@ -1149,8 +1152,12 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { assertEquals(incomingPayment.received?.receivedWith, dbPayment.received?.receivedWith) } - private suspend fun createFixture(invoiceAmount: MilliSatoshi?): Triple { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRates) + private suspend fun createFixture(invoiceAmount: MilliSatoshi?, allowFeeCredit: Boolean = false): Triple { + val nodeParams = when { + allowFeeCredit -> TestConstants.Bob.nodeParams.copy(features = TestConstants.Bob.nodeParams.features.add(Feature.OnTheFlyFundingFeeCredit to FeatureSupport.Optional)) + else -> TestConstants.Bob.nodeParams + } + val paymentHandler = IncomingPaymentHandler(nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRates) // 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) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt index e4bdf1735..59ab50fbc 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -812,6 +812,28 @@ class LightningCodecsTestsCommon : LightningTestSuite() { } } + @Test + fun `encode - decode fee credit messages`() { + val preimage1 = ByteVector32("d4d93abf0ed2d0d4c082a0570883690e61640f04eb8357c6a8e0255a7e69bc80") + val preimage2 = ByteVector32("831e7e290b94d56a078df896958f0c67332258eab5c7c2b66661d4088c1f124d") + val testCases = listOf( + // @formatter:off + Pair(AddFeeCredit(Block.TestnetGenesisBlock.hash, preimage1), Hex.decode("88d9 43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000 d4d93abf0ed2d0d4c082a0570883690e61640f04eb8357c6a8e0255a7e69bc80")), + Pair(CurrentFeeCredit(Block.TestnetGenesisBlock.hash, 0.msat), Hex.decode("88d7 43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000 0000000000000000")), + Pair(CurrentFeeCredit(Block.TestnetGenesisBlock.hash, 471.msat), Hex.decode("88d7 43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000 00000000000001d7")), + Pair(CurrentFeeCredit(Block.TestnetGenesisBlock.hash, 1105.msat, TlvStream(CurrentFeeCreditTlv.LatestPayments(listOf(preimage1)))), Hex.decode("88d7 43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000 0000000000000451 0120d4d93abf0ed2d0d4c082a0570883690e61640f04eb8357c6a8e0255a7e69bc80")), + Pair(CurrentFeeCredit(Block.TestnetGenesisBlock.hash, 1729.msat, TlvStream(CurrentFeeCreditTlv.LatestPayments(listOf(preimage1, preimage2)))), Hex.decode("88d7 43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000 00000000000006c1 0140d4d93abf0ed2d0d4c082a0570883690e61640f04eb8357c6a8e0255a7e69bc80831e7e290b94d56a078df896958f0c67332258eab5c7c2b66661d4088c1f124d")), + // @formatter:on + ) + testCases.forEach { + val decoded = LightningMessage.decode(it.second) + assertNotNull(decoded) + assertEquals(it.first, decoded) + val encoded = LightningMessage.encode(decoded) + assertArrayEquals(it.second, encoded) + } + } + @Test fun `encode - decode phoenix-android-legacy-info messages`() { val testCases = listOf(