diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt index 25e2a90e3..a4086d9b4 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt @@ -87,7 +87,15 @@ sealed class ChannelCommand { data class UpdateFee(val feerate: FeeratePerKw, val commit: Boolean = false) : Commitment(), ForbiddenDuringSplice, ForbiddenDuringQuiescence data object CheckHtlcTimeout : Commitment() sealed class Splice : Commitment() { - data class Request(val replyTo: CompletableDeferred, val spliceIn: SpliceIn?, val spliceOut: SpliceOut?, val requestRemoteFunding: LiquidityAds.RequestFunding?, val feerate: FeeratePerKw, val origins: List) : Splice() { + data class Request( + val replyTo: CompletableDeferred, + val spliceIn: SpliceIn?, + val spliceOut: SpliceOut?, + val requestRemoteFunding: LiquidityAds.RequestFunding?, + val currentFeeCredit: MilliSatoshi, + 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() @@ -110,7 +118,7 @@ sealed class ChannelCommand { ) : Response() sealed class Failure : Response() { - data object InsufficientFunds : Failure() + data class InsufficientFunds(val balanceAfterFees: MilliSatoshi, val liquidityFees: MilliSatoshi, val currentFeeCredit: MilliSatoshi) : Failure() data object InvalidSpliceOutPubKeyScript : Failure() data object SpliceAlreadyInProgress : Failure() data object ConcurrentRemoteSplice : Failure() 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 3c24f9e2c..99f010425 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt @@ -389,22 +389,29 @@ data class Normal( paysCommitTxFees -> Transactions.commitTxFee(commitments.params.remoteParams.dustLimit, parentCommitment.remoteCommit.spec) else -> 0.sat } - if (parentCommitment.localCommit.spec.toLocal + fundingContribution.toMilliSatoshi() < parentCommitment.localChannelReserve(commitments.params).max(commitTxFees)) { - logger.warning { "cannot do splice: insufficient funds" } - spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds) - val actions = buildList { - add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceRequest(channelId).message))) - add(ChannelAction.Disconnect) + val liquidityFees = when (val requestRemoteFunding = spliceStatus.command.requestRemoteFunding) { + null -> 0.msat + else -> when (requestRemoteFunding.paymentDetails.paymentType) { + LiquidityAds.PaymentType.FromChannelBalance -> requestRemoteFunding.fees(spliceStatus.command.feerate, isChannelCreation = false).total.toMilliSatoshi() + LiquidityAds.PaymentType.FromChannelBalanceForFutureHtlc -> requestRemoteFunding.fees(spliceStatus.command.feerate, isChannelCreation = false).total.toMilliSatoshi() + // Liquidity fees will be deducted from future HTLCs instead of being paid immediately. + LiquidityAds.PaymentType.FromFutureHtlc -> 0.msat + LiquidityAds.PaymentType.FromFutureHtlcWithPreimage -> 0.msat + is LiquidityAds.PaymentType.Unknown -> 0.msat } - Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) + } + val liquidityFeesOwed = (liquidityFees - spliceStatus.command.currentFeeCredit).max(0.msat) + val balanceAfterFees = parentCommitment.localCommit.spec.toLocal + fundingContribution.toMilliSatoshi() - liquidityFeesOwed + if (balanceAfterFees < parentCommitment.localChannelReserve(commitments.params).max(commitTxFees)) { + logger.warning { "cannot do splice: insufficient funds (balanceAfterFees=$balanceAfterFees, liquidityFees=$liquidityFees, feeCredit=${spliceStatus.command.currentFeeCredit})" } + spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds(balanceAfterFees, liquidityFees, spliceStatus.command.currentFeeCredit)) + val action = listOf(ChannelAction.Message.Send(TxAbort(channelId, InvalidSpliceRequest(channelId).message))) + Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), action) } else if (spliceStatus.command.spliceOut?.scriptPubKey?.let { Helpers.Closing.isValidFinalScriptPubkey(it, allowAnySegwit = true) } == false) { logger.warning { "cannot do splice: invalid splice-out script" } spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InvalidSpliceOutPubKeyScript) - val actions = buildList { - add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceRequest(channelId).message))) - add(ChannelAction.Disconnect) - } - Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) + val action = listOf(ChannelAction.Message.Send(TxAbort(channelId, InvalidSpliceRequest(channelId).message))) + Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), action) } else { val spliceInit = SpliceInit( channelId, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index 9cbe0696d..0144026ca 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -643,6 +643,7 @@ class Peer( spliceIn = null, spliceOut = ChannelCommand.Commitment.Splice.Request.SpliceOut(amount, scriptPubKey), requestRemoteFunding = null, + currentFeeCredit = feeCreditFlow.value, feerate = feerate, origins = listOf(), ) @@ -662,6 +663,7 @@ class Peer( spliceIn = null, spliceOut = null, requestRemoteFunding = null, + currentFeeCredit = feeCreditFlow.value, feerate = feerate, origins = listOf(), ) @@ -680,6 +682,7 @@ class Peer( spliceIn = null, spliceOut = null, requestRemoteFunding = LiquidityAds.RequestFunding(amount, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance), + currentFeeCredit = feeCreditFlow.value, feerate = feerate, origins = listOf(), ) @@ -1285,6 +1288,7 @@ class Peer( spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(cmd.walletInputs), spliceOut = null, requestRemoteFunding = null, + currentFeeCredit = feeCreditFlow.value, feerate = feerate, origins = listOf(Origin.OnChainWallet(cmd.walletInputs.map { it.outPoint }.toSet(), cmd.totalAmount.toMilliSatoshi(), ChannelManagementFees(fee, 0.sat))) ) @@ -1425,6 +1429,7 @@ class Peer( spliceIn = null, spliceOut = null, requestRemoteFunding = LiquidityAds.RequestFunding(cmd.requestedAmount, cmd.fundingRate, paymentDetails), + currentFeeCredit = currentFeeCredit, feerate = targetFeerate, origins = listOf(Origin.OffChainPayment(cmd.preimage, cmd.paymentAmount, totalFees)) ) 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 53737c775..a27a3cd25 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt @@ -520,6 +520,7 @@ class QuiescenceTestsCommon : LightningTestSuite() { spliceOut = spliceOut?.let { ChannelCommand.Commitment.Splice.Request.SpliceOut(it, Script.write(Script.pay2wpkh(Lightning.randomKey().publicKey())).byteVector()) }, feerate = FeeratePerKw(253.sat), requestRemoteFunding = null, + currentFeeCredit = 0.msat, origins = listOf(), ) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt index 30282771b..6d73e55ab 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt @@ -161,7 +161,7 @@ class SpliceTestsCommon : LightningTestSuite() { val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) val bobStfu = actionsBob2.findOutgoingMessage() val (_, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(bobStfu)) - actionsAlice3.findOutgoingMessage() + actionsAlice3.findOutgoingMessage() runBlocking { val response = cmd.replyTo.await() assertIs(response) @@ -193,7 +193,7 @@ class SpliceTestsCommon : LightningTestSuite() { paymentTypes = setOf(LiquidityAds.PaymentType.FromChannelBalance), ) val liquidityRequest = LiquidityAds.RequestFunding(200_000.sat, fundingRates.findRate(200_000.sat)!!, LiquidityAds.PaymentDetails.FromChannelBalance) - val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf()) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, 0.msat, FeeratePerKw(1000.sat), listOf()) val (alice1, bob1, spliceInit) = reachQuiescent(cmd, alice, bob) assertEquals(spliceInit.requestFunding, liquidityRequest) // Alice's contribution is negative: she needs to pay on-chain fees for the splice. @@ -243,33 +243,35 @@ class SpliceTestsCommon : LightningTestSuite() { @Test fun `splice to purchase inbound liquidity -- not enough funds`() { - val (alice, bob) = reachNormal(aliceFundingAmount = 100_000.sat, bobFundingAmount = 10_000.sat, alicePushAmount = 0.msat, bobPushAmount = 0.msat) + val (alice, bob) = reachNormal(channelType = ChannelType.SupportedChannelType.AnchorOutputsZeroReserve, aliceFundingAmount = 100_000.sat, bobFundingAmount = 10_000.sat, alicePushAmount = 0.msat, bobPushAmount = 0.msat) val fundingRate = LiquidityAds.FundingRate(100_000.sat, 10_000_000.sat, 0, 100 /* 1% */, 0.sat, 1000.sat) val fundingRates = LiquidityAds.WillFundRates(listOf(fundingRate), setOf(LiquidityAds.PaymentType.FromChannelBalance, LiquidityAds.PaymentType.FromFutureHtlc)) run { val liquidityRequest = LiquidityAds.RequestFunding(1_000_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance) assertEquals(10_000.sat, liquidityRequest.fees(FeeratePerKw(1000.sat), isChannelCreation = false).total) - val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf()) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, 0.msat, FeeratePerKw(1000.sat), listOf()) val (bob1, actionsBob1) = bob.process(cmd) val bobStfu = actionsBob1.findOutgoingMessage() - val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) + val (_, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) val aliceStfu = actionsAlice1.findOutgoingMessage() val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) - val spliceInit = actionsBob2.hasOutgoingMessage().also { assertEquals(liquidityRequest, it.requestFunding) } - val (_, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceInit)) - val spliceAck = actionsAlice2.hasOutgoingMessage() - // We don't implement the liquidity provider side, so we must fake it. - assertNull(spliceAck.willFund) - val fundingScript = Helpers.Funding.makeFundingPubKeyScript(spliceInit.fundingPubkey, spliceAck.fundingPubkey) - val willFund = fundingRates.validateRequest(alice.staticParams.nodeParams.nodePrivateKey, fundingScript, cmd.feerate, spliceInit.requestFunding!!, isChannelCreation = false, 0.msat)!!.willFund - val (_, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(spliceAck.copy(fundingContribution = liquidityRequest.requestedAmount, tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))))) - assertEquals(1, actionsBob3.size) - actionsBob3.hasOutgoingMessage() + assertIs(bob2.state) + assertEquals(SpliceStatus.Aborted, bob2.state.spliceStatus) + actionsBob2.hasOutgoingMessage() + runBlocking { + val response = cmd.replyTo.await() + assertIs(response) + assertEquals(10_000_000.msat, response.liquidityFees) + } + val (bob3, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(TxAbort(bob.channelId, SpliceAborted(bob.channelId).message))) + assertIs(bob3.state) + assertEquals(SpliceStatus.None, bob3.state.spliceStatus) + assertTrue(actionsBob3.isEmpty()) } run { val liquidityRequest = LiquidityAds.RequestFunding(900_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance) assertEquals(9_000.sat, liquidityRequest.fees(FeeratePerKw(1000.sat), isChannelCreation = false).total) - val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf()) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, 0.msat, FeeratePerKw(1000.sat), listOf()) val (bob1, actionsBob1) = bob.process(cmd) val bobStfu = actionsBob1.findOutgoingMessage() val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) @@ -290,7 +292,7 @@ class SpliceTestsCommon : LightningTestSuite() { // When we don't have enough funds in our channel balance, fees can be paid via future HTLCs. val liquidityRequest = LiquidityAds.RequestFunding(1_000_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(randomBytes32()))) assertEquals(10_000.sat, liquidityRequest.fees(FeeratePerKw(1000.sat), isChannelCreation = false).total) - val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, FeeratePerKw(1000.sat), listOf()) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, liquidityRequest, 0.msat, FeeratePerKw(1000.sat), listOf()) val (bob1, actionsBob1) = bob.process(cmd) val bobStfu = actionsBob1.findOutgoingMessage() val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) @@ -316,29 +318,44 @@ class SpliceTestsCommon : LightningTestSuite() { val fundingRates = LiquidityAds.WillFundRates(listOf(fundingRate), setOf(LiquidityAds.PaymentType.FromChannelBalanceForFutureHtlc, LiquidityAds.PaymentType.FromFutureHtlc)) val origin = Origin.OffChainPayment(randomBytes32(), 25_000_000.msat, ChannelManagementFees(0.sat, 500.sat)) run { - // We don't have enough funds to pay fees from our channel balance. + // We don't have enough funds nor fee credit to pay fees from our channel balance. val fundingRequest = LiquidityAds.RequestFunding(100_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(listOf(origin.paymentHash))) - val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, fundingRequest, FeeratePerKw(1000.sat), listOf(origin)) + val currentFeeCredit = 499_999.msat + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, fundingRequest, currentFeeCredit, FeeratePerKw(1000.sat), listOf(origin)) val (bob1, actionsBob1) = bob.process(cmd) val bobStfu = actionsBob1.findOutgoingMessage() - val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) + val (_, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) val aliceStfu = actionsAlice1.findOutgoingMessage() val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) - val spliceInit = actionsBob2.hasOutgoingMessage().also { assertEquals(fundingRequest, it.requestFunding) } - val (_, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceInit)) - val spliceAck = actionsAlice2.hasOutgoingMessage() - // We don't implement the liquidity provider side, so we must fake it. - assertNull(spliceAck.willFund) - val fundingScript = Helpers.Funding.makeFundingPubKeyScript(spliceInit.fundingPubkey, spliceAck.fundingPubkey) - val willFund = fundingRates.validateRequest(alice.staticParams.nodeParams.nodePrivateKey, fundingScript, cmd.feerate, spliceInit.requestFunding!!, isChannelCreation = false, 0.msat)!!.willFund - val (_, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(spliceAck.copy(fundingContribution = fundingRequest.requestedAmount, tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund))))) - assertEquals(1, actionsBob3.size) - actionsBob3.hasOutgoingMessage() + assertIs(bob2.state) + assertEquals(SpliceStatus.Aborted, bob2.state.spliceStatus) + actionsBob2.hasOutgoingMessage() + runBlocking { + val response = cmd.replyTo.await() + assertIs(response) + assertEquals(500_000.msat, response.liquidityFees) + assertEquals(currentFeeCredit, response.currentFeeCredit) + } + } + run { + // We can use our fee credit to pay fees for the liquidity we're purchasing. + val fundingRequest = LiquidityAds.RequestFunding(100_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(listOf(origin.paymentHash))) + val currentFeeCredit = 500_000.msat + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, fundingRequest, currentFeeCredit, 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)) + actionsBob2.findOutgoingMessage().also { + assertEquals(0.sat, it.fundingContribution) + assertEquals(fundingRequest, it.requestFunding) + } } run { // We can use future HTLCs to pay fees for the liquidity we're purchasing. val fundingRequest = LiquidityAds.RequestFunding(100_000.sat, fundingRate, LiquidityAds.PaymentDetails.FromFutureHtlc(listOf(origin.paymentHash))) - val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, fundingRequest, FeeratePerKw(1000.sat), listOf(origin)) + val cmd = ChannelCommand.Commitment.Splice.Request(CompletableDeferred(), null, null, fundingRequest, 0.msat, FeeratePerKw(1000.sat), listOf(origin)) val (bob1, actionsBob1) = bob.process(cmd) val bobStfu = actionsBob1.findOutgoingMessage() val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(bobStfu)) @@ -1394,6 +1411,7 @@ class SpliceTestsCommon : LightningTestSuite() { spliceIn = null, spliceOut = ChannelCommand.Commitment.Splice.Request.SpliceOut(amount, Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector()), requestRemoteFunding = null, + currentFeeCredit = 0.msat, feerate = spliceFeerate, origins = listOf(), ) @@ -1440,6 +1458,7 @@ class SpliceTestsCommon : LightningTestSuite() { spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(alice.staticParams.nodeParams.keyManager, amounts)), spliceOut = null, requestRemoteFunding = null, + currentFeeCredit = 0.msat, feerate = spliceFeerate, origins = listOf(), ) @@ -1478,6 +1497,7 @@ class SpliceTestsCommon : LightningTestSuite() { spliceIn = null, spliceOut = null, requestRemoteFunding = null, + currentFeeCredit = 0.msat, feerate = spliceFeerate, origins = listOf(), ) @@ -1513,6 +1533,7 @@ class SpliceTestsCommon : LightningTestSuite() { spliceOut = ChannelCommand.Commitment.Splice.Request.SpliceOut(outAmount, Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector()), feerate = spliceFeerate, requestRemoteFunding = null, + currentFeeCredit = 0.msat, origins = listOf(), ) val (alice1, bob1, spliceInit) = reachQuiescent(cmd, alice, bob)