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 a31bca3 commit f16eeaf
Show file tree
Hide file tree
Showing 12 changed files with 63 additions and 54 deletions.
2 changes: 2 additions & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -321,13 +321,15 @@ eclair {
// funding-weight = 400
// fee-base-satoshis = 500 // flat fee that we will receive every time we accept a liquidity request
// fee-basis-points = 250 // proportional fee based on the amount requested by our peer (2.5%)
// channel-creation-fee-satoshis = 2500 // flat fee that is added when creating a new channel
// },
// {
// min-funding-amount-satoshis = 500000
// max-funding-amount-satoshis = 5000000
// funding-weight = 750
// fee-base-satoshis = 1000
// fee-basis-points = 200 // 2%
// channel-creation-fee-satoshis = 2000
// }
// ]
// Multiple ways of paying the liquidity fees can be provided.
Expand Down
3 changes: 2 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,8 @@ object NodeParams extends Logging {
maxAmount = r.getLong("max-funding-amount-satoshis").sat,
fundingWeight = r.getInt("funding-weight"),
feeBase = r.getLong("fee-base-satoshis").sat,
feeProportional = r.getInt("fee-basis-points")
feeProportional = r.getInt("fee-basis-points"),
channelCreationFee = r.getLong("channel-creation-fee-satoshis").sat,
)
}.toList
if (fundingRates.nonEmpty && paymentTypes.nonEmpty) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ object Helpers {

for {
script_opt <- extractShutdownScript(open.temporaryChannelId, localFeatures, remoteFeatures, open.upfrontShutdownScript_opt)
willFund_opt <- LiquidityAds.validateRequest(nodeParams.privateKey, open.temporaryChannelId, fundingScript, open.fundingFeerate, open.requestFunding_opt, addFunding_opt.flatMap(_.rates_opt))
willFund_opt <- LiquidityAds.validateRequest(nodeParams.privateKey, open.temporaryChannelId, fundingScript, open.fundingFeerate, isChannelCreation = true, open.requestFunding_opt, addFunding_opt.flatMap(_.rates_opt))
} yield (channelFeatures, script_opt, willFund_opt)
}

Expand Down Expand Up @@ -259,7 +259,7 @@ object Helpers {
for {
script_opt <- extractShutdownScript(accept.temporaryChannelId, localFeatures, remoteFeatures, accept.upfrontShutdownScript_opt)
fundingScript = Funding.makeFundingPubKeyScript(open.fundingPubkey, accept.fundingPubkey)
liquidityPurchase_opt <- LiquidityAds.validateRemoteFunding(open.requestFunding_opt, remoteNodeId, accept.temporaryChannelId, fundingScript, accept.fundingAmount, open.fundingFeerate, accept.willFund_opt)
liquidityPurchase_opt <- LiquidityAds.validateRemoteFunding(open.requestFunding_opt, remoteNodeId, accept.temporaryChannelId, fundingScript, accept.fundingAmount, open.fundingFeerate, isChannelCreation = true, accept.willFund_opt)
} yield {
val channelFeatures = ChannelFeatures(channelType, localFeatures, remoteFeatures, open.channelFlags.announceChannel)
(channelFeatures, script_opt, liquidityPurchase_opt)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -951,7 +951,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
val parentCommitment = d.commitments.latest.commitment
val localFundingPubKey = nodeParams.channelKeyManager.fundingPublicKey(d.commitments.params.localParams.fundingKeyPath, parentCommitment.fundingTxIndex + 1).publicKey
val fundingScript = Funding.makeFundingPubKeyScript(localFundingPubKey, msg.fundingPubKey)
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, msg.requestFunding_opt, nodeParams.willFundRates_opt) match {
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = false, msg.requestFunding_opt, nodeParams.willFundRates_opt) match {
case Left(t) =>
log.warning("rejecting splice request with invalid liquidity ads: {}", t.getMessage)
stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, t.getMessage)
Expand Down Expand Up @@ -1018,7 +1018,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
requireConfirmedInputs = RequireConfirmedInputs(forLocal = msg.requireConfirmedInputs, forRemote = spliceInit.requireConfirmedInputs)
)
val fundingScript = Funding.makeFundingPubKeyScript(spliceInit.fundingPubKey, msg.fundingPubKey)
LiquidityAds.validateRemoteFunding(spliceInit.requestFunding_opt, remoteNodeId, d.channelId, fundingScript, msg.fundingContribution, spliceInit.feerate, msg.willFund_opt) match {
LiquidityAds.validateRemoteFunding(spliceInit.requestFunding_opt, remoteNodeId, d.channelId, fundingScript, msg.fundingContribution, spliceInit.feerate, isChannelCreation = false, msg.willFund_opt) match {
case Left(t) =>
log.info("rejecting splice attempt: invalid liquidity ads response ({})", t.getMessage)
cmd.replyTo ! RES_FAILURE(cmd, t)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
stay() using d.copy(rbfStatus = RbfStatus.RbfAborted) sending TxAbort(d.channelId, InvalidRbfAttemptTooSoon(d.channelId, d.latestFundingTx.createdAt, d.latestFundingTx.createdAt + nodeParams.channelConf.remoteRbfLimits.attemptDeltaBlocks).getMessage)
} else {
val fundingScript = d.commitments.latest.commitInput.txOut.publicKeyScript
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, msg.requestFunding_opt, nodeParams.willFundRates_opt) match {
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = true, msg.requestFunding_opt, nodeParams.willFundRates_opt) match {
case Left(t) =>
log.warning("rejecting rbf attempt: invalid liquidity ads request ({})", t.getMessage)
stay() using d.copy(rbfStatus = RbfStatus.RbfAborted) sending TxAbort(d.channelId, t.getMessage)
Expand Down Expand Up @@ -598,7 +598,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
targetFeerate = cmd.targetFeerate,
)
val fundingScript = d.commitments.latest.commitInput.txOut.publicKeyScript
LiquidityAds.validateRemoteFunding(cmd.requestFunding_opt, remoteNodeId, d.channelId, fundingScript, msg.fundingContribution, cmd.targetFeerate, msg.willFund_opt) match {
LiquidityAds.validateRemoteFunding(cmd.requestFunding_opt, remoteNodeId, d.channelId, fundingScript, msg.fundingContribution, cmd.targetFeerate, isChannelCreation = true, msg.willFund_opt) match {
case Left(t) =>
log.warning("rejecting rbf attempt: invalid liquidity ads response ({})", t.getMessage)
cmd.replyTo ! RES_FAILURE(cmd, t)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,20 +52,22 @@ object LiquidityAds {
* Rate at which a liquidity seller sells its liquidity.
* Liquidity fees are computed based on multiple components.
*
* @param minAmount minimum amount that can be purchased at this rate.
* @param maxAmount maximum amount that can be purchased at this rate.
* @param fundingWeight the seller will have to add inputs/outputs to the transaction and pay on-chain fees
* for them. 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 minAmount minimum amount that can be purchased at this rate.
* @param maxAmount maximum amount that can be purchased at this rate.
* @param fundingWeight the seller will have to add inputs/outputs to the transaction and pay on-chain fees
* for them. 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.
*/
case class FundingRate(minAmount: Satoshi, maxAmount: Satoshi, fundingWeight: Int, feeProportional: Int, feeBase: Satoshi) {
case class FundingRate(minAmount: Satoshi, maxAmount: Satoshi, fundingWeight: Int, feeProportional: Int, feeBase: Satoshi, channelCreationFee: Satoshi) {
/** Fees paid by the liquidity buyer. */
def fees(feerate: FeeratePerKw, requestedAmount: Satoshi, contributedAmount: Satoshi): Fees = {
def 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).toMilliSatoshi * feeProportional / 10_000
Fees(onChainFees, feeBase + proportionalFee.truncateToSatoshi)
val flatFee = if (isChannelCreation) channelCreationFee + feeBase else feeBase
Fees(onChainFees, flatFee + proportionalFee.truncateToSatoshi)
}

/** Return true if this rate is compatible with the requested funding amount. */
Expand Down Expand Up @@ -110,7 +112,7 @@ object LiquidityAds {

/** Sellers offer various rates and payment options. */
case class WillFundRates(fundingRates: List[FundingRate], paymentTypes: Set[PaymentType]) {
def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding): Either[ChannelException, WillFundPurchase] = {
def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding, isChannelCreation: Boolean): Either[ChannelException, WillFundPurchase] = {
if (!paymentTypes.contains(request.paymentDetails.paymentType)) {
Left(InvalidLiquidityAdsPaymentType(channelId, request.paymentDetails.paymentType, paymentTypes))
} else if (!fundingRates.contains(request.fundingRate)) {
Expand All @@ -119,17 +121,17 @@ object LiquidityAds {
Left(InvalidLiquidityAdsRate(channelId))
} else {
val sig = Crypto.sign(request.fundingRate.signedData(fundingScript), nodeKey)
val purchase = Purchase.Standard(request.requestedAmount, request.fundingRate.fees(fundingFeerate, request.requestedAmount, request.requestedAmount), request.paymentDetails)
val purchase = Purchase.Standard(request.requestedAmount, request.fundingRate.fees(fundingFeerate, request.requestedAmount, request.requestedAmount, isChannelCreation), request.paymentDetails)
Right(WillFundPurchase(WillFund(request.fundingRate, fundingScript, sig), purchase))
}
}

def findRate(requestedAmount: Satoshi): Option[FundingRate] = fundingRates.find(r => r.minAmount <= requestedAmount && requestedAmount <= r.maxAmount)
}

def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request_opt: Option[RequestFunding], rates_opt: Option[WillFundRates]): Either[ChannelException, Option[WillFundPurchase]] = {
def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, isChannelCreation: Boolean, request_opt: Option[RequestFunding], rates_opt: Option[WillFundRates]): Either[ChannelException, Option[WillFundPurchase]] = {
(request_opt, rates_opt) match {
case (Some(request), Some(rates)) => rates.validateRequest(nodeKey, channelId, fundingScript, fundingFeerate, request).map(l => Some(l))
case (Some(request), Some(rates)) => rates.validateRequest(nodeKey, channelId, fundingScript, fundingFeerate, request, isChannelCreation).map(l => Some(l))
case _ => Right(None)
}
}
Expand All @@ -147,13 +149,14 @@ object LiquidityAds {

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

def validateRemoteFunding(remoteNodeId: PublicKey,
channelId: ByteVector32,
fundingScript: ByteVector,
remoteFundingAmount: Satoshi,
fundingFeerate: FeeratePerKw,
isChannelCreation: Boolean,
willFund_opt: Option[WillFund]): Either[ChannelException, Purchase] = {
willFund_opt match {
case Some(willFund) =>
Expand All @@ -163,7 +166,7 @@ object LiquidityAds {
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)
Right(Purchase.Standard(purchasedAmount, fees, paymentDetails))
}
case None =>
Expand All @@ -180,9 +183,10 @@ object LiquidityAds {
fundingScript: ByteVector,
remoteFundingAmount: Satoshi,
fundingFeerate: FeeratePerKw,
isChannelCreation: Boolean,
willFund_opt: Option[WillFund]): Either[ChannelException, Option[Purchase]] = {
request_opt match {
case Some(request) => request.validateRemoteFunding(remoteNodeId, channelId, fundingScript, remoteFundingAmount, fundingFeerate, willFund_opt) match {
case Some(request) => request.validateRemoteFunding(remoteNodeId, channelId, fundingScript, remoteFundingAmount, fundingFeerate, isChannelCreation, willFund_opt) match {
case Left(f) => Left(f)
case Right(purchase) => Right(Some(purchase))
}
Expand Down Expand Up @@ -218,7 +222,8 @@ object LiquidityAds {
("maxAmount" | satoshi32) ::
("fundingWeight" | uint16) ::
("feeBasis" | uint16) ::
("feeBase" | satoshi32)
("feeBase" | satoshi32) ::
("channelCreationFee" | satoshi32)
).as[FundingRate]

private val paymentDetails: Codec[PaymentDetails] = discriminated[PaymentDetails].by(varint)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ object TestConstants {
val feeratePerKw: FeeratePerKw = FeeratePerKw(10_000 sat)
val anchorOutputsFeeratePerKw: FeeratePerKw = FeeratePerKw(2_500 sat)
val defaultLiquidityRates: LiquidityAds.WillFundRates = LiquidityAds.WillFundRates(
fundingRates = LiquidityAds.FundingRate(100_000 sat, 10_000_000 sat, 500, 100, 100 sat) :: Nil,
fundingRates = LiquidityAds.FundingRate(100_000 sat, 10_000_000 sat, 500, 100, 100 sat, 1000 sat) :: Nil,
paymentTypes = Set(LiquidityAds.PaymentType.FromChannelBalance)
)
val emptyOnionPacket: OnionRoutingPacket = OnionRoutingPacket(0, ByteVector.fill(33)(0), ByteVector.fill(1300)(0), ByteVector32.Zeroes)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture
}
if (!test.tags.contains(noFundingContribution)) {
// Alice pays fees for the liquidity she bought, and push amounts are correctly transferred.
val liquidityFees = TestConstants.defaultLiquidityRates.fundingRates.head.fees(TestConstants.feeratePerKw, TestConstants.nonInitiatorFundingSatoshis, TestConstants.nonInitiatorFundingSatoshis)
val liquidityFees = TestConstants.defaultLiquidityRates.fundingRates.head.fees(TestConstants.feeratePerKw, TestConstants.nonInitiatorFundingSatoshis, TestConstants.nonInitiatorFundingSatoshis, isChannelCreation = true)
val bobReserve = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].commitments.latest.remoteChannelReserve
val expectedBalanceBob = bobContribution.map(_.fundingAmount).getOrElse(0 sat) + liquidityFees.total + initiatorPushAmount.getOrElse(0 msat) - nonInitiatorPushAmount.getOrElse(0 msat) - bobReserve
assert(bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].commitments.availableBalanceForSend == expectedBalanceBob)
Expand Down Expand Up @@ -387,12 +387,12 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture

val remoteFunding = TestConstants.nonInitiatorFundingSatoshis
val feerate1 = TestConstants.feeratePerKw
val liquidityFee1 = TestConstants.defaultLiquidityRates.fundingRates.head.fees(feerate1, remoteFunding, remoteFunding)
val liquidityFee1 = TestConstants.defaultLiquidityRates.fundingRates.head.fees(feerate1, remoteFunding, remoteFunding, isChannelCreation = true)
val balanceBob1 = bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].commitments.latest.localCommit.spec.toLocal
assert(alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].previousFundingTxs.isEmpty)

val feerate2 = FeeratePerKw(12_500 sat)
val liquidityFee2 = TestConstants.defaultLiquidityRates.fundingRates.head.fees(feerate2, remoteFunding, remoteFunding)
val liquidityFee2 = TestConstants.defaultLiquidityRates.fundingRates.head.fees(feerate2, remoteFunding, remoteFunding, isChannelCreation = true)
testBumpFundingFees(f, Some(feerate2), Some(LiquidityAds.RequestFunding(remoteFunding, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance)))
val balanceBob2 = bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].commitments.latest.localCommit.spec.toLocal
assert(liquidityFee1.total < liquidityFee2.total)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
import f._

val sender = TestProbe()
val fundingRequest = LiquidityAds.RequestFunding(100_000 sat, LiquidityAds.FundingRate(10_000 sat, 200_000 sat, 0, 0, 0 sat), LiquidityAds.PaymentDetails.FromChannelBalance)
val fundingRequest = LiquidityAds.RequestFunding(100_000 sat, LiquidityAds.FundingRate(10_000 sat, 200_000 sat, 0, 0, 0 sat, 0 sat), LiquidityAds.PaymentDetails.FromChannelBalance)
val cmd = CMD_SPLICE(sender.ref, Some(SpliceIn(500_000 sat)), None, Some(fundingRequest))
alice ! cmd

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ class PeerSpec extends FixtureSpec {
connect(remoteNodeId, peer, peerConnection, switchboard)
assert(peer.stateData.channels.isEmpty)

val requestFunds = LiquidityAds.RequestFunding(50_000 sat, LiquidityAds.FundingRate(10_000 sat, 100_000 sat, 0, 0, 0 sat), LiquidityAds.PaymentDetails.FromChannelBalance)
val requestFunds = LiquidityAds.RequestFunding(50_000 sat, LiquidityAds.FundingRate(10_000 sat, 100_000 sat, 0, 0, 0 sat, 0 sat), LiquidityAds.PaymentDetails.FromChannelBalance)
val open = Peer.OpenChannel(remoteNodeId, 10000 sat, None, None, None, None, Some(requestFunds), None, None)
peerConnection.send(peer, open)
assert(channel.expectMsgType[INPUT_INIT_CHANNEL_INITIATOR].requestFunding_opt.contains(requestFunds))
Expand Down
Loading

0 comments on commit f16eeaf

Please sign in to comment.