Skip to content

Commit

Permalink
Add funding_fee_credit feature
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
t-bast committed Jul 17, 2024
1 parent ab9ef6e commit 850f884
Show file tree
Hide file tree
Showing 20 changed files with 644 additions and 229 deletions.
13 changes: 11 additions & 2 deletions src/commonMain/kotlin/fr/acinq/lightning/Features.kt
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,13 @@ sealed class Feature {
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node)
}

@Serializable
object FundingFeeCredit : Feature() {
override val rfcName get() = "funding_fee_credit"
override val mandatory get() = 562
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node)
}

}

@Serializable
Expand Down Expand Up @@ -345,7 +352,8 @@ data class Features(val activated: Map<Feature, FeatureSupport>, val unknown: Se
Feature.ChannelBackupClient,
Feature.ChannelBackupProvider,
Feature.ExperimentalSplice,
Feature.OnTheFlyFunding
Feature.OnTheFlyFunding,
Feature.FundingFeeCredit
)

operator fun invoke(bytes: ByteVector): Features = invoke(bytes.toByteArray())
Expand Down Expand Up @@ -378,7 +386,8 @@ data class Features(val activated: Map<Feature, FeatureSupport>, val unknown: Se
Feature.AnchorOutputs to listOf(Feature.StaticRemoteKey),
Feature.TrampolinePayment to listOf(Feature.PaymentSecret),
Feature.ExperimentalTrampolinePayment to listOf(Feature.PaymentSecret),
Feature.OnTheFlyFunding to listOf(Feature.ExperimentalSplice)
Feature.OnTheFlyFunding to listOf(Feature.ExperimentalSplice),
Feature.FundingFeeCredit to listOf(Feature.OnTheFlyFunding)
)

class FeatureException(message: String) : IllegalArgumentException(message)
Expand Down
62 changes: 44 additions & 18 deletions src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,20 @@ data class InteractiveTxParams(
val fundingTxIndex = (sharedInput as? SharedFundingInput.Multisig2of2)?.let { it.fundingTxIndex + 1 } ?: 0
return Helpers.Funding.makeFundingPubKeyScript(channelKeys.fundingPubKey(fundingTxIndex), remoteFundingPubkey)
}

fun liquidityFees(purchase: LiquidityAds.Purchase?): MilliSatoshi = purchase?.let { l ->
val fees = when (l) {
is LiquidityAds.Purchase.Standard -> l.fees.total.toMilliSatoshi()
is LiquidityAds.Purchase.WithFeeCredit -> l.fees.total.toMilliSatoshi() - l.feeCreditUsed
}
when (l.paymentDetails) {
is LiquidityAds.PaymentDetails.FromChannelBalance -> if (isInitiator) fees else -fees
is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> if (isInitiator) fees else -fees
// Fees will be paid later, from relayed HTLCs.
is LiquidityAds.PaymentDetails.FromFutureHtlc -> 0.msat
is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> 0.msat
}
} ?: 0.msat
}

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

/** The shared output can be added by us or by our peer, depending on who initiated the protocol. */
/**
* The shared output can be added by us or by our peer, depending on who initiated the protocol.
*
* @param localAmount amount contributed by us, before applying push_amount and (optional) liquidity fees: this is different from the channel balance.
* @param remoteAmount amount contributed by our peer, before applying push_amount and (optional) liquidity fees: this is different from the channel balance.
*/
data class Shared(override val serialId: Long, override val pubkeyScript: ByteVector, val localAmount: MilliSatoshi, val remoteAmount: MilliSatoshi, val htlcAmount: MilliSatoshi) : InteractiveTxOutput(), Incoming, Outgoing {
// Note that the truncation is a no-op: the sum of balances in a channel must be a satoshi amount.
override val amount: Satoshi = (localAmount + remoteAmount + htlcAmount).truncateToSatoshi()
Expand Down Expand Up @@ -246,8 +265,17 @@ data class FundingContributions(val inputs: List<InteractiveTxInput.Outgoing>, v
/**
* @param walletInputs 2-of-2 swap-in wallet inputs.
*/
fun create(channelKeys: KeyManager.ChannelKeys, swapInKeys: KeyManager.SwapInOnChainKeys, params: InteractiveTxParams, walletInputs: List<WalletState.Utxo>): Either<FundingContributionFailure, FundingContributions> =
create(channelKeys, swapInKeys, params, null, walletInputs, listOf())
fun create(
channelKeys: KeyManager.ChannelKeys,
swapInKeys: KeyManager.SwapInOnChainKeys,
params: InteractiveTxParams,
walletInputs: List<WalletState.Utxo>,
localPushAmount: MilliSatoshi,
remotePushAmount: MilliSatoshi,
liquidityPurchase: LiquidityAds.Purchase?
): Either<FundingContributionFailure, FundingContributions> {
return create(channelKeys, swapInKeys, params, null, walletInputs, listOf(), localPushAmount, remotePushAmount, liquidityPurchase)
}

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

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 liquidityFees = params.liquidityFees(liquidityPurchase)
val nextLocalBalanceBeforePush = (sharedUtxo?.second?.toLocal ?: 0.msat) + params.localContribution.toMilliSatoshi()
val nextLocalBalanceAfterPush = (sharedUtxo?.second?.toLocal ?: 0.msat) + params.localContribution.toMilliSatoshi() - localPushAmount + remotePushAmount - liquidityFees
val nextRemoteBalanceBeforePush = (sharedUtxo?.second?.toRemote ?: 0.msat) + params.remoteContribution.toMilliSatoshi()
val nextRemoteBalanceAfterPush = (sharedUtxo?.second?.toRemote ?: 0.msat) + params.remoteContribution.toMilliSatoshi() + localPushAmount - remotePushAmount + liquidityFees
if (nextLocalBalanceAfterPush < 0.msat || nextRemoteBalanceAfterPush < 0.msat) {
return Either.Left(FundingContributionFailure.InvalidFundingBalances(params.fundingAmount, nextLocalBalanceAfterPush, nextRemoteBalanceAfterPush))
}

val fundingPubkeyScript = params.fundingPubkeyScript(channelKeys)
val sharedOutput = listOf(InteractiveTxOutput.Shared(0, fundingPubkeyScript, nextLocalBalance, nextRemoteBalance, sharedUtxo?.second?.toHtlcs ?: 0.msat))
// We use local and remote balances before amounts are pushed to allow computing the local and remote mining fees.
val sharedOutput = listOf(InteractiveTxOutput.Shared(0, fundingPubkeyScript, nextLocalBalanceBeforePush, nextRemoteBalanceBeforePush, sharedUtxo?.second?.toHtlcs ?: 0.msat))
val nonChangeOutputs = localOutputs.map { o -> InteractiveTxOutput.Local.NonChange(0, o.amount, o.publicKeyScript) }
val changeOutput = when (changePubKey) {
null -> listOf()
Expand Down Expand Up @@ -1068,16 +1103,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 = liquidityPurchase?.let { l ->
val fees = l.fees.total.toMilliSatoshi()
when (l.paymentDetails) {
is LiquidityAds.PaymentDetails.FromChannelBalance -> if (fundingParams.isInitiator) fees else -fees
is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> if (fundingParams.isInitiator) fees else -fees
// Fees will be paid later, from relayed HTLCs.
is LiquidityAds.PaymentDetails.FromFutureHtlc -> 0.msat
is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> 0.msat
}
} ?: 0.msat
val liquidityFees = fundingParams.liquidityFees(liquidityPurchase)
return Helpers.Funding.makeCommitTxs(
channelKeys,
channelParams.channelId,
Expand Down
26 changes: 4 additions & 22 deletions src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt
Original file line number Diff line number Diff line change
Expand Up @@ -405,15 +405,6 @@ data class Normal(
add(ChannelAction.Disconnect)
}
Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions)
} else if (!canAffordSpliceLiquidityFees(spliceStatus.command, parentCommitment)) {
val missing = spliceStatus.command.requestRemoteFunding?.let { r -> r.fees(spliceStatus.command.feerate).total - parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() }
logger.warning { "cannot do splice: balance is too low to pay for inbound liquidity (missing=$missing)" }
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)
}
Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions)
} else {
val spliceInit = SpliceInit(
channelId,
Expand Down Expand Up @@ -521,6 +512,7 @@ data class Normal(
Helpers.Funding.makeFundingPubKeyScript(spliceStatus.spliceInit.fundingPubkey, cmd.message.fundingPubkey),
cmd.message.fundingContribution,
spliceStatus.spliceInit.feerate,
cmd.message.feeCreditUsed,
cmd.message.willFund,
)) {
is Either.Left<ChannelException> -> {
Expand Down Expand Up @@ -550,6 +542,9 @@ data class Normal(
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,
localPushAmount = spliceStatus.spliceInit.pushAmount,
remotePushAmount = cmd.message.pushAmount,
liquidityPurchase = liquidityPurchase.value,
changePubKey = null // we don't want a change output: we're spending every funds available
)) {
is Either.Left -> {
Expand Down Expand Up @@ -854,19 +849,6 @@ data class Normal(
}
}

private fun canAffordSpliceLiquidityFees(splice: ChannelCommand.Commitment.Splice.Request, parentCommitment: Commitment): Boolean {
return when (val request = splice.requestRemoteFunding) {
null -> true
else -> when (request.paymentDetails) {
is LiquidityAds.PaymentDetails.FromChannelBalance -> request.fees(splice.feerate).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi()
is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> request.fees(splice.feerate).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi()
// Fees don't need to be paid during the splice, they will be deducted from relayed HTLCs.
is LiquidityAds.PaymentDetails.FromFutureHtlc -> true
is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> true
}
}
}

private fun ChannelContext.sendSpliceTxSigs(
origins: List<Origin>,
action: InteractiveTxSigningSessionAction.SendTxSigs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,22 @@ data class WaitForAcceptChannel(
fundingParams.fundingPubkeyScript(channelKeys),
accept.fundingAmount,
lastSent.fundingFeerate,
accept.feeCreditUsed,
accept.willFund
)) {
is Either.Left -> {
logger.error { "rejecting liquidity proposal: ${liquidityPurchase.value.message}" }
Pair(Aborted, listOf(ChannelAction.Message.Send(Error(cmd.message.temporaryChannelId, liquidityPurchase.value.message))))
}
is Either.Right -> when (val fundingContributions = FundingContributions.create(channelKeys, keyManager.swapInOnChainWallet, fundingParams, init.walletInputs)) {
is Either.Right -> when (val fundingContributions = FundingContributions.create(
channelKeys,
keyManager.swapInOnChainWallet,
fundingParams,
init.walletInputs,
lastSent.pushAmount,
accept.pushAmount,
liquidityPurchase.value
)) {
is Either.Left -> {
logger.error { "could not fund channel: ${fundingContributions.value}" }
Pair(Aborted, listOf(ChannelAction.Message.Send(Error(channelId, ChannelFundingError(channelId).message))))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ data class WaitForFundingConfirmed(
latestFundingTx.fundingParams.dustLimit,
rbfStatus.command.targetFeerate
)
when (val contributions = FundingContributions.create(channelKeys(), keyManager.swapInOnChainWallet, fundingParams, rbfStatus.command.walletInputs)) {
when (val contributions = FundingContributions.create(channelKeys(), keyManager.swapInOnChainWallet, fundingParams, rbfStatus.command.walletInputs, 0.msat, 0.msat, null)) {
is Either.Left -> {
logger.warning { "error creating funding contributions: ${contributions.value}" }
Pair(this@WaitForFundingConfirmed.copy(rbfStatus = RbfStatus.RbfAborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, ChannelFundingError(channelId).message))))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ data class WaitForOpenChannel(
fundingRates == null -> null
requestFunding == null -> null
requestFunding.requestedAmount > fundingAmount -> null
else -> fundingRates.validateRequest(staticParams.nodeParams.nodePrivateKey, fundingScript, open.fundingFeerate, requestFunding)
else -> fundingRates.validateRequest(staticParams.nodeParams.nodePrivateKey, fundingScript, open.fundingFeerate, requestFunding, 0.msat)
}
val accept = AcceptDualFundedChannel(
temporaryChannelId = open.temporaryChannelId,
Expand Down Expand Up @@ -91,7 +91,7 @@ data class WaitForOpenChannel(
val remoteFundingPubkey = open.fundingPubkey
val dustLimit = open.dustLimit.max(localParams.dustLimit)
val fundingParams = InteractiveTxParams(channelId, false, fundingAmount, open.fundingAmount, remoteFundingPubkey, open.lockTime, dustLimit, open.fundingFeerate)
when (val fundingContributions = FundingContributions.create(channelKeys, keyManager.swapInOnChainWallet, fundingParams, walletInputs)) {
when (val fundingContributions = FundingContributions.create(channelKeys, keyManager.swapInOnChainWallet, fundingParams, walletInputs, accept.pushAmount, open.pushAmount, null)) {
is Either.Left -> {
logger.error { "could not fund channel: ${fundingContributions.value}" }
Pair(Aborted, listOf(ChannelAction.Message.Send(Error(temporaryChannelId, ChannelFundingError(temporaryChannelId).message))))
Expand Down
9 changes: 9 additions & 0 deletions src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,15 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r
override val fees: MilliSatoshi = fundingFee?.amount ?: 0.msat
}

/**
* Payment was added to our fee credit for future on-chain operations (see [Feature.FundingFeeCredit]).
* We didn't really receive this amount yet, but we trust our peer to use it for future on-chain operations.
*/
data class AddedToFeeCredit(override val amount: MilliSatoshi) : ReceivedWith() {
// Adding to the fee credit doesn't cost any fees.
override val fees: MilliSatoshi = 0.msat
}

sealed class OnChainIncomingPayment : ReceivedWith() {
abstract val serviceFee: MilliSatoshi
abstract val miningFee: Satoshi
Expand Down
Loading

0 comments on commit 850f884

Please sign in to comment.