diff --git a/src/commonMain/kotlin/fr/acinq/lightning/Features.kt b/src/commonMain/kotlin/fr/acinq/lightning/Features.kt index a819b9ecb..f3c81e8e4 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,13 +256,30 @@ 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) } + /** 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 @@ -321,6 +345,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 +361,9 @@ data class Features(val activated: Map, val unknown: Se Feature.ChannelBackupClient, Feature.ChannelBackupProvider, Feature.ExperimentalSplice, - Feature.Quiescence + Feature.OnTheFlyFundingClient, + Feature.OnTheFlyFundingProvider, + Feature.OnTheFlyFundingFeeCredit, ) 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 76991cda7..864c8cf01 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt @@ -1,23 +1,27 @@ 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 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 + 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 { @@ -27,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 @@ -39,11 +44,12 @@ 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 + 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. */ @@ -56,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/NodeParams.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index f82ce2fab..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, @@ -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..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 @@ -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() - 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() + /** @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() { @@ -128,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/ChannelCommand.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt index 0a3f7643c..c34238da8 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 } @@ -47,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() @@ -83,9 +85,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 +95,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..087140139 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,33 @@ 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 { + /** Amount of the origin payment, before fees are paid. */ 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() + /** Fees applied for the channel funding transaction. */ + abstract val fees: TransactionFees + + 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/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..f94c8238e 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt @@ -5,22 +5,17 @@ import fr.acinq.bitcoin.Script.tail import fr.acinq.bitcoin.crypto.musig2.IndividualNonce import fr.acinq.bitcoin.crypto.musig2.Musig2 import fr.acinq.bitcoin.crypto.musig2.SecretNonce -import fr.acinq.bitcoin.utils.getOrDefault -import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.bitcoin.utils.Either import fr.acinq.bitcoin.utils.Try +import fr.acinq.bitcoin.utils.getOrDefault import fr.acinq.bitcoin.utils.runTrying +import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.blockchain.electrum.WalletState import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.crypto.Bolt3Derivation.deriveForCommitment import fr.acinq.lightning.crypto.KeyManager -import fr.acinq.lightning.logging.* -import fr.acinq.lightning.transactions.CommitmentSpec -import fr.acinq.lightning.transactions.DirectedHtlc -import fr.acinq.lightning.transactions.Scripts -import fr.acinq.lightning.transactions.SwapInProtocol -import fr.acinq.lightning.transactions.Transactions +import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.transactions.* import fr.acinq.lightning.utils.* import fr.acinq.lightning.wire.* @@ -227,7 +222,6 @@ sealed class FundingContributionFailure { data class InputBelowDust(val txId: TxId, val outputIndex: Int, val amount: Satoshi, val dustLimit: Satoshi) : FundingContributionFailure() { override fun toString(): String = "invalid input $txId:$outputIndex (below dust: amount=$amount, dust=$dustLimit)" } data class InputTxTooLarge(val tx: Transaction) : FundingContributionFailure() { override fun toString(): String = "invalid input tx ${tx.txid} (too large)" } data class NotEnoughFunding(val fundingAmount: Satoshi, val nonFundingAmount: Satoshi, val providedAmount: Satoshi) : FundingContributionFailure() { override fun toString(): String = "not enough funds provided (expected at least $fundingAmount + $nonFundingAmount, got $providedAmount)" } - data class NotEnoughFees(val currentFees: Satoshi, val expectedFees: Satoshi) : FundingContributionFailure() { override fun toString(): String = "not enough funds to pay fees (expected at least $expectedFees, got $currentFees)" } data class InvalidFundingBalances(val fundingAmount: Satoshi, val localBalance: MilliSatoshi, val remoteBalance: MilliSatoshi) : FundingContributionFailure() { override fun toString(): String = "invalid balances funding_amount=$fundingAmount local=$localBalance remote=$remoteBalance" } // @formatter:on } @@ -236,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 + } } /** @@ -276,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())) @@ -339,7 +330,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) { @@ -941,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())) } } @@ -1075,7 +1068,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, @@ -1168,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. */ @@ -1178,10 +1171,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..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 @@ -242,7 +240,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 +311,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,22 +376,26 @@ 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 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 { - 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)) { - 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 +410,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 +425,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))) @@ -493,7 +497,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 +570,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 +582,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))) @@ -770,6 +775,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()) @@ -842,7 +864,7 @@ data class Normal( } private fun ChannelContext.sendSpliceTxSigs( - origins: List, + origins: List, action: InteractiveTxSigningSessionAction.SendTxSigs, liquidityLease: LiquidityAds.Lease?, remoteChannelData: EncryptedChannelData @@ -858,28 +880,19 @@ 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, - serviceFee = origin.serviceFee, - miningFee = origin.miningFee, + 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(), txId = action.fundingTx.txId, 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, @@ -888,16 +901,21 @@ 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)) } + 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/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..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,9 +6,7 @@ import fr.acinq.lightning.ChannelEvents import fr.acinq.lightning.channel.* import fr.acinq.lightning.channel.Helpers.Funding.computeChannelId import fr.acinq.lightning.utils.msat -import fr.acinq.lightning.wire.AcceptDualFundedChannel -import fr.acinq.lightning.wire.Error -import fr.acinq.lightning.wire.OpenDualFundedChannel +import fr.acinq.lightning.wire.* /* * We initiated a channel open and are waiting for our peer to accept it. @@ -21,7 +19,8 @@ import fr.acinq.lightning.wire.OpenDualFundedChannel */ data class WaitForAcceptChannel( val init: ChannelCommand.Init.Initiator, - val lastSent: OpenDualFundedChannel + val lastSent: OpenDualFundedChannel, + val channelOrigin: Origin?, ) : ChannelState() { val temporaryChannelId: ByteVector32 get() = lastSent.temporaryChannelId @@ -52,41 +51,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, - null - ) - val actions = listOf( - ChannelAction.ChannelId.IdAssigned(staticParams.remoteNodeId, temporaryChannelId, channelId), - ChannelAction.Message.Send(interactiveTxAction.msg), - ChannelAction.EmitEvent(ChannelEvents.Creating(nextState)) - ) - Pair(nextState, actions) - } - else -> { - logger.error { "could not start interactive-tx session: $interactiveTxAction" } + 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)))) + } + } + } } } } @@ -97,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/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..2a4a2614a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreated.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreated.kt @@ -38,9 +38,10 @@ data class WaitForFundingCreated( val commitTxFeerate: FeeratePerKw, val remoteFirstPerCommitmentPoint: PublicKey, val remoteSecondPerCommitmentPoint: PublicKey, - val channelFlags: Byte, + val channelFlags: ChannelFlags, val channelConfig: ChannelConfig, val channelFeatures: ChannelFeatures, + val 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/WaitForFundingSigned.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSigned.kt index 2ed3c3ed7..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,14 +4,13 @@ import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.crypto.Pack import fr.acinq.bitcoin.utils.Either -import fr.acinq.lightning.ChannelEvents -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.ShortChannelId +import fr.acinq.lightning.* import fr.acinq.lightning.blockchain.BITCOIN_FUNDING_DEPTHOK import fr.acinq.lightning.blockchain.WatchConfirmed import fr.acinq.lightning.channel.* import fr.acinq.lightning.crypto.ShaChain import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.toMilliSatoshi import fr.acinq.lightning.wire.* import kotlin.math.absoluteValue @@ -123,13 +122,19 @@ 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 ) ) + 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/channel/states/WaitForInit.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForInit.kt index d45f4328a..76d1708e3 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForInit.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForInit.kt @@ -21,7 +21,8 @@ data object WaitForInit : ChannelState() { cmd.walletInputs, cmd.localParams, cmd.channelConfig, - cmd.remoteInit + cmd.remoteInit, + cmd.leaseRate, ) Pair(nextState, listOf()) } @@ -50,12 +51,16 @@ 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)) + when (cmd.channelOrigin) { + is Origin.OffChainPayment -> add(ChannelTlv.OnTheFlyFundingPreimage(cmd.channelOrigin.paymentPreimage)) + else -> {} + } } ) ) - 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..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,7 +113,8 @@ data class WaitForOpenChannel( open.channelFlags, channelConfig, channelFeatures, - open.origin + willFundLease?.lease, + channelOrigin = null, ) val actions = listOf( ChannelAction.ChannelId.IdAssigned(staticParams.remoteNodeId, temporaryChannelId, channelId), diff --git a/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt b/src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt index 2777263c8..7778d4d34 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,103 @@ 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.AddedToFeeCredit -> 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.AddedToFeeCredit -> 0.msat + 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() + /** + * 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. + */ + 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 afb150d13..f4f78428f 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -12,7 +12,9 @@ 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 @@ -32,11 +34,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,11 +41,24 @@ data class OpenChannel( val walletInputs: List, val commitTxFeerate: FeeratePerKw, val fundingTxFeerate: FeeratePerKw, - val channelFlags: Byte, val channelType: ChannelType.SupportedChannelType ) : PeerCommand() -data class PeerConnection(val id: Long, val output: Channel, val logger: MDCLogger) { +/** 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() +} + +/** + * 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 } @@ -101,8 +110,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 leaseRates Rates 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 +120,9 @@ class Peer( val walletParams: WalletParams, val watcher: ElectrumWatcher, val db: Databases, + val leaseRates: List, socketBuilder: TcpSocket.Builder?, scope: CoroutineScope, - private val trustedSwapInTxs: Set = emptySet(), private val initTlvStream: TlvStream = TlvStream.empty() ) : CoroutineScope by scope { companion object { @@ -151,9 +160,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 @@ -161,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, leaseRates) // encapsulates logic for sending payments private val outgoingPaymentHandler = OutgoingPaymentHandler(nodeParams, walletParams, db.payments) @@ -173,7 +179,8 @@ class Peer( val currentTipFlow = MutableStateFlow?>(null) val onChainFeeratesFlow = MutableStateFlow(null) - val swapInFeeratesFlow = 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> { @@ -319,7 +326,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) @@ -385,13 +392,30 @@ 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) { 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()}" } } @@ -423,6 +447,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. @@ -451,17 +476,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 +519,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 +538,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 +553,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 +563,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 +582,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 +601,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 +620,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() @@ -654,6 +674,16 @@ class Peer( peerConnection?.send(message) } + /** + * 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 || 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 } + } + 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 @@ -678,7 +708,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)) { @@ -692,7 +721,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)) @@ -700,26 +728,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( @@ -775,24 +798,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() @@ -802,11 +820,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 -> { @@ -881,7 +900,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) { @@ -912,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) @@ -930,50 +969,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!!, leaseRate = null)) + val (state2, actions2) = state1.process(ChannelCommand.MessageReceived(msg)) + _channels = _channels + (msg.temporaryChannelId to state2) + processActions(msg.temporaryChannelId, peerConnection, actions1 + actions2) } } is ChannelReestablish -> { @@ -1045,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) @@ -1065,24 +1075,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 -> { @@ -1106,64 +1114,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,18 +1126,221 @@ class Peer( cmd.fundingTxFeerate, localParams, theirInit!!, - cmd.channelFlags, + ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = false), ChannelConfig.standard, - cmd.channelType + cmd.channelType, + requestRemoteFunding = null, + channelOrigin = null, ) ) val msg = actions1.filterIsInstance().map { it.message }.filterIsInstance().first() _channels = _channels + (msg.temporaryChannelId to state1) processActions(msg.temporaryChannelId, peerConnection, actions1) } - is PayToOpenResponseCommand -> { - logger.info { "sending ${cmd.payToOpenResponse::class.simpleName}" } - peerConnection?.send(cmd.payToOpenResponse) + is 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)) + nodeParams._nodeEvents.emit(SwapInEvents.Requested(cmd.walletInputs)) + } + } + } + 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 { + // We need our peer to contribute, because they must have enough funds to pay the commitment fees. + 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 + } + val leaseRate = LiquidityAds.chooseLeaseRate(inboundLiquidityTarget, leaseRates) + LiquidityAds.RequestRemoteFunding(inboundLiquidityTarget, currentTipFlow.filterNotNull().first().first, leaseRate) + } + val (localFundingAmount, fees) = run { + 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 + 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) + } + 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.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) + swapInCommands.trySend(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet())) + } + else -> { + // We ask our peer to pay the commit tx fees. + val localParams = LocalParams(nodeParams, isChannelOpener = true, payCommitTxFees = false) + val channelFlags = ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = true) + val (state, actions) = WaitForInit.process( + ChannelCommand.Init.Initiator( + fundingAmount = localFundingAmount, + pushAmount = 0.msat, + walletInputs = cmd.walletInputs, + commitTxFeerate = currentFeerates.commitmentFeerate, + fundingTxFeerate = currentFeerates.fundingFeerate, + localParams = localParams, + remoteInit = theirInit!!, + channelFlags = channelFlags, + channelConfig = ChannelConfig.standard, + channelType = ChannelType.SupportedChannelType.AnchorOutputsZeroReserve, + requestRemoteFunding = requestRemoteFunding, + channelOrigin = Origin.OnChainWallet(cmd.walletInputs.map { it.outPoint }.toSet(), cmd.totalAmount.toMilliSatoshi(), fees), + ) + ) + val msg = actions.filterIsInstance().map { it.message }.filterIsInstance().first() + _channels = _channels + (msg.temporaryChannelId to state) + processActions(msg.temporaryChannelId, peerConnection, actions) + nodeParams._nodeEvents.emit(SwapInEvents.Requested(cmd.walletInputs)) + } + } + } + } else { + // There are existing channels but not immediately usable (e.g. creating, disconnected), we don't do anything yet. + 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 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. + 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 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. + 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/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..72665255d 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -7,15 +7,17 @@ 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.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.* +import fr.acinq.lightning.logging.MDCLogger +import fr.acinq.lightning.logging.mdc import fr.acinq.lightning.utils.* import fr.acinq.lightning.wire.* @@ -33,14 +35,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 leaseRates: List) { sealed class ProcessAddResult { abstract val actions: List @@ -106,13 +108,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, @@ -122,7 +124,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, @@ -131,26 +133,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.PayToOpenOrigin -> { - // 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))) } } @@ -160,11 +159,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, @@ -172,27 +171,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 @@ -221,10 +220,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 { @@ -234,13 +232,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) } @@ -250,57 +242,74 @@ 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() -> 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 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) } } - pending.remove(paymentPart.paymentHash) - return ProcessAddResult.Rejected(actions, incomingPayment) } + 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) } } } @@ -314,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: @@ -337,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: @@ -345,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: @@ -356,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) } @@ -369,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. @@ -381,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 } @@ -407,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() } @@ -430,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 { @@ -461,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 101dc7bb7..c9ed8ac95 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/LiquidityPolicy.kt @@ -3,24 +3,26 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.LiquidityEvents import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.toMilliSatoshi - sealed class LiquidityPolicy { - /** Never initiates swap-ins, never accept pay-to-open */ + /** Never initiates swap-ins, never accept on-the-fly funding requests. */ data object Disable : LiquidityPolicy() /** - * Allow automated liquidity managements, within relative and absolute fee limits. Both conditions must be met. + * Allow automated liquidity management, within relative and absolute fee limits. Both conditions must be met. + * + * @param inboundLiquidityTarget amount of inbound liquidity the buyer would like to 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,13 +30,21 @@ 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) } } + 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/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..9a61bce9d 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,76 @@ object Deserialization { } private fun Input.readChannelOrigin(): Origin = when (val discriminator = read()) { - 0x01 -> Origin.PayToOpenOrigin( - paymentHash = readByteVector32(), - serviceFee = readNumber().msat, - miningFee = readNumber().sat, + 0x01 -> { + // Note that we've replaced this field by the payment preimage: old entries will be incorrect, but it's not critical. + val paymentHash = readByteVector32() + val serviceFee = readNumber().msat + val miningFee = readNumber().sat + val amount = readNumber().msat + Origin.OffChainPayment(paymentHash, amount, 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( + paymentPreimage = readByteVector32(), 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..2fa338703 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) - writeByteVector32(o.paymentHash) - writeNumber(o.serviceFee.toLong()) - writeNumber(o.miningFee.toLong()) + is Origin.OffChainPayment -> { + write(0x03) + writeByteVector32(o.paymentPreimage) 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..91cdf3ffd 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 @@ -201,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 { @@ -339,37 +278,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/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 598be3826..85d157305 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -8,9 +8,9 @@ import fr.acinq.bitcoin.io.Input import fr.acinq.bitcoin.io.Output import fr.acinq.lightning.* import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.channel.ChannelFlags import fr.acinq.lightning.channel.ChannelType -import fr.acinq.lightning.channel.Origin -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.router.Announcements import fr.acinq.lightning.utils.* import fr.acinq.secp256k1.Hex @@ -77,12 +77,14 @@ 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) + CurrentFeeCredit.type -> CurrentFeeCredit.read(stream) + AddFeeCredit.type -> AddFeeCredit.read(stream) 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 +666,13 @@ data class OpenDualFundedChannel( val htlcBasepoint: PublicKey, val firstPerCommitmentPoint: PublicKey, val secondPerCommitmentPoint: PublicKey, - val channelFlags: Byte, + val channelFlags: ChannelFlags, val tlvStream: TlvStream = TlvStream.empty() ) : ChannelMessage, HasTemporaryChannelId, HasChainHash { val channelType: ChannelType? get() = tlvStream.get()?.channelType val pushAmount: MilliSatoshi get() = tlvStream.get()?.amount ?: 0.msat val requestFunds: ChannelTlv.RequestFunds? get() = tlvStream.get() - val origin: Origin? get() = tlvStream.get()?.origin + val preimage: ByteVector32? = tlvStream.get()?.preimage override val type: Long get() = OpenDualFundedChannel.type @@ -693,7 +695,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 +710,55 @@ 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, + ChannelTlv.OnTheFlyFundingPreimage.tag to ChannelTlv.OnTheFlyFundingPreimage.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,15 +953,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 origins: List = tlvStream.get()?.origins?.filterIsInstance() ?: emptyList() + 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) { @@ -954,7 +987,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.OriginsTlv.tag to ChannelTlv.OriginsTlv.Companion as TlvValueReader + ChannelTlv.OnTheFlyFundingPreimage.tag to ChannelTlv.OnTheFlyFundingPreimage.Companion as TlvValueReader, ) override fun read(input: Input): SpliceInit = SpliceInit( @@ -1572,102 +1605,130 @@ 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), + ) + } +} + +/** + * 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 + + fun toAscii(): String = data.toByteArray().decodeToString() + + override fun write(out: Output) { + LightningCodecs.writeBytes(channelId, out) + LightningCodecs.writeBytes(paymentHash, out) + LightningCodecs.writeU16(data.size(), out) + LightningCodecs.writeBytes(data, out) + } + + companion object : LightningMessageReader { + const val type: Long = 35029 + + 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() + ) } } -data class PayToOpenResponse(override val chainHash: BlockHash, val paymentHash: ByteVector32, val result: Result) : LightningMessage, HasChainHash { - override val type: Long get() = PayToOpenResponse.type +/** + * 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() - sealed class Result { - // @formatter:off - data class Success(val paymentPreimage: ByteVector32) : Result() - /** reason is an onion-encrypted failure message, like those in UpdateFailHtlc */ - data class Failure(val reason: ByteVector?) : Result() - // @formatter:on + override fun write(out: Output) { + LightningCodecs.writeBytes(chainHash.value, out) + LightningCodecs.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(paymentHash, out) - when (result) { - is Result.Success -> LightningCodecs.writeBytes(result.paymentPreimage, out) - is Result.Failure -> { - LightningCodecs.writeBytes(ByteVector32.Zeroes, out) // this is for backward compatibility - result.reason?.let { - LightningCodecs.writeU16(it.size(), out) - LightningCodecs.writeBytes(it, out) - } - } - } + LightningCodecs.writeBytes(preimage, out) } - companion object : LightningMessageReader { - const val type: Long = 35003 + companion object : LightningMessageReader { + const val type: Long = 35033 - 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): AddFeeCredit = AddFeeCredit( + chainHash = BlockHash(LightningCodecs.bytes(input, 32)), + preimage = LightningCodecs.bytes(input, 32).byteVector32(), + ) } } @@ -1715,48 +1776,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 - - @Suppress("UNCHECKED_CAST") - val readers = mapOf( - PleaseOpenChannelTlv.GrandParents.tag to PleaseOpenChannelTlv.GrandParents.Companion as TlvValueReader, - ) + companion object : LightningMessageReader { + const val type: Long = 35025 - 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..e5a3e3cc8 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) { @@ -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) { @@ -78,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 @@ -137,6 +157,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/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/InteractiveTxTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt index 955293a92..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() - val localParamsB = TestConstants.Bob.channelParams() + val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = !nonInitiatorPaysCommitFees) + val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = nonInitiatorPaysCommitFees) val channelKeysA = localParamsA.channelKeys(TestConstants.Alice.keyManager) val channelKeysB = localParamsB.channelKeys(TestConstants.Bob.keyManager) val swapInKeysA = TestConstants.Alice.keyManager.swapInOnChainWallet @@ -1216,11 +1292,12 @@ class InteractiveTxTestsCommon : LightningTestSuite() { fundingContributionB: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, - lockTime: Long + lockTime: Long, + nonInitiatorPaysCommitFees: Boolean = false, ): Either { val channelId = randomBytes32() - val localParamsA = TestConstants.Alice.channelParams() - val localParamsB = TestConstants.Bob.channelParams() + val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = !nonInitiatorPaysCommitFees) + val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = nonInitiatorPaysCommitFees) val channelKeysA = localParamsA.channelKeys(TestConstants.Alice.keyManager) val channelKeysB = localParamsB.channelKeys(TestConstants.Bob.keyManager) val swapInKeysA = TestConstants.Alice.keyManager.swapInOnChainWallet @@ -1248,12 +1325,13 @@ class InteractiveTxTestsCommon : LightningTestSuite() { outputsB: List, targetFeerate: FeeratePerKw, dustLimit: Satoshi, - lockTime: Long + lockTime: Long, + nonInitiatorPaysCommitFees: Boolean = false, ): Fixture { val channelId = randomBytes32() val fundingTxIndex = 0L - val localParamsA = TestConstants.Alice.channelParams() - val localParamsB = TestConstants.Bob.channelParams() + val localParamsA = TestConstants.Alice.channelParams(payCommitTxFees = !nonInitiatorPaysCommitFees) + val localParamsB = TestConstants.Bob.channelParams(payCommitTxFees = nonInitiatorPaysCommitFees) val channelKeysA = localParamsA.channelKeys(TestConstants.Alice.keyManager) val channelKeysB = localParamsB.channelKeys(TestConstants.Bob.keyManager) val swapInKeysA = TestConstants.Alice.keyManager.swapInOnChainWallet diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt index b2fc90955..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,9 +179,9 @@ object TestsHelper { WaitForInit ) - val channelFlags = 0.toByte() - val aliceChannelParams = TestConstants.Alice.channelParams().copy(features = aliceFeatures.initFeatures()) - val bobChannelParams = TestConstants.Bob.channelParams().copy(features = bobFeatures.initFeatures()) + val channelFlags = ChannelFlags(announceChannel = false, nonInitiatorPaysCommitFees = requestRemoteFunding != null) + val aliceChannelParams = TestConstants.Alice.channelParams(payCommitTxFees = !channelFlags.nonInitiatorPaysCommitFees).copy(features = aliceFeatures.initFeatures()) + val bobChannelParams = TestConstants.Bob.channelParams(payCommitTxFees = channelFlags.nonInitiatorPaysCommitFees).copy(features = bobFeatures.initFeatures()) val aliceInit = Init(aliceFeatures) val bobInit = Init(bobFeatures) val (alice1, actionsAlice1) = alice.process( @@ -194,12 +196,14 @@ object TestsHelper { channelFlags, ChannelConfig.standard, channelType, - channelOrigin + 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/QuiescenceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt index 430b971de..53737c775 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt @@ -519,7 +519,8 @@ class QuiescenceTestsCommon : LightningTestSuite() { spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(sender.staticParams.nodeParams.keyManager, spliceIn)), spliceOut = spliceOut?.let { ChannelCommand.Commitment.Splice.Request.SpliceOut(it, Script.write(Script.pay2wpkh(Lightning.randomKey().publicKey())).byteVector()) }, feerate = FeeratePerKw(253.sat), - requestRemoteFunding = null + requestRemoteFunding = null, + origins = listOf(), ) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ShutdownTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ShutdownTestsCommon.kt index 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 f8d7eb039..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)) + 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. @@ -202,7 +204,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 +214,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) @@ -237,7 +239,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 +252,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)) @@ -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() @@ -1284,7 +1334,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 +1380,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 +1418,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 +1452,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)) @@ -1486,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 -> {} @@ -1509,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/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 faf65e15e..1bcb4754d 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,62 +47,130 @@ 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) } } } @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, 6) + 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() + val events = actionsAlice1.filterIsInstance().map { it.event } + assertTrue(events.any { it is ChannelEvents.Created }) + assertTrue(events.any { it is LiquidityEvents.Accepted }) } @Test fun `recv CommitSig -- with channel origin -- dual-swap-in`() { - val channelOrigin = Origin.PleaseOpenChannelOrigin(randomBytes32(), 2500.msat, 0.sat, TestConstants.bobFundingAmount.toMilliSatoshi() - TestConstants.bobPushAmount) - val (_, commitSigAlice, bob, _) = init(alicePushAmount = 0.msat, channelOrigin = channelOrigin) - val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(commitSigAlice)) - assertIs(bob1.state) - assertEquals(actionsBob1.size, 5) - assertFalse(actionsBob1.hasOutgoingMessage().channelData.isEmpty()) - actionsBob1.has() - actionsBob1.find().also { - assertEquals(it.amount, TestConstants.bobFundingAmount.toMilliSatoshi() - TestConstants.bobPushAmount) + val channelOrigin = Origin.OnChainWallet(setOf(), 200_000_000.msat, 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, 6) + 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() + val events = actionsAlice1.filterIsInstance().map { it.event } + assertTrue(events.any { it is ChannelEvents.Created }) + assertTrue(events.any { it is SwapInEvents.Accepted }) } @Test @@ -295,6 +361,7 @@ class WaitForFundingSignedTestsCommon : LightningTestSuite() { bobFundingAmount: Satoshi = TestConstants.bobFundingAmount, alicePushAmount: MilliSatoshi = TestConstants.alicePushAmount, bobPushAmount: MilliSatoshi = TestConstants.bobPushAmount, + requestRemoteFunding: Satoshi? = null, zeroConf: Boolean = false, channelOrigin: Origin? = null ): Fixture { @@ -307,6 +374,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/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/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/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 b0d0a3a24..97cc040c4 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt @@ -2,17 +2,15 @@ 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.* +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 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 +129,246 @@ 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.PayToOpenOrigin(amount = payToOpenRequest.amountMsat, paymentHash = payToOpenRequest.paymentHash, serviceFee = 0.msat, miningFee = payToOpenRequest.payToOpenFeeSatoshis) + 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 { + 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. + TestCase(defaultPolicy, 7_000_000.msat, allowFeeCredit = false, success = true), + // If fee is above our liquidity policy maximum fee, we reject the payment. + 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. + TestCase(defaultPolicy, 6_999_999.msat, allowFeeCredit = false, success = false), ) - assertEquals(setOf(expected), result.actions.toSet()) + 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 (it.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 +376,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 +387,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 +405,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 +413,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 +425,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 +434,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 +450,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 +461,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 +521,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 +543,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 +562,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 +572,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 +601,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 +610,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 +625,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 +636,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 +648,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 +669,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 +681,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 +691,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 +701,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 +709,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { @Test fun `invoice expired`() = runSuspendTest { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb(), TestConstants.leaseRates) val (incomingPayment, paymentSecret) = makeIncomingPayment( payee = paymentHandler, amount = defaultAmount, @@ -788,7 +717,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 +728,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 +739,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 +758,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 +776,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 +795,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 +807,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 +834,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 +845,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 +863,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 +902,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 +920,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 +930,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 +954,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 +971,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 +992,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 +1010,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 +1026,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 +1041,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.leaseRates) // create incoming payment that has expired and not been paid val expiredInvoice = paymentHandler.createInvoice( @@ -1128,7 +1057,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 +1081,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 +1111,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,37 +1137,12 @@ 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) 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) @@ -1243,8 +1152,14 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { assertEquals(incomingPayment.received?.receivedWith, dbPayment.received?.receivedWith) } - private suspend fun createFixture(invoiceAmount: MilliSatoshi?): Triple { - val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + private suspend fun createFixture(invoiceAmount: MilliSatoshi?, 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) return Triple(paymentHandler, incomingPayment, paymentSecret) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt index 718d09770..0a7b14a91 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/LiquidityPolicyTestsCommon.kt @@ -1,7 +1,7 @@ package fr.acinq.lightning.payment import fr.acinq.lightning.LiquidityEvents -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat @@ -15,44 +15,34 @@ class LiquidityPolicyTestsCommon : LightningTestSuite() { @Test fun `policy rejection`() { - - val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false) - + val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false, inboundLiquidityTarget = null) // fee over both absolute and relative assertEquals( expected = LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(policy.maxRelativeFeeBasisPoints), actual = policy.maybeReject(amount = 4_000_000.msat, fee = 3_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)?.reason ) - // fee over absolute assertEquals( expected = LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(policy.maxAbsoluteFee), actual = policy.maybeReject(amount = 15_000_000.msat, fee = 3_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)?.reason ) - // fee over relative assertEquals( expected = LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee(policy.maxRelativeFeeBasisPoints), actual = policy.maybeReject(amount = 4_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)?.reason ) - assertNull(policy.maybeReject(amount = 10_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)) - } @Test fun `policy rejection skip absolute check`() { - - val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 1_000.sat, maxRelativeFeeBasisPoints = 5_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = true) - + val policy = LiquidityPolicy.Auto(maxAbsoluteFee = 1_000.sat, maxRelativeFeeBasisPoints = 5_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = true, inboundLiquidityTarget = null) // fee is over absolute, and it's an offchain payment so the check passes assertNull(policy.maybeReject(amount = 4_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OffChainPayment, logger)) - // fee is over absolute, but it's an on-chain payment so the check fails assertEquals( expected = LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee(policy.maxAbsoluteFee), actual = policy.maybeReject(amount = 4_000_000.msat, fee = 2_000_000.msat, source = LiquidityEvents.Source.OnChainWallet, logger)?.reason ) - } } \ No newline at end of file diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt index 868530494..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()) + 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) @@ -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 e7eec6023..8f16673c1 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 @@ -11,6 +14,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 +41,17 @@ 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, + ) + + val leaseRates = listOf(LiquidityAds.BoundedLeaseRate(0.sat, 100_000_000.sat, leaseRate)) + const val aliceSwapInServerXpub = "tpubDCvYeHUZisCMVTSfWDa1yevTf89NeF6TWxXUQwqkcmFrNvNdNvZQh1j4m4uTA4QcmPEwcrKVF8bJih1v16zDZacRr4j9MCAFQoSydKKy66q" const val bobSwapInServerXpub = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41" @@ -65,7 +80,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, @@ -85,7 +99,7 @@ object TestConstants { paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(0), CltvExpiryDelta(0)), ) - fun channelParams(): LocalParams = LocalParams(nodeParams, isInitiator = true) + fun channelParams(payCommitTxFees: Boolean): LocalParams = LocalParams(nodeParams, isChannelOpener = true, payCommitTxFees = payCommitTxFees) } object Bob { @@ -116,7 +130,7 @@ object TestConstants { paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(0), CltvExpiryDelta(0)), ) - fun channelParams(): LocalParams = LocalParams(nodeParams, isInitiator = false) + fun channelParams(payCommitTxFees: Boolean): LocalParams = LocalParams(nodeParams, isChannelOpener = false, payCommitTxFees = payCommitTxFees) } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/tests/io/peer/builders.kt b/src/commonTest/kotlin/fr/acinq/lightning/tests/io/peer/builders.kt index c82c3bde3..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 @@ -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 @@ -62,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)) @@ -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. @@ -140,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 -> @@ -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.leaseRates, TcpSocket.Builder(), scope) peer.currentTipFlow.value = currentTip peer.onChainFeeratesFlow.value = OnChainFeerates( fundingFeerate = FeeratePerKw(FeeratePerByte(5.sat)), @@ -195,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/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..59ab50fbc 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -290,17 +290,38 @@ 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 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")), - 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) -> @@ -460,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"), @@ -751,33 +774,57 @@ class LightningCodecsTestsCommon : LightningTestSuite() { } @Test - fun `encode - decode pay-to-open messages`() { + fun `encode - decode maybe_add_htlc`() { + // @formatter:off + val paymentOnion = OnionRoutingPacket(0, ByteVector("03462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b"), ByteVector("9149ce01cce1709194109ab594037113e897ab6120025c770527dd8537997e2528082b984fe078a5667978a573abeaf7977d9b8b6ee4f124d3352f7eea52cc66c0e76b8f6d7a25d4501a04ae190b17baff8e6378b36f165815f714559dfef275278eba897f5f229be70fc8a1980cf859d1c25fe90c77f006419770e19d29ba80be8f613d039dd05600734e0d1e218af441fe30877e717a26b7b37c2c071d62bf6d61dd17f7abfb81546d2c722c9a6dc581aa97fb6f3b513e5fbaf0d669fbf0714b2b016a0a8e356d55f267fa144f7501792f2a59269c5a22e555a914e2eb71eba5af519564f246cf58983ea3fa2674e3ab7d9969d8dffbb2bda2b2752657417937d46601eb8ebf1837221d4bdf55a4d6a97ecffde5a09bd409717fa19e440e55d775890aed89f72e65af515757e94a9b501e6bad048af55e1583adb2960a84f60fb5efd0352e77a34045fc6b221498f62810bd8294d995d9f513696f8633f29faaa9668d0c6fa0d0dd7fa13e2c185572485762bd2810dc12187f521fbefa9c320762ac1e107f7988d81c6ee201ab68a95d45d578027e271b6526154317877037dca17134ccd955a22a8481b8e1996d896fc4bf006154ed18ef279d4f255e3f1233d037aea2560011069a0ae56d6bfdd8327054ded12d85d120b8982bff970986db333baae7c95f85677726a8f74cc8bd1e5aca3d433c113048305ecce8e35caf0485a53df00284b52b42291a9ffe686b96442135b3107d8856bc652d674ee9a148e79b02c9972d3ca8c2b02609f3b358c4a67c540ba6769c4d83169bceda640b1d18b74d12b6df605b417dacf6f82d79d43bb40920898f818dc8344c036ae9c8bbf9ef52ea1ccf225c8825a4d8503df190b999e15a4be34c9d7bbf60d3b93bb7d6559f4a5916f5e40c3defeeca9337ccd1280e46d6727c5c91c2d898b685543d4ca7cfee23981323c43260b6387e7febb0fffb200a8c974ef36b3253d0fb9fe0c1c6017f2dbbdc169f3f061d9781521e8118164aeec31c3e59c199016f1025c295d8f7bdeb627d357105a2708c4c3a856b9e83ff37ed69f59f2d2e464ed1db5882925ebe2493a7ddb707e1a308fa445172a24b3ea60732f75f5c69b41fc11467ee93f37c9a6f7285ba42f716e2a0e30909056ea3e4f7985d14ca9ab280cc184ce98e2a0722d0447aa1a2eedc5e53ddfa53731df7eced406b10627b0bebd768a30bde0d470c0f1d10adc070f8d3029cacceec74e4833f4dc8c52c3f41733f5f896fceb425d0737e717a63bfb033df46286d99594dd01e2bd0a942ab792874177b32842f4833bc0340ddb74852e9cd6f29f1d997a4a4bf05dd5d12011f95e6ce18928e3a9b83b24d15f989bdf43370bcc657c3ac6601eaf5e951efdbd7ee69b1623dc5039b2dfc640692378ef032f17bc36cc00293ad90b7e18f5feb8f287a7061ed9713929aed9b14b8d566199fc7822b1c38daa16b6d83077b10af0e2b6e531ccc34ea248ea593128c9ff17defcee6618c29cd2d93cfed99b90319104b1fdcfea91e98b41d792782840fb7b25280d8565b0bcd874e79b1b323139e7fc88eb5f80f690ce30fcd81111076adb31de6aeced879b538c0b5f2b74c027cc582a540133952cb021424510312f13e15d403f700f3e15b41d677c5a1e7c4e692c5880cb4522c48e993381996a29615d2956781509cd74aec6a3c73b8536d1817e473dad4cbb1787e046606b692a44e5d21ef6b5219658b002f674367e90a2b610924e9ac543362257d4567728f2e61f61231cb5d7816e100bb6f6bd9a42329b728b18d7a696711650c16fd476e2f471f38af0f6b00d45c6e"), ByteVector32("1fa492cc7962814953ab6ad1ce3d3f3dc950e64d18a8fdce6aabc14321576f06")) + val trampolineOnion = OnionRoutingPacket(0, ByteVector("02531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337"), ByteVector("1860c0749bfd613056cfc5718beecc25a2f255fc7abbea3cd75ff820e9d30807d19b30f33626452fa54bb2d822e918558ed3e6714deb3f9a2a10895e7553c6f088c9a852043530dbc9abcc486030894364b205f5de60171b451ff462664ebce23b672579bf2a444ebfe0a81875c26d2fa16d426795b9b02ccbc4bdf909c583f0c2ebe9136510645917153ecb05181ca0c1b207824578ee841804a148f4c3df7306"), ByteVector32("dcea52d94222907c9187bc31c0880fc084f0d88716e195c0abe7672d15217623")) + val paymentHash = ByteVector32.fromValidHex("a04f0e1256ac502c6c878f473a98fbf4e2adbf1921eb076c3f40e99ec3956d8a") val testCases = listOf( - PayToOpenRequest(BlockHash(randomBytes32()), 10_000.sat, 5_000.msat, 100.msat, 10.sat, randomBytes32(), 100, OnionRoutingPacket(0, randomKey().publicKey().value, ByteVector("0102030405"), randomBytes32())), - PayToOpenResponse(BlockHash(randomBytes32()), randomBytes32(), PayToOpenResponse.Result.Success(randomBytes32())), - PayToOpenResponse(BlockHash(randomBytes32()), randomBytes32(), PayToOpenResponse.Result.Failure(null)), - PayToOpenResponse(BlockHash(randomBytes32()), randomBytes32(), PayToOpenResponse.Result.Failure(ByteVector("deadbeef"))), + Pair(MaybeAddHtlc(Chain.Mainnet.chainHash, 500.msat, paymentHash, CltvExpiry(1105), paymentOnion), Hex.decode("88d3 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 00000000000001f4 a04f0e1256ac502c6c878f473a98fbf4e2adbf1921eb076c3f40e99ec3956d8a 00000451 0514 0003462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b9149ce01cce1709194109ab594037113e897ab6120025c770527dd8537997e2528082b984fe078a5667978a573abeaf7977d9b8b6ee4f124d3352f7eea52cc66c0e76b8f6d7a25d4501a04ae190b17baff8e6378b36f165815f714559dfef275278eba897f5f229be70fc8a1980cf859d1c25fe90c77f006419770e19d29ba80be8f613d039dd05600734e0d1e218af441fe30877e717a26b7b37c2c071d62bf6d61dd17f7abfb81546d2c722c9a6dc581aa97fb6f3b513e5fbaf0d669fbf0714b2b016a0a8e356d55f267fa144f7501792f2a59269c5a22e555a914e2eb71eba5af519564f246cf58983ea3fa2674e3ab7d9969d8dffbb2bda2b2752657417937d46601eb8ebf1837221d4bdf55a4d6a97ecffde5a09bd409717fa19e440e55d775890aed89f72e65af515757e94a9b501e6bad048af55e1583adb2960a84f60fb5efd0352e77a34045fc6b221498f62810bd8294d995d9f513696f8633f29faaa9668d0c6fa0d0dd7fa13e2c185572485762bd2810dc12187f521fbefa9c320762ac1e107f7988d81c6ee201ab68a95d45d578027e271b6526154317877037dca17134ccd955a22a8481b8e1996d896fc4bf006154ed18ef279d4f255e3f1233d037aea2560011069a0ae56d6bfdd8327054ded12d85d120b8982bff970986db333baae7c95f85677726a8f74cc8bd1e5aca3d433c113048305ecce8e35caf0485a53df00284b52b42291a9ffe686b96442135b3107d8856bc652d674ee9a148e79b02c9972d3ca8c2b02609f3b358c4a67c540ba6769c4d83169bceda640b1d18b74d12b6df605b417dacf6f82d79d43bb40920898f818dc8344c036ae9c8bbf9ef52ea1ccf225c8825a4d8503df190b999e15a4be34c9d7bbf60d3b93bb7d6559f4a5916f5e40c3defeeca9337ccd1280e46d6727c5c91c2d898b685543d4ca7cfee23981323c43260b6387e7febb0fffb200a8c974ef36b3253d0fb9fe0c1c6017f2dbbdc169f3f061d9781521e8118164aeec31c3e59c199016f1025c295d8f7bdeb627d357105a2708c4c3a856b9e83ff37ed69f59f2d2e464ed1db5882925ebe2493a7ddb707e1a308fa445172a24b3ea60732f75f5c69b41fc11467ee93f37c9a6f7285ba42f716e2a0e30909056ea3e4f7985d14ca9ab280cc184ce98e2a0722d0447aa1a2eedc5e53ddfa53731df7eced406b10627b0bebd768a30bde0d470c0f1d10adc070f8d3029cacceec74e4833f4dc8c52c3f41733f5f896fceb425d0737e717a63bfb033df46286d99594dd01e2bd0a942ab792874177b32842f4833bc0340ddb74852e9cd6f29f1d997a4a4bf05dd5d12011f95e6ce18928e3a9b83b24d15f989bdf43370bcc657c3ac6601eaf5e951efdbd7ee69b1623dc5039b2dfc640692378ef032f17bc36cc00293ad90b7e18f5feb8f287a7061ed9713929aed9b14b8d566199fc7822b1c38daa16b6d83077b10af0e2b6e531ccc34ea248ea593128c9ff17defcee6618c29cd2d93cfed99b90319104b1fdcfea91e98b41d792782840fb7b25280d8565b0bcd874e79b1b323139e7fc88eb5f80f690ce30fcd81111076adb31de6aeced879b538c0b5f2b74c027cc582a540133952cb021424510312f13e15d403f700f3e15b41d677c5a1e7c4e692c5880cb4522c48e993381996a29615d2956781509cd74aec6a3c73b8536d1817e473dad4cbb1787e046606b692a44e5d21ef6b5219658b002f674367e90a2b610924e9ac543362257d4567728f2e61f61231cb5d7816e100bb6f6bd9a42329b728b18d7a696711650c16fd476e2f471f38af0f6b00d45c6e1fa492cc7962814953ab6ad1ce3d3f3dc950e64d18a8fdce6aabc14321576f06")), + Pair(MaybeAddHtlc(Chain.Mainnet.chainHash, 500_000_000.msat, paymentHash, CltvExpiry(1729), trampolineOnion), Hex.decode("88d3 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000 000000001dcd6500 a04f0e1256ac502c6c878f473a98fbf4e2adbf1921eb076c3f40e99ec3956d8a 000006c1 00a1 0002531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe3371860c0749bfd613056cfc5718beecc25a2f255fc7abbea3cd75ff820e9d30807d19b30f33626452fa54bb2d822e918558ed3e6714deb3f9a2a10895e7553c6f088c9a852043530dbc9abcc486030894364b205f5de60171b451ff462664ebce23b672579bf2a444ebfe0a81875c26d2fa16d426795b9b02ccbc4bdf909c583f0c2ebe9136510645917153ecb05181ca0c1b207824578ee841804a148f4c3df7306dcea52d94222907c9187bc31c0880fc084f0d88716e195c0abe7672d15217623")), ) - + // @formatter:on testCases.forEach { - val encoded = LightningMessage.encode(it) - val decoded = LightningMessage.decode(encoded) + val decoded = LightningMessage.decode(it.second) assertNotNull(decoded) - assertEquals(it, decoded) + assertEquals(it.first, decoded) + val encoded = LightningMessage.encode(decoded) + assertArrayEquals(it.second, encoded) } } @Test - fun `encode - decode please-open-channel messages`() { + fun `encode - decode cancel_on_the_fly_funding`() { + val channelId = ByteVector32.fromValidHex("7254d3485152e694c57407f7954bb12ecf0a276ca48e72a49eef105507d49a7b") + val paymentHash = ByteVector32.fromValidHex("f71a0725d6d922ac29156af8d45492a9f2c5e1980e90661e0345a4f65853de99") 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"), + 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 decoded = LightningMessage.decode(it.second) + assertNotNull(decoded) + assertEquals(it.first, decoded) + val encoded = LightningMessage.encode(decoded) + assertArrayEquals(it.second, encoded) + } + } + @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) @@ -802,6 +849,21 @@ class LightningCodecsTestsCommon : LightningTestSuite() { } } + @Test + fun `encode - decode recommended feerates messages`() { + val testCases = listOf( + 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) + assertNotNull(decoded) + assertEquals(it.first, decoded) + val encoded = LightningMessage.encode(decoded) + assertArrayEquals(it.second, encoded) + } + } + @Test fun `validate liquidity ads lease`() { // The following lease has been signed by eclair. 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": {