Skip to content

Commit aab1d7d

Browse files
committed
Add funding_fee_credit feature
We add an optional feature that lets on-the-fly funding clients accept payments that are too small to pay the fees for an on-the-fly funding. When that happens, the payment amount is added as "fee credit" without performing an on-chain operation. Once enough fee credit has been obtained, we can initiate an on-chain operation to create a channel or a splice by paying part of the fees from our fee credit. This feature makes more efficient use of on-chain transactions by trusting that our peer will honor our fee credit in the future. The fee credit takes precedence over other ways of paying the fees (from our channel balance or future HTLCs), which guarantees that the fee credit eventually converges to 0.
1 parent 85f2de9 commit aab1d7d

20 files changed

+718
-243
lines changed

src/commonMain/kotlin/fr/acinq/lightning/Features.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,13 @@ sealed class Feature {
263263
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node)
264264
}
265265

266+
@Serializable
267+
object FundingFeeCredit : Feature() {
268+
override val rfcName get() = "funding_fee_credit"
269+
override val mandatory get() = 562
270+
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node)
271+
}
272+
266273
}
267274

268275
@Serializable
@@ -345,7 +352,8 @@ data class Features(val activated: Map<Feature, FeatureSupport>, val unknown: Se
345352
Feature.ChannelBackupClient,
346353
Feature.ChannelBackupProvider,
347354
Feature.ExperimentalSplice,
348-
Feature.OnTheFlyFunding
355+
Feature.OnTheFlyFunding,
356+
Feature.FundingFeeCredit
349357
)
350358

351359
operator fun invoke(bytes: ByteVector): Features = invoke(bytes.toByteArray())
@@ -378,7 +386,8 @@ data class Features(val activated: Map<Feature, FeatureSupport>, val unknown: Se
378386
Feature.AnchorOutputs to listOf(Feature.StaticRemoteKey),
379387
Feature.TrampolinePayment to listOf(Feature.PaymentSecret),
380388
Feature.ExperimentalTrampolinePayment to listOf(Feature.PaymentSecret),
381-
Feature.OnTheFlyFunding to listOf(Feature.ExperimentalSplice)
389+
Feature.OnTheFlyFunding to listOf(Feature.ExperimentalSplice),
390+
Feature.FundingFeeCredit to listOf(Feature.OnTheFlyFunding)
382391
)
383392

384393
class FeatureException(message: String) : IllegalArgumentException(message)

src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,20 @@ data class InteractiveTxParams(
9898
val fundingTxIndex = (sharedInput as? SharedFundingInput.Multisig2of2)?.let { it.fundingTxIndex + 1 } ?: 0
9999
return Helpers.Funding.makeFundingPubKeyScript(channelKeys.fundingPubKey(fundingTxIndex), remoteFundingPubkey)
100100
}
101+
102+
fun liquidityFees(purchase: LiquidityAds.Purchase?): MilliSatoshi = purchase?.let { l ->
103+
val fees = when (l) {
104+
is LiquidityAds.Purchase.Standard -> l.fees.total.toMilliSatoshi()
105+
is LiquidityAds.Purchase.WithFeeCredit -> l.fees.total.toMilliSatoshi() - l.feeCreditUsed
106+
}
107+
when (l.paymentDetails) {
108+
is LiquidityAds.PaymentDetails.FromChannelBalance -> if (isInitiator) fees else -fees
109+
is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> if (isInitiator) fees else -fees
110+
// Fees will be paid later, from relayed HTLCs.
111+
is LiquidityAds.PaymentDetails.FromFutureHtlc -> 0.msat
112+
is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> 0.msat
113+
}
114+
} ?: 0.msat
101115
}
102116

103117
sealed class InteractiveTxInput {
@@ -209,7 +223,12 @@ sealed class InteractiveTxOutput {
209223
*/
210224
data class Remote(override val serialId: Long, override val amount: Satoshi, override val pubkeyScript: ByteVector) : InteractiveTxOutput(), Incoming
211225

212-
/** The shared output can be added by us or by our peer, depending on who initiated the protocol. */
226+
/**
227+
* The shared output can be added by us or by our peer, depending on who initiated the protocol.
228+
*
229+
* @param localAmount amount contributed by us, before applying push_amount and (optional) liquidity fees: this is different from the channel balance.
230+
* @param remoteAmount amount contributed by our peer, before applying push_amount and (optional) liquidity fees: this is different from the channel balance.
231+
*/
213232
data class Shared(override val serialId: Long, override val pubkeyScript: ByteVector, val localAmount: MilliSatoshi, val remoteAmount: MilliSatoshi, val htlcAmount: MilliSatoshi) : InteractiveTxOutput(), Incoming, Outgoing {
214233
// Note that the truncation is a no-op: the sum of balances in a channel must be a satoshi amount.
215234
override val amount: Satoshi = (localAmount + remoteAmount + htlcAmount).truncateToSatoshi()
@@ -246,8 +265,17 @@ data class FundingContributions(val inputs: List<InteractiveTxInput.Outgoing>, v
246265
/**
247266
* @param walletInputs 2-of-2 swap-in wallet inputs.
248267
*/
249-
fun create(channelKeys: KeyManager.ChannelKeys, swapInKeys: KeyManager.SwapInOnChainKeys, params: InteractiveTxParams, walletInputs: List<WalletState.Utxo>): Either<FundingContributionFailure, FundingContributions> =
250-
create(channelKeys, swapInKeys, params, null, walletInputs, listOf())
268+
fun create(
269+
channelKeys: KeyManager.ChannelKeys,
270+
swapInKeys: KeyManager.SwapInOnChainKeys,
271+
params: InteractiveTxParams,
272+
walletInputs: List<WalletState.Utxo>,
273+
localPushAmount: MilliSatoshi,
274+
remotePushAmount: MilliSatoshi,
275+
liquidityPurchase: LiquidityAds.Purchase?
276+
): Either<FundingContributionFailure, FundingContributions> {
277+
return create(channelKeys, swapInKeys, params, null, walletInputs, listOf(), localPushAmount, remotePushAmount, liquidityPurchase)
278+
}
251279

252280
/**
253281
* @param sharedUtxo previous input shared between the two participants (e.g. previous funding output when splicing) and our corresponding balance.
@@ -262,6 +290,9 @@ data class FundingContributions(val inputs: List<InteractiveTxInput.Outgoing>, v
262290
sharedUtxo: Pair<SharedFundingInput, SharedFundingInputBalances>?,
263291
walletInputs: List<WalletState.Utxo>,
264292
localOutputs: List<TxOut>,
293+
localPushAmount: MilliSatoshi,
294+
remotePushAmount: MilliSatoshi,
295+
liquidityPurchase: LiquidityAds.Purchase?,
265296
changePubKey: PublicKey? = null
266297
): Either<FundingContributionFailure, FundingContributions> {
267298
walletInputs.forEach { utxo ->
@@ -277,14 +308,18 @@ data class FundingContributions(val inputs: List<InteractiveTxInput.Outgoing>, v
277308
return Either.Left(FundingContributionFailure.NotEnoughFunding(params.localContribution, localOutputs.map { it.amount }.sum(), totalAmountIn))
278309
}
279310

280-
val nextLocalBalance = (sharedUtxo?.second?.toLocal ?: 0.msat) + params.localContribution.toMilliSatoshi()
281-
val nextRemoteBalance = (sharedUtxo?.second?.toRemote ?: 0.msat) + params.remoteContribution.toMilliSatoshi()
282-
if (nextLocalBalance < 0.msat || nextRemoteBalance < 0.msat) {
283-
return Either.Left(FundingContributionFailure.InvalidFundingBalances(params.fundingAmount, nextLocalBalance, nextRemoteBalance))
311+
val liquidityFees = params.liquidityFees(liquidityPurchase)
312+
val nextLocalBalanceBeforePush = (sharedUtxo?.second?.toLocal ?: 0.msat) + params.localContribution.toMilliSatoshi()
313+
val nextLocalBalanceAfterPush = (sharedUtxo?.second?.toLocal ?: 0.msat) + params.localContribution.toMilliSatoshi() - localPushAmount + remotePushAmount - liquidityFees
314+
val nextRemoteBalanceBeforePush = (sharedUtxo?.second?.toRemote ?: 0.msat) + params.remoteContribution.toMilliSatoshi()
315+
val nextRemoteBalanceAfterPush = (sharedUtxo?.second?.toRemote ?: 0.msat) + params.remoteContribution.toMilliSatoshi() + localPushAmount - remotePushAmount + liquidityFees
316+
if (nextLocalBalanceAfterPush < 0.msat || nextRemoteBalanceAfterPush < 0.msat) {
317+
return Either.Left(FundingContributionFailure.InvalidFundingBalances(params.fundingAmount, nextLocalBalanceAfterPush, nextRemoteBalanceAfterPush))
284318
}
285319

286320
val fundingPubkeyScript = params.fundingPubkeyScript(channelKeys)
287-
val sharedOutput = listOf(InteractiveTxOutput.Shared(0, fundingPubkeyScript, nextLocalBalance, nextRemoteBalance, sharedUtxo?.second?.toHtlcs ?: 0.msat))
321+
// We use local and remote balances before amounts are pushed to allow computing the local and remote mining fees.
322+
val sharedOutput = listOf(InteractiveTxOutput.Shared(0, fundingPubkeyScript, nextLocalBalanceBeforePush, nextRemoteBalanceBeforePush, sharedUtxo?.second?.toHtlcs ?: 0.msat))
288323
val nonChangeOutputs = localOutputs.map { o -> InteractiveTxOutput.Local.NonChange(0, o.amount, o.publicKeyScript) }
289324
val changeOutput = when (changePubKey) {
290325
null -> listOf()
@@ -1068,16 +1103,7 @@ data class InteractiveTxSigningSession(
10681103
val channelKeys = channelParams.localParams.channelKeys(keyManager)
10691104
val unsignedTx = sharedTx.buildUnsignedTx()
10701105
val sharedOutputIndex = unsignedTx.txOut.indexOfFirst { it.publicKeyScript == fundingParams.fundingPubkeyScript(channelKeys) }
1071-
val liquidityFees = liquidityPurchase?.let { l ->
1072-
val fees = l.fees.total.toMilliSatoshi()
1073-
when (l.paymentDetails) {
1074-
is LiquidityAds.PaymentDetails.FromChannelBalance -> if (fundingParams.isInitiator) fees else -fees
1075-
is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> if (fundingParams.isInitiator) fees else -fees
1076-
// Fees will be paid later, from relayed HTLCs.
1077-
is LiquidityAds.PaymentDetails.FromFutureHtlc -> 0.msat
1078-
is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> 0.msat
1079-
}
1080-
} ?: 0.msat
1106+
val liquidityFees = fundingParams.liquidityFees(liquidityPurchase)
10811107
return Helpers.Funding.makeCommitTxs(
10821108
channelKeys,
10831109
channelParams.channelId,

src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -405,15 +405,6 @@ data class Normal(
405405
add(ChannelAction.Disconnect)
406406
}
407407
Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions)
408-
} else if (!canAffordSpliceLiquidityFees(spliceStatus.command, parentCommitment)) {
409-
val missing = spliceStatus.command.requestRemoteFunding?.let { r -> r.fees(spliceStatus.command.feerate, isChannelCreation = false).total - parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() }
410-
logger.warning { "cannot do splice: balance is too low to pay for inbound liquidity (missing=$missing)" }
411-
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds)
412-
val actions = buildList {
413-
add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceRequest(channelId).message)))
414-
add(ChannelAction.Disconnect)
415-
}
416-
Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions)
417408
} else {
418409
val spliceInit = SpliceInit(
419410
channelId,
@@ -522,6 +513,7 @@ data class Normal(
522513
cmd.message.fundingContribution,
523514
spliceStatus.spliceInit.feerate,
524515
isChannelCreation = false,
516+
cmd.message.feeCreditUsed,
525517
cmd.message.willFund,
526518
)) {
527519
is Either.Left<ChannelException> -> {
@@ -551,6 +543,9 @@ data class Normal(
551543
sharedUtxo = Pair(sharedInput, SharedFundingInputBalances(toLocal = parentCommitment.localCommit.spec.toLocal, toRemote = parentCommitment.localCommit.spec.toRemote, toHtlcs = parentCommitment.localCommit.spec.htlcs.map { it.add.amountMsat }.sum())),
552544
walletInputs = spliceStatus.command.spliceIn?.walletInputs ?: emptyList(),
553545
localOutputs = spliceStatus.command.spliceOutputs,
546+
localPushAmount = spliceStatus.spliceInit.pushAmount,
547+
remotePushAmount = cmd.message.pushAmount,
548+
liquidityPurchase = liquidityPurchase.value,
554549
changePubKey = null // we don't want a change output: we're spending every funds available
555550
)) {
556551
is Either.Left -> {
@@ -855,19 +850,6 @@ data class Normal(
855850
}
856851
}
857852

858-
private fun canAffordSpliceLiquidityFees(splice: ChannelCommand.Commitment.Splice.Request, parentCommitment: Commitment): Boolean {
859-
return when (val request = splice.requestRemoteFunding) {
860-
null -> true
861-
else -> when (request.paymentDetails) {
862-
is LiquidityAds.PaymentDetails.FromChannelBalance -> request.fees(splice.feerate, isChannelCreation = false).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi()
863-
is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> request.fees(splice.feerate, isChannelCreation = false).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi()
864-
// Fees don't need to be paid during the splice, they will be deducted from relayed HTLCs.
865-
is LiquidityAds.PaymentDetails.FromFutureHtlc -> true
866-
is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> true
867-
}
868-
}
869-
}
870-
871853
private fun ChannelContext.sendSpliceTxSigs(
872854
origins: List<Origin>,
873855
action: InteractiveTxSigningSessionAction.SendTxSigs,

src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,22 @@ data class WaitForAcceptChannel(
5959
accept.fundingAmount,
6060
lastSent.fundingFeerate,
6161
isChannelCreation = true,
62+
accept.feeCreditUsed,
6263
accept.willFund
6364
)) {
6465
is Either.Left -> {
6566
logger.error { "rejecting liquidity proposal: ${liquidityPurchase.value.message}" }
6667
Pair(Aborted, listOf(ChannelAction.Message.Send(Error(cmd.message.temporaryChannelId, liquidityPurchase.value.message))))
6768
}
68-
is Either.Right -> when (val fundingContributions = FundingContributions.create(channelKeys, keyManager.swapInOnChainWallet, fundingParams, init.walletInputs)) {
69+
is Either.Right -> when (val fundingContributions = FundingContributions.create(
70+
channelKeys,
71+
keyManager.swapInOnChainWallet,
72+
fundingParams,
73+
init.walletInputs,
74+
lastSent.pushAmount,
75+
accept.pushAmount,
76+
liquidityPurchase.value
77+
)) {
6978
is Either.Left -> {
7079
logger.error { "could not fund channel: ${fundingContributions.value}" }
7180
Pair(Aborted, listOf(ChannelAction.Message.Send(Error(channelId, ChannelFundingError(channelId).message))))

src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ data class WaitForFundingConfirmed(
136136
latestFundingTx.fundingParams.dustLimit,
137137
rbfStatus.command.targetFeerate
138138
)
139-
when (val contributions = FundingContributions.create(channelKeys(), keyManager.swapInOnChainWallet, fundingParams, rbfStatus.command.walletInputs)) {
139+
when (val contributions = FundingContributions.create(channelKeys(), keyManager.swapInOnChainWallet, fundingParams, rbfStatus.command.walletInputs, 0.msat, 0.msat, null)) {
140140
is Either.Left -> {
141141
logger.warning { "error creating funding contributions: ${contributions.value}" }
142142
Pair(this@WaitForFundingConfirmed.copy(rbfStatus = RbfStatus.RbfAborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, ChannelFundingError(channelId).message))))

src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannel.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ data class WaitForOpenChannel(
4848
fundingRates == null -> null
4949
requestFunding == null -> null
5050
requestFunding.requestedAmount > fundingAmount -> null
51-
else -> fundingRates.validateRequest(staticParams.nodeParams.nodePrivateKey, fundingScript, open.fundingFeerate, requestFunding, isChannelCreation = true)
51+
else -> fundingRates.validateRequest(staticParams.nodeParams.nodePrivateKey, fundingScript, open.fundingFeerate, requestFunding, isChannelCreation = true, 0.msat)
5252
}
5353
val accept = AcceptDualFundedChannel(
5454
temporaryChannelId = open.temporaryChannelId,
@@ -91,7 +91,7 @@ data class WaitForOpenChannel(
9191
val remoteFundingPubkey = open.fundingPubkey
9292
val dustLimit = open.dustLimit.max(localParams.dustLimit)
9393
val fundingParams = InteractiveTxParams(channelId, false, fundingAmount, open.fundingAmount, remoteFundingPubkey, open.lockTime, dustLimit, open.fundingFeerate)
94-
when (val fundingContributions = FundingContributions.create(channelKeys, keyManager.swapInOnChainWallet, fundingParams, walletInputs)) {
94+
when (val fundingContributions = FundingContributions.create(channelKeys, keyManager.swapInOnChainWallet, fundingParams, walletInputs, accept.pushAmount, open.pushAmount, null)) {
9595
is Either.Left -> {
9696
logger.error { "could not fund channel: ${fundingContributions.value}" }
9797
Pair(Aborted, listOf(ChannelAction.Message.Send(Error(temporaryChannelId, ChannelFundingError(temporaryChannelId).message))))

src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,15 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r
175175
override val fees: MilliSatoshi = fundingFee?.amount ?: 0.msat
176176
}
177177

178+
/**
179+
* Payment was added to our fee credit for future on-chain operations (see [Feature.FundingFeeCredit]).
180+
* We didn't really receive this amount yet, but we trust our peer to use it for future on-chain operations.
181+
*/
182+
data class AddedToFeeCredit(override val amountReceived: MilliSatoshi) : ReceivedWith() {
183+
// Adding to the fee credit doesn't cost any fees.
184+
override val fees: MilliSatoshi = 0.msat
185+
}
186+
178187
sealed class OnChainIncomingPayment : ReceivedWith() {
179188
abstract val serviceFee: MilliSatoshi
180189
abstract val miningFee: Satoshi

0 commit comments

Comments
 (0)