Skip to content

Commit

Permalink
Add channelCreationFee to liquidity ads
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
t-bast committed Sep 3, 2024
1 parent 7f95eb4 commit 87a1d54
Show file tree
Hide file tree
Showing 11 changed files with 70 additions and 59 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ data class Normal(
}
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() }
val missing = spliceStatus.command.requestRemoteFunding?.let { r -> r.fees(spliceStatus.command.feerate, isChannelCreation = false).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 {
Expand Down Expand Up @@ -521,6 +521,7 @@ data class Normal(
Helpers.Funding.makeFundingPubKeyScript(spliceStatus.spliceInit.fundingPubkey, cmd.message.fundingPubkey),
cmd.message.fundingContribution,
spliceStatus.spliceInit.feerate,
isChannelCreation = false,
cmd.message.willFund,
)) {
is Either.Left<ChannelException> -> {
Expand Down Expand Up @@ -858,8 +859,8 @@ data class Normal(
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()
is LiquidityAds.PaymentDetails.FromChannelBalance -> request.fees(splice.feerate, isChannelCreation = false).total <= parentCommitment.localCommit.spec.toLocal.truncateToSatoshi()
is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> request.fees(splice.feerate, isChannelCreation = false).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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ data class WaitForAcceptChannel(
fundingParams.fundingPubkeyScript(channelKeys),
accept.fundingAmount,
lastSent.fundingFeerate,
isChannelCreation = true,
accept.willFund
)) {
is Either.Left -> {
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, isChannelCreation = true)
}
val accept = AcceptDualFundedChannel(
temporaryChannelId = open.temporaryChannelId,
Expand Down
12 changes: 6 additions & 6 deletions src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ data class AddWalletInputsToChannel(val walletInputs: List<WalletState.Utxo>) :
data class AddLiquidityForIncomingPayment(val paymentAmount: MilliSatoshi, val requestedAmount: Satoshi, val fundingRate: LiquidityAds.FundingRate, val preimage: ByteVector32, val willAddHtlcs: List<WillAddHtlc>) : PeerCommand() {
val paymentHash: ByteVector32 = Crypto.sha256(preimage.toByteArray()).byteVector32()

fun fees(fundingFeerate: FeeratePerKw): LiquidityAds.Fees = fundingRate.fees(fundingFeerate, requestedAmount, requestedAmount)
fun fees(fundingFeerate: FeeratePerKw, isChannelCreation: Boolean): LiquidityAds.Fees = fundingRate.fees(fundingFeerate, requestedAmount, requestedAmount, isChannelCreation)
}

data class PeerConnection(val id: Long, val output: Channel<LightningMessage>, val logger: MDCLogger) {
Expand Down Expand Up @@ -600,7 +600,7 @@ class Peer(
// The mining fee below pays for the entirety of the splice transaction, including inputs and outputs from the liquidity provider.
val (actualFeerate, miningFee) = client.computeSpliceCpfpFeerate(channel.commitments, targetFeerate, spliceWeight = weight, logger)
// The mining fee below only covers the remote node's inputs and outputs, which are already included in the mining fee above.
val fundingFees = fundingRate.fees(actualFeerate, amount, amount)
val fundingFees = fundingRate.fees(actualFeerate, amount, amount, isChannelCreation = false)
Pair(actualFeerate, ChannelManagementFees(miningFee, fundingFees.serviceFee))
}
}
Expand Down Expand Up @@ -1291,7 +1291,7 @@ class Peer(
val dummyFundingScript = Script.write(Scripts.multiSig2of2(Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey)).byteVector()
val localMiningFee = Transactions.weight2fee(currentFeerates.fundingFeerate, FundingContributions.computeWeightPaid(isInitiator = true, null, dummyFundingScript, cmd.walletInputs, emptyList()))
val localFundingAmount = cmd.totalAmount - localMiningFee
val fundingFees = requestRemoteFunding.fees(currentFeerates.fundingFeerate)
val fundingFees = requestRemoteFunding.fees(currentFeerates.fundingFeerate, isChannelCreation = true)
// We also refund the liquidity provider for some of the on-chain fees they will pay for their inputs/outputs of the transaction.
// This will be taken from our channel balance during the interactive-tx construction, they shouldn't be deducted from our funding amount.
val totalFees = ChannelManagementFees(miningFee = localMiningFee + fundingFees.miningFee, serviceFee = fundingFees.serviceFee)
Expand Down Expand Up @@ -1347,7 +1347,7 @@ class Peer(
val spliceWeight = FundingContributions.computeWeightPaid(isInitiator = true, commitment = available.channel.commitments.active.first(), walletInputs = listOf(), localOutputs = listOf())
val (fundingFeerate, localMiningFee) = client.computeSpliceCpfpFeerate(available.channel.commitments, currentFeerates.fundingFeerate, spliceWeight, logger)
val (targetFeerate, paymentDetails) = when {
localBalance >= localMiningFee + cmd.fees(fundingFeerate).total -> {
localBalance >= localMiningFee + cmd.fees(fundingFeerate, isChannelCreation = false).total -> {
// We have enough funds to pay the mining fee and the lease fees.
// This the ideal scenario because the fees can be paid immediately with the splice transaction.
Pair(fundingFeerate, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(listOf(cmd.paymentHash)))
Expand Down Expand Up @@ -1380,7 +1380,7 @@ class Peer(
logger.warning { "cannot request on-the-fly splice: payment types not supported (${walletParams.remoteFundingRates.paymentTypes.joinToString()})" }
}
else -> {
val leaseFees = cmd.fees(targetFeerate)
val leaseFees = cmd.fees(targetFeerate, isChannelCreation = false)
val totalFees = ChannelManagementFees(miningFee = localMiningFee.min(localBalance.truncateToSatoshi()) + leaseFees.miningFee, serviceFee = leaseFees.serviceFee)
logger.info { "requesting on-the-fly splice for paymentHash=${cmd.paymentHash} feerate=$targetFeerate fee=${totalFees.total} paymentType=${paymentDetails.paymentType}" }
val spliceCommand = ChannelCommand.Commitment.Splice.Request(
Expand All @@ -1406,7 +1406,7 @@ class Peer(
// We only need to cover the shared output, which doesn't add too much weight, so we add 25%.
val fundingFeerate = currentFeerates.fundingFeerate * 1.25
// We don't pay any local on-chain fees, our fee is only for the liquidity lease.
val leaseFees = cmd.fees(fundingFeerate)
val leaseFees = cmd.fees(fundingFeerate, isChannelCreation = true)
val totalFees = ChannelManagementFees(miningFee = leaseFees.miningFee, serviceFee = leaseFees.serviceFee)
// We cannot pay the liquidity fees from our channel balance, so we fall back to future HTLCs.
val paymentDetails = when {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,9 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri
when (val fundingRate = remoteFundingRates.findRate(requestedAmount)) {
null -> Either.Left(LiquidityEvents.Rejected(requestedAmount.toMilliSatoshi(), 0.msat, LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.NoMatchingFundingRate))
else -> {
val fees = fundingRate.fees(currentFeerate, requestedAmount, requestedAmount).total
// We don't know at that point if we'll need a channel or if we already have one.
// We must use the worst case fees that applies to channel creation.
val fees = fundingRate.fees(currentFeerate, requestedAmount, requestedAmount, isChannelCreation = true).total
val rejected = when {
// We only initiate on-the-fly funding if the missing amount is greater than the fees paid.
// Otherwise our peer may not be able to claim the funding fees from the relayed HTLCs.
Expand Down
22 changes: 14 additions & 8 deletions src/commonMain/kotlin/fr/acinq/lightning/wire/LiquidityAds.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,16 @@ object LiquidityAds {
* The buyer refunds those on-chain fees for the given vbytes.
* @param feeProportional proportional fee (expressed in basis points) based on the amount contributed by the seller.
* @param feeBase flat fee that must be paid regardless of the amount contributed by the seller.
* @param channelCreationFee flat fee that must be paid when a new channel is created.
*/
data class FundingRate(val minAmount: Satoshi, val maxAmount: Satoshi, val fundingWeight: Int, val feeProportional: Int, val feeBase: Satoshi) {
data class FundingRate(val minAmount: Satoshi, val maxAmount: Satoshi, val fundingWeight: Int, val feeProportional: Int, val feeBase: Satoshi, val channelCreationFee: Satoshi) {
/** Fees paid by the liquidity buyer. */
fun fees(feerate: FeeratePerKw, requestedAmount: Satoshi, contributedAmount: Satoshi): Fees {
fun fees(feerate: FeeratePerKw, requestedAmount: Satoshi, contributedAmount: Satoshi, isChannelCreation: Boolean): Fees {
val onChainFees = Transactions.weight2fee(feerate, fundingWeight)
// If the seller adds more liquidity than requested, the buyer doesn't pay for that extra liquidity.
val proportionalFee = requestedAmount.min(contributedAmount) * feeProportional / 10_000
return Fees(onChainFees, feeBase + proportionalFee)
val flatFee = if (isChannelCreation) channelCreationFee + feeBase else feeBase
return Fees(onChainFees, flatFee + proportionalFee)
}

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

companion object {
Expand All @@ -77,6 +80,7 @@ object LiquidityAds {
fundingWeight = LightningCodecs.u16(input),
feeProportional = LightningCodecs.u16(input),
feeBase = LightningCodecs.u32(input).sat,
channelCreationFee = LightningCodecs.u32(input).sat,
)
}
}
Expand Down Expand Up @@ -186,14 +190,14 @@ object LiquidityAds {

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

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

fun validateRemoteFunding(
remoteNodeId: PublicKey,
channelId: ByteVector32,
fundingScript: ByteVector,
remoteFundingAmount: Satoshi,
fundingFeerate: FeeratePerKw,
isChannelCreation: Boolean,
willFund: WillFund?
): Either<ChannelException, Purchase> {
return when (willFund) {
Expand All @@ -261,7 +266,7 @@ object LiquidityAds {
remoteFundingAmount < requestedAmount -> Either.Left(InvalidLiquidityAdsAmount(channelId, remoteFundingAmount, requestedAmount))
else -> {
val purchasedAmount = requestedAmount.min(remoteFundingAmount)
val fees = fundingRate.fees(fundingFeerate, requestedAmount, remoteFundingAmount)
val fees = fundingRate.fees(fundingFeerate, requestedAmount, remoteFundingAmount, isChannelCreation)
Either.Right(Purchase.Standard(purchasedAmount, fees, paymentDetails))
}
}
Expand Down Expand Up @@ -295,11 +300,12 @@ object LiquidityAds {
fundingScript: ByteVector,
remoteFundingAmount: Satoshi,
fundingFeerate: FeeratePerKw,
isChannelCreation: Boolean,
willFund: WillFund?,
): Either<ChannelException, Purchase?> {
return when (request) {
null -> Either.Right(null)
else -> request.validateRemoteFunding(remoteNodeId, channelId, fundingScript, remoteFundingAmount, fundingFeerate, willFund)
else -> request.validateRemoteFunding(remoteNodeId, channelId, fundingScript, remoteFundingAmount, fundingFeerate, isChannelCreation, willFund)
}
}

Expand Down
Loading

0 comments on commit 87a1d54

Please sign in to comment.