Skip to content

Commit 46ef5c0

Browse files
committed
Add channelCreationFee to liquidity ads
Creating a new channel has an additional cost compared to adding liquidity to an existing channel: the channel will be closed in the future, which will require paying on-chain fees. Node operators can include a `channel-creation-fee-satoshis` in their liquidity ads to cover some of that future cost.
1 parent 5b9b4f5 commit 46ef5c0

11 files changed

Lines changed: 70 additions & 59 deletions

File tree

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ data class Normal(
406406
}
407407
Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions)
408408
} 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() }
409+
val missing = spliceStatus.command.requestRemoteFunding?.let { r -> r.fees(spliceStatus.command.feerate, isChannelCreation = false).total - parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() }
410410
logger.warning { "cannot do splice: balance is too low to pay for inbound liquidity (missing=$missing)" }
411411
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds)
412412
val actions = buildList {
@@ -521,6 +521,7 @@ data class Normal(
521521
Helpers.Funding.makeFundingPubKeyScript(spliceStatus.spliceInit.fundingPubkey, cmd.message.fundingPubkey),
522522
cmd.message.fundingContribution,
523523
spliceStatus.spliceInit.feerate,
524+
isChannelCreation = false,
524525
cmd.message.willFund,
525526
)) {
526527
is Either.Left<ChannelException> -> {
@@ -858,8 +859,8 @@ data class Normal(
858859
return when (val request = splice.requestRemoteFunding) {
859860
null -> true
860861
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()
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()
863864
// Fees don't need to be paid during the splice, they will be deducted from relayed HTLCs.
864865
is LiquidityAds.PaymentDetails.FromFutureHtlc -> true
865866
is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> true

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ data class WaitForAcceptChannel(
5858
fundingParams.fundingPubkeyScript(channelKeys),
5959
accept.fundingAmount,
6060
lastSent.fundingFeerate,
61+
isChannelCreation = true,
6162
accept.willFund
6263
)) {
6364
is Either.Left -> {

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

Lines changed: 1 addition & 1 deletion
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, isChannelCreation = true)
5252
}
5353
val accept = AcceptDualFundedChannel(
5454
temporaryChannelId = open.temporaryChannelId,

src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ data class AddWalletInputsToChannel(val walletInputs: List<WalletState.Utxo>) :
6565
data class AddLiquidityForIncomingPayment(val paymentAmount: MilliSatoshi, val requestedAmount: Satoshi, val fundingRate: LiquidityAds.FundingRate, val preimage: ByteVector32, val willAddHtlcs: List<WillAddHtlc>) : PeerCommand() {
6666
val paymentHash: ByteVector32 = Crypto.sha256(preimage.toByteArray()).byteVector32()
6767

68-
fun fees(fundingFeerate: FeeratePerKw): LiquidityAds.Fees = fundingRate.fees(fundingFeerate, requestedAmount, requestedAmount)
68+
fun fees(fundingFeerate: FeeratePerKw, isChannelCreation: Boolean): LiquidityAds.Fees = fundingRate.fees(fundingFeerate, requestedAmount, requestedAmount, isChannelCreation)
6969
}
7070

7171
data class PeerConnection(val id: Long, val output: Channel<LightningMessage>, val logger: MDCLogger) {
@@ -593,7 +593,7 @@ class Peer(
593593
// The mining fee below pays for the entirety of the splice transaction, including inputs and outputs from the liquidity provider.
594594
val (actualFeerate, miningFee) = client.computeSpliceCpfpFeerate(channel.commitments, targetFeerate, spliceWeight = weight, logger)
595595
// The mining fee below only covers the remote node's inputs and outputs, which are already included in the mining fee above.
596-
val fundingFees = fundingRate.fees(actualFeerate, amount, amount)
596+
val fundingFees = fundingRate.fees(actualFeerate, amount, amount, isChannelCreation = false)
597597
Pair(actualFeerate, ChannelManagementFees(miningFee, fundingFees.serviceFee))
598598
}
599599
}
@@ -1287,7 +1287,7 @@ class Peer(
12871287
val dummyFundingScript = Script.write(Scripts.multiSig2of2(Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey)).byteVector()
12881288
val localMiningFee = Transactions.weight2fee(currentFeerates.fundingFeerate, FundingContributions.computeWeightPaid(isInitiator = true, null, dummyFundingScript, cmd.walletInputs, emptyList()))
12891289
val localFundingAmount = cmd.totalAmount - localMiningFee
1290-
val fundingFees = requestRemoteFunding.fees(currentFeerates.fundingFeerate)
1290+
val fundingFees = requestRemoteFunding.fees(currentFeerates.fundingFeerate, isChannelCreation = true)
12911291
// We also refund the liquidity provider for some of the on-chain fees they will pay for their inputs/outputs of the transaction.
12921292
// This will be taken from our channel balance during the interactive-tx construction, they shouldn't be deducted from our funding amount.
12931293
val totalFees = ChannelManagementFees(miningFee = localMiningFee + fundingFees.miningFee, serviceFee = fundingFees.serviceFee)
@@ -1343,7 +1343,7 @@ class Peer(
13431343
val spliceWeight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = available.channel.commitments.active.first(), walletInputs = listOf(), localOutputs = listOf())
13441344
val (fundingFeerate, localMiningFee) = client.computeSpliceCpfpFeerate(available.channel.commitments, currentFeerates.fundingFeerate, spliceWeight, logger)
13451345
val (targetFeerate, paymentDetails) = when {
1346-
localBalance >= localMiningFee + cmd.fees(fundingFeerate).total -> {
1346+
localBalance >= localMiningFee + cmd.fees(fundingFeerate, isChannelCreation = false).total -> {
13471347
// We have enough funds to pay the mining fee and the lease fees.
13481348
// This the ideal scenario because the fees can be paid immediately with the splice transaction.
13491349
Pair(fundingFeerate, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(listOf(cmd.paymentHash)))
@@ -1376,7 +1376,7 @@ class Peer(
13761376
logger.warning { "cannot request on-the-fly splice: payment types not supported (${walletParams.remoteFundingRates.paymentTypes.joinToString()})" }
13771377
}
13781378
else -> {
1379-
val leaseFees = cmd.fees(targetFeerate)
1379+
val leaseFees = cmd.fees(targetFeerate, isChannelCreation = false)
13801380
val totalFees = ChannelManagementFees(miningFee = localMiningFee.min(localBalance.truncateToSatoshi()) + leaseFees.miningFee, serviceFee = leaseFees.serviceFee)
13811381
logger.info { "requesting on-the-fly splice for paymentHash=${cmd.paymentHash} feerate=$targetFeerate fee=${totalFees.total} paymentType=${paymentDetails.paymentType}" }
13821382
val spliceCommand = ChannelCommand.Commitment.Splice.Request(
@@ -1402,7 +1402,7 @@ class Peer(
14021402
// We only need to cover the shared output, which doesn't add too much weight, so we add 25%.
14031403
val fundingFeerate = currentFeerates.fundingFeerate * 1.25
14041404
// We don't pay any local on-chain fees, our fee is only for the liquidity lease.
1405-
val leaseFees = cmd.fees(fundingFeerate)
1405+
val leaseFees = cmd.fees(fundingFeerate, isChannelCreation = true)
14061406
val totalFees = ChannelManagementFees(miningFee = leaseFees.miningFee, serviceFee = leaseFees.serviceFee)
14071407
// We cannot pay the liquidity fees from our channel balance, so we fall back to future HTLCs.
14081408
val paymentDetails = when {

src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,9 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri
305305
when (val fundingRate = remoteFundingRates.findRate(requestedAmount)) {
306306
null -> Either.Left(LiquidityEvents.Rejected(requestedAmount.toMilliSatoshi(), 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.NoMatchingFundingRate))
307307
else -> {
308-
val fees = fundingRate.fees(currentFeerate, requestedAmount, requestedAmount).total
308+
// We don't know at that point if we'll need a channel or if we already have one.
309+
// We must use the worst case fees that applies to channel creation.
310+
val fees = fundingRate.fees(currentFeerate, requestedAmount, requestedAmount, isChannelCreation = true).total
309311
val rejected = when {
310312
// We only initiate on-the-fly funding if the missing amount is greater than the fees paid.
311313
// Otherwise our peer may not be able to claim the funding fees from the relayed HTLCs.

src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,16 @@ object LiquidityAds {
4343
* The buyer refunds those on-chain fees for the given vbytes.
4444
* @param feeProportional proportional fee (expressed in basis points) based on the amount contributed by the seller.
4545
* @param feeBase flat fee that must be paid regardless of the amount contributed by the seller.
46+
* @param channelCreationFee flat fee that must be paid when a new channel is created.
4647
*/
47-
data class FundingRate(val minAmount: Satoshi, val maxAmount: Satoshi, val fundingWeight: Int, val feeProportional: Int, val feeBase: Satoshi) {
48+
data class FundingRate(val minAmount: Satoshi, val maxAmount: Satoshi, val fundingWeight: Int, val feeProportional: Int, val feeBase: Satoshi, val channelCreationFee: Satoshi) {
4849
/** Fees paid by the liquidity buyer. */
49-
fun fees(feerate: FeeratePerKw, requestedAmount: Satoshi, contributedAmount: Satoshi): Fees {
50+
fun fees(feerate: FeeratePerKw, requestedAmount: Satoshi, contributedAmount: Satoshi, isChannelCreation: Boolean): Fees {
5051
val onChainFees = Transactions.weight2fee(feerate, fundingWeight)
5152
// If the seller adds more liquidity than requested, the buyer doesn't pay for that extra liquidity.
5253
val proportionalFee = requestedAmount.min(contributedAmount) * feeProportional / 10_000
53-
return Fees(onChainFees, feeBase + proportionalFee)
54+
val flatFee = if (isChannelCreation) channelCreationFee + feeBase else feeBase
55+
return Fees(onChainFees, flatFee + proportionalFee)
5456
}
5557

5658
/** When liquidity is purchased, the seller provides a signature of the funding rate and funding script. */
@@ -68,6 +70,7 @@ object LiquidityAds {
6870
LightningCodecs.writeU16(fundingWeight, out)
6971
LightningCodecs.writeU16(feeProportional, out)
7072
LightningCodecs.writeU32(feeBase.sat.toInt(), out)
73+
LightningCodecs.writeU32(channelCreationFee.sat.toInt(), out)
7174
}
7275

7376
companion object {
@@ -77,6 +80,7 @@ object LiquidityAds {
7780
fundingWeight = LightningCodecs.u16(input),
7881
feeProportional = LightningCodecs.u16(input),
7982
feeBase = LightningCodecs.u32(input).sat,
83+
channelCreationFee = LightningCodecs.u32(input).sat,
8084
)
8185
}
8286
}
@@ -186,14 +190,14 @@ object LiquidityAds {
186190

187191
/** Sellers offer various rates and payment options. */
188192
data class WillFundRates(val fundingRates: List<FundingRate>, val paymentTypes: Set<PaymentType>) {
189-
fun validateRequest(nodeKey: PrivateKey, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding): WillFundPurchase? {
193+
fun validateRequest(nodeKey: PrivateKey, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding, isChannelCreation: Boolean): WillFundPurchase? {
190194
val paymentTypeOk = paymentTypes.contains(request.paymentDetails.paymentType)
191195
val rateOk = fundingRates.contains(request.fundingRate)
192196
val amountOk = request.fundingRate.minAmount <= request.requestedAmount && request.requestedAmount <= request.fundingRate.maxAmount
193197
return when {
194198
paymentTypeOk && rateOk && amountOk -> {
195199
val sig = Crypto.sign(request.fundingRate.signedData(fundingScript), nodeKey)
196-
val purchase = Purchase.Standard(request.requestedAmount, request.fees(fundingFeerate), request.paymentDetails)
200+
val purchase = Purchase.Standard(request.requestedAmount, request.fees(fundingFeerate, isChannelCreation), request.paymentDetails)
197201
WillFundPurchase(WillFund(request.fundingRate, fundingScript, sig), purchase)
198202
}
199203
else -> null
@@ -242,14 +246,15 @@ object LiquidityAds {
242246

243247
/** Request inbound liquidity from a remote peer that supports liquidity ads. */
244248
data class RequestFunding(val requestedAmount: Satoshi, val fundingRate: FundingRate, val paymentDetails: PaymentDetails) {
245-
fun fees(feerate: FeeratePerKw): Fees = fundingRate.fees(feerate, requestedAmount, requestedAmount)
249+
fun fees(feerate: FeeratePerKw, isChannelCreation: Boolean): Fees = fundingRate.fees(feerate, requestedAmount, requestedAmount, isChannelCreation)
246250

247251
fun validateRemoteFunding(
248252
remoteNodeId: PublicKey,
249253
channelId: ByteVector32,
250254
fundingScript: ByteVector,
251255
remoteFundingAmount: Satoshi,
252256
fundingFeerate: FeeratePerKw,
257+
isChannelCreation: Boolean,
253258
willFund: WillFund?
254259
): Either<ChannelException, Purchase> {
255260
return when (willFund) {
@@ -261,7 +266,7 @@ object LiquidityAds {
261266
remoteFundingAmount < requestedAmount -> Either.Left(InvalidLiquidityAdsAmount(channelId, remoteFundingAmount, requestedAmount))
262267
else -> {
263268
val purchasedAmount = requestedAmount.min(remoteFundingAmount)
264-
val fees = fundingRate.fees(fundingFeerate, requestedAmount, remoteFundingAmount)
269+
val fees = fundingRate.fees(fundingFeerate, requestedAmount, remoteFundingAmount, isChannelCreation)
265270
Either.Right(Purchase.Standard(purchasedAmount, fees, paymentDetails))
266271
}
267272
}
@@ -295,11 +300,12 @@ object LiquidityAds {
295300
fundingScript: ByteVector,
296301
remoteFundingAmount: Satoshi,
297302
fundingFeerate: FeeratePerKw,
303+
isChannelCreation: Boolean,
298304
willFund: WillFund?,
299305
): Either<ChannelException, Purchase?> {
300306
return when (request) {
301307
null -> Either.Right(null)
302-
else -> request.validateRemoteFunding(remoteNodeId, channelId, fundingScript, remoteFundingAmount, fundingFeerate, willFund)
308+
else -> request.validateRemoteFunding(remoteNodeId, channelId, fundingScript, remoteFundingAmount, fundingFeerate, isChannelCreation, willFund)
303309
}
304310
}
305311

0 commit comments

Comments
 (0)