Skip to content

Commit e6f9236

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 aa4fc3b commit e6f9236

20 files changed

+645
-230
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).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,
@@ -521,6 +512,7 @@ data class Normal(
521512
Helpers.Funding.makeFundingPubKeyScript(spliceStatus.spliceInit.fundingPubkey, cmd.message.fundingPubkey),
522513
cmd.message.fundingContribution,
523514
spliceStatus.spliceInit.feerate,
515+
cmd.message.feeCreditUsed,
524516
cmd.message.willFund,
525517
)) {
526518
is Either.Left<ChannelException> -> {
@@ -550,6 +542,9 @@ data class Normal(
550542
sharedUtxo = Pair(sharedInput, SharedFundingInputBalances(toLocal = parentCommitment.localCommit.spec.toLocal, toRemote = parentCommitment.localCommit.spec.toRemote, toHtlcs = parentCommitment.localCommit.spec.htlcs.map { it.add.amountMsat }.sum())),
551543
walletInputs = spliceStatus.command.spliceIn?.walletInputs ?: emptyList(),
552544
localOutputs = spliceStatus.command.spliceOutputs,
545+
localPushAmount = spliceStatus.spliceInit.pushAmount,
546+
remotePushAmount = cmd.message.pushAmount,
547+
liquidityPurchase = liquidityPurchase.value,
553548
changePubKey = null // we don't want a change output: we're spending every funds available
554549
)) {
555550
is Either.Left -> {
@@ -854,19 +849,6 @@ data class Normal(
854849
}
855850
}
856851

857-
private fun canAffordSpliceLiquidityFees(splice: ChannelCommand.Commitment.Splice.Request, parentCommitment: Commitment): Boolean {
858-
return when (val request = splice.requestRemoteFunding) {
859-
null -> true
860-
else -> when (request.paymentDetails) {
861-
is LiquidityAds.PaymentDetails.FromChannelBalance -> request.fees(splice.feerate).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi()
862-
is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> request.fees(splice.feerate).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi()
863-
// Fees don't need to be paid during the splice, they will be deducted from relayed HTLCs.
864-
is LiquidityAds.PaymentDetails.FromFutureHtlc -> true
865-
is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> true
866-
}
867-
}
868-
}
869-
870852
private fun ChannelContext.sendSpliceTxSigs(
871853
origins: List<Origin>,
872854
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
@@ -58,13 +58,22 @@ data class WaitForAcceptChannel(
5858
fundingParams.fundingPubkeyScript(channelKeys),
5959
accept.fundingAmount,
6060
lastSent.fundingFeerate,
61+
accept.feeCreditUsed,
6162
accept.willFund
6263
)) {
6364
is Either.Left -> {
6465
logger.error { "rejecting liquidity proposal: ${lease.value.message}" }
6566
Pair(Aborted, listOf(ChannelAction.Message.Send(Error(cmd.message.temporaryChannelId, lease.value.message))))
6667
}
67-
is Either.Right -> when (val fundingContributions = FundingContributions.create(channelKeys, keyManager.swapInOnChainWallet, fundingParams, init.walletInputs)) {
68+
is Either.Right -> when (val fundingContributions = FundingContributions.create(
69+
channelKeys,
70+
keyManager.swapInOnChainWallet,
71+
fundingParams,
72+
init.walletInputs,
73+
lastSent.pushAmount,
74+
accept.pushAmount,
75+
lease.value
76+
)) {
6877
is Either.Left -> {
6978
logger.error { "could not fund channel: ${fundingContributions.value}" }
7079
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)
51+
else -> fundingRates.validateRequest(staticParams.nodeParams.nodePrivateKey, fundingScript, open.fundingFeerate, requestFunding, 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 amount: 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)