Skip to content

Commit

Permalink
Cancel splice early when missing funds (#744)
Browse files Browse the repository at this point in the history
When we are splicing out or doing a CPFP on an existing splice, we use
our channel balance to pay fees and the LSP doesn't contribute at all.
We must have enough funds to pay the mining fees, otherwise we will go
through all of the `interactive-tx` protocol but fail at the end when
our peer will notice that we're not paying the feerate we said we would.

We now fail early in that case, which also lets us provide a better
error explaining why the splice attempt was cancelled.
  • Loading branch information
t-bast authored Jan 17, 2025
1 parent a79262f commit 66ad261
Show file tree
Hide file tree
Showing 3 changed files with 39 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -249,15 +249,15 @@ sealed class FundingContributionFailure {
data class FundingContributions(val inputs: List<InteractiveTxInput.Outgoing>, val outputs: List<InteractiveTxOutput.Outgoing>) {
companion object {
/** Compute our local splice contribution using all the funds available in our wallet. */
fun computeSpliceContribution(isInitiator: Boolean, commitment: Commitment, walletInputs: List<WalletState.Utxo>, localOutputs: List<TxOut>, targetFeerate: FeeratePerKw): Satoshi {
fun computeSpliceContribution(isInitiator: Boolean, commitment: Commitment, walletInputs: List<WalletState.Utxo>, localOutputs: List<TxOut>, isLiquidityPurchase: Boolean, targetFeerate: FeeratePerKw): Satoshi {
val weight = computeWeightPaid(isInitiator, commitment, walletInputs, localOutputs)
val fees = Transactions.weight2fee(targetFeerate, weight)
return when {
// When buying inbound liquidity, we may not have enough funds in our current balance to pay on-chain fees.
// The maximum amount we can use for on-chain fees is our current balance, which is fine because:
// - this will simply result in a splice transaction with a lower feerate than expected
// - liquidity fees will be paid later from future HTLCs relayed to us
walletInputs.isEmpty() && localOutputs.isEmpty() -> -(fees.min(commitment.localCommit.spec.toLocal.truncateToSatoshi()))
walletInputs.isEmpty() && localOutputs.isEmpty() && isLiquidityPurchase -> -(fees.min(commitment.localCommit.spec.toLocal.truncateToSatoshi()))
else -> walletInputs.map { it.amount }.sum() - localOutputs.map { it.amount }.sum() - fees
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ data class Normal(
commitment = parentCommitment,
walletInputs = spliceStatus.command.spliceIn?.walletInputs ?: emptyList(),
localOutputs = spliceStatus.command.spliceOutputs,
isLiquidityPurchase = spliceStatus.command.requestRemoteFunding != null,
targetFeerate = spliceStatus.command.feerate
)
val commitTxFees = when {
Expand Down Expand Up @@ -543,7 +544,14 @@ data class Normal(
channelKeys = channelKeys(),
swapInKeys = keyManager.swapInOnChainWallet,
params = fundingParams,
sharedUtxo = Pair(sharedInput, SharedFundingInputBalances(toLocal = parentCommitment.localCommit.spec.toLocal, toRemote = parentCommitment.localCommit.spec.toRemote, toHtlcs = parentCommitment.localCommit.spec.htlcs.map { it.add.amountMsat }.sum())),
sharedUtxo = Pair(
sharedInput,
SharedFundingInputBalances(
toLocal = parentCommitment.localCommit.spec.toLocal,
toRemote = parentCommitment.localCommit.spec.toRemote,
toHtlcs = parentCommitment.localCommit.spec.htlcs.map { it.add.amountMsat }.sum()
)
),
walletInputs = spliceStatus.command.spliceIn?.walletInputs ?: emptyList(),
localOutputs = spliceStatus.command.spliceOutputs,
liquidityPurchase = liquidityPurchase.value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,34 @@ class SpliceTestsCommon : LightningTestSuite() {
assertEquals(alice5.state.commitments.latest.localCommit.spec.toRemote, TestConstants.bobFundingAmount.toMilliSatoshi() + 15_000_000.msat)
}

@Test
fun `splice cpfp -- not enough funds`() {
val (alice, bob) = reachNormal(channelType = ChannelType.SupportedChannelType.AnchorOutputsZeroReserve, aliceFundingAmount = 75_000.sat, bobFundingAmount = 25_000.sat)
val (alice1, bob1) = spliceOut(alice, bob, 65_000.sat)
// After the splice-out, Alice doesn't have enough funds to pay the mining fees to CPFP.
val spliceCpfp = ChannelCommand.Commitment.Splice.Request(
replyTo = CompletableDeferred(),
spliceIn = null,
spliceOut = null,
requestRemoteFunding = null,
currentFeeCredit = 0.msat,
feerate = FeeratePerKw(20_000.sat),
origins = listOf(),
)
val (alice2, actionsAlice2) = alice1.process(spliceCpfp)
val aliceStfu = actionsAlice2.findOutgoingMessage<Stfu>()
val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu))
val bobStfu = actionsBob2.findOutgoingMessage<Stfu>()
val (alice3, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(bobStfu))
actionsAlice3.hasOutgoingMessage<TxAbort>()
assertIs<Normal>(alice3.state)
assertEquals(SpliceStatus.Aborted, alice3.state.spliceStatus)
runBlocking {
val response = spliceCpfp.replyTo.await()
assertIs<ChannelFundingResponse.Failure.InsufficientFunds>(response)
}
}

@Test
fun `splice to purchase inbound liquidity`() {
val (alice, bob) = reachNormal()
Expand Down

0 comments on commit 66ad261

Please sign in to comment.