Skip to content

Commit

Permalink
Configure different rates based on lease duration
Browse files Browse the repository at this point in the history
Send those rates in `node_announcement` and `init`, and update codecs
accordingly.

This matches the proposal in lightning/bolts#878 (comment)
  • Loading branch information
t-bast committed Mar 29, 2024
1 parent 73325fc commit c10aa95
Show file tree
Hide file tree
Showing 30 changed files with 412 additions and 279 deletions.
31 changes: 27 additions & 4 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -548,10 +548,33 @@ eclair {

// Liquidity Ads allow remote nodes to pay us to provide them with inbound liquidity.
liquidity-ads {
enabled = false // set this field to true if you want to sell your unused on-chain liquidity
fee-base-satoshis = 1000 // flat fee that we will receive every time we accept a lease request
fee-basis-points = 500 // 5% of the liquidity we will provide
max-duration-blocks = 4032 // ~1 month
// Set this field to true to activate liquidity ads and sell your available on-chain liquidity.
enabled = false
// Multiple rates can be provided, for different lease durations.
// The leased amount will be locked for that duration: the seller cannot get it back before the lease expires.
rates = [
{
duration-blocks = 1008 // ~1 week
min-funding-amount-satoshis = 10000 // minimum funding amount we will sell
// The seller can ask the buyer to pay for some of the weight of the funding transaction (for the inputs and
// outputs added by the seller). This field contains the transaction weight (in vbytes) that the seller asks the
// buyer to pay for. The default value matches the weight of one p2wpkh input with one p2wpkh change output.
funding-weight = 400
fee-base-satoshis = 500 // flat fee that we will receive every time we accept a lease request
fee-basis-points = 200 // proportional fee based on the amount requested by our peer (2%)
max-channel-relay-fee-base-msat = 1000 // maximum base routing fee we will apply to that channel during the lease
max-channel-relay-fee-basis-points = 10 // maximum proportional routing fee we will apply to that channel during the lease (0.1%)
},
{
duration-blocks = 4032 // ~1 month
min-funding-amount-satoshis = 25000
funding-weight = 400
fee-base-satoshis = 1000
fee-basis-points = 500 // 5%
max-channel-relay-fee-base-msat = 5000
max-channel-relay-fee-basis-points = 50 // 0.5%
}
]
}
}

Expand Down
22 changes: 14 additions & 8 deletions eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
onionMessageConfig: OnionMessageConfig,
purgeInvoicesInterval: Option[FiniteDuration],
revokedHtlcInfoCleanerConfig: RevokedHtlcInfoCleaner.Config,
liquidityAdsConfig_opt: Option[LiquidityAds.Config]) {
liquidityAdsConfig_opt: Option[LiquidityAds.SellerConfig]) {
val privateKey: Crypto.PrivateKey = nodeKeyManager.nodeKey.privateKey

val nodeId: PublicKey = nodeKeyManager.nodeId
Expand All @@ -99,8 +99,6 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,

val pluginOpenChannelInterceptor: Option[InterceptOpenChannelPlugin] = pluginParams.collectFirst { case p: InterceptOpenChannelPlugin => p }

val liquidityRates_opt: Option[LiquidityAds.LeaseRates] = liquidityAdsConfig_opt.map(_.leaseRates(relayParams.defaultFees(announceChannel = true)))

def currentBlockHeight: BlockHeight = BlockHeight(blockHeight.get)

def currentFeerates: FeeratesPerKw = feerates.get()
Expand Down Expand Up @@ -615,11 +613,19 @@ object NodeParams extends Logging {
interval = FiniteDuration(config.getDuration("db.revoked-htlc-info-cleaner.interval").getSeconds, TimeUnit.SECONDS)
),
liquidityAdsConfig_opt = if (config.getBoolean("liquidity-ads.enabled")) {
Some(LiquidityAds.Config(
feeBase = Satoshi(config.getInt("liquidity-ads.fee-base-satoshis")),
feeProportional = config.getInt("liquidity-ads.fee-basis-points"),
maxLeaseDuration = config.getInt("liquidity-ads.max-duration-blocks"),
))
Some(LiquidityAds.SellerConfig(rates = config.getConfigList("liquidity-ads.rates").asScala.map { r =>
LiquidityAds.LeaseRateConfig(
rate = LiquidityAds.LeaseRate(
leaseDuration = r.getInt("duration-blocks"),
fundingWeight = r.getInt("funding-weight"),
leaseFeeProportional = r.getInt("fee-basis-points"),
leaseFeeBase = Satoshi(r.getLong("fee-base-satoshis")),
maxRelayFeeProportional = r.getInt("max-channel-relay-fee-basis-points"),
maxRelayFeeBase = MilliSatoshi(r.getLong("max-channel-relay-fee-base-msat")),
),
minAmount = Satoshi(r.getLong("min-funding-amount-satoshis")),
)
}.toSeq))
} else {
None
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ case class ChannelSignatureSent(channel: ActorRef, commitments: Commitments) ext

case class ChannelSignatureReceived(channel: ActorRef, commitments: Commitments) extends ChannelEvent

case class LiquidityPurchased(channel: ActorRef, channelId: ByteVector32, remoteNodeId: PublicKey, fundingTxId: TxId, purchase: LiquidityAds.LiquidityPurchased) extends ChannelEvent
case class LiquidityPurchased(channel: ActorRef, channelId: ByteVector32, remoteNodeId: PublicKey, fundingTxId: TxId, isBuyer: Boolean, lease: LiquidityAds.Lease) extends ChannelEvent

case class ChannelErrorOccurred(channel: ActorRef, channelId: ByteVector32, remoteNodeId: PublicKey, error: ChannelError, isFatal: Boolean) extends ChannelEvent

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@

package fr.acinq.eclair.channel

import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, ByteVector64, Satoshi, Transaction, TxId}
import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, Satoshi, Transaction, TxId}
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.wire.protocol
import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, InteractiveTxMessage, LiquidityAds, UpdateAddHtlc}
import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, InteractiveTxMessage, UpdateAddHtlc}
import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshi, UInt64}
import scodec.bits.ByteVector

Expand Down Expand Up @@ -52,8 +52,10 @@ case class ChannelReserveTooHigh (override val channelId: Byte
case class ChannelReserveBelowOurDustLimit (override val channelId: ByteVector32, channelReserve: Satoshi, dustLimit: Satoshi) extends ChannelException(channelId, s"their channelReserve=$channelReserve is below our dustLimit=$dustLimit")
case class ChannelReserveNotMet (override val channelId: ByteVector32, toLocal: MilliSatoshi, toRemote: MilliSatoshi, reserve: Satoshi) extends ChannelException(channelId, s"channel reserve is not met toLocal=$toLocal toRemote=$toRemote reserve=$reserve")
case class MissingLiquidityAds (override val channelId: ByteVector32) extends ChannelException(channelId, "liquidity ads field is missing")
case class InvalidLiquidityAdsAmount (override val channelId: ByteVector32, proposed: Satoshi, min: Satoshi) extends ChannelException(channelId, s"liquidity ads funding amount is too low (expected at least $min, got $proposed)")
case class InvalidLiquidityAdsSig (override val channelId: ByteVector32) extends ChannelException(channelId, "liquidity ads signature is invalid")
case class LiquidityRatesRejected (override val channelId: ByteVector32) extends ChannelException(channelId, "rejecting liquidity ads proposed rates")
case class InvalidLiquidityRates (override val channelId: ByteVector32) extends ChannelException(channelId, "rejecting liquidity ads proposed rates")
case class InvalidLiquidityAdsDuration (override val channelId: ByteVector32, leaseDuration: Int) extends ChannelException(channelId, s"rejecting liquidity ads proposed duration ($leaseDuration blocks)")
case class ChannelFundingError (override val channelId: ByteVector32) extends ChannelException(channelId, "channel funding error")
case class InvalidFundingTx (override val channelId: ByteVector32) extends ChannelException(channelId, "invalid funding tx")
case class InvalidSerialId (override val channelId: ByteVector32, serialId: UInt64) extends ChannelException(channelId, s"invalid serial_id=${serialId.toByteVector.toHex}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ object Helpers {
if (accept.toSelfDelay > nodeParams.channelConf.maxToLocalDelay) return Left(ToSelfDelayTooHigh(accept.temporaryChannelId, accept.toSelfDelay, nodeParams.channelConf.maxToLocalDelay))

// If we're purchasing liquidity, verify the liquidity ads:
val liquidityLease_opt = requestedFunds_opt.map(_.validateLeaseRates(remoteNodeId, accept.temporaryChannelId, accept.fundingPubkey, accept.fundingAmount, open.fundingFeerate, accept.willFund_opt) match {
val liquidityLease_opt = requestedFunds_opt.map(_.validateLease(remoteNodeId, accept.temporaryChannelId, Funding.makeFundingPubKeyScript(open.fundingPubkey, accept.fundingPubkey), accept.fundingAmount, open.fundingFeerate, accept.willFund_opt) match {
case Left(t) => return Left(t)
case Right(lease) => lease // we agree on liquidity rates, if any
})
Expand Down Expand Up @@ -363,6 +363,8 @@ object Helpers {

object Funding {

def makeFundingPubKeyScript(localFundingKey: PublicKey, remoteFundingKey: PublicKey): ByteVector = Script.write(Script.pay2wsh(Scripts.multiSig2of2(localFundingKey, remoteFundingKey)))

def makeFundingInputInfo(fundingTxId: TxId, fundingTxOutputIndex: Int, fundingSatoshis: Satoshi, fundingPubkey1: PublicKey, fundingPubkey2: PublicKey): InputInfo = {
val fundingScript = multiSig2of2(fundingPubkey1, fundingPubkey2)
val fundingTxOut = TxOut(fundingSatoshis, pay2wsh(fundingScript))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -950,39 +950,45 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
log.info(s"accepting splice with remote.in.amount=${msg.fundingContribution} remote.in.push=${msg.pushAmount}")
val parentCommitment = d.commitments.latest.commitment
val localFundingPubKey = nodeParams.channelKeyManager.fundingPublicKey(d.commitments.params.localParams.fundingKeyPath, parentCommitment.fundingTxIndex + 1).publicKey
val liquidityLease_opt = nodeParams.liquidityRates_opt.flatMap(_.signLease(nodeParams.privateKey, nodeParams.currentBlockHeight, localFundingPubKey, msg.feerate, msg.requestFunds_opt))
val spliceAck = SpliceAck(d.channelId,
fundingContribution = liquidityLease_opt.map(_.lease.amount).getOrElse(0.sat),
fundingPubKey = localFundingPubKey,
pushAmount = 0.msat,
addFunding_opt = liquidityLease_opt.map(_.willFund),
requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding,
)
val fundingParams = InteractiveTxParams(
channelId = d.channelId,
isInitiator = false,
localContribution = spliceAck.fundingContribution,
remoteContribution = msg.fundingContribution,
sharedInput_opt = Some(Multisig2of2Input(parentCommitment)),
remoteFundingPubKey = msg.fundingPubKey,
localOutputs = Nil,
lockTime = msg.lockTime,
dustLimit = d.commitments.params.localParams.dustLimit.max(d.commitments.params.remoteParams.dustLimit),
targetFeerate = msg.feerate,
requireConfirmedInputs = RequireConfirmedInputs(forLocal = msg.requireConfirmedInputs, forRemote = spliceAck.requireConfirmedInputs)
)
val sessionId = randomBytes32()
val txBuilder = context.spawnAnonymous(InteractiveTxBuilder(
sessionId,
nodeParams, fundingParams,
channelParams = d.commitments.params,
purpose = InteractiveTxBuilder.SpliceTx(parentCommitment),
localPushAmount = spliceAck.pushAmount, remotePushAmount = msg.pushAmount,
liquidityPurchased_opt = liquidityLease_opt.map(l => LiquidityAds.LiquidityPurchased(isBuyer = false, l.lease)),
wallet
))
txBuilder ! InteractiveTxBuilder.Start(self)
stay() using d.copy(spliceStatus = SpliceStatus.SpliceInProgress(cmd_opt = None, sessionId, txBuilder, remoteCommitSig = None)) sending spliceAck
val fundingScript = Funding.makeFundingPubKeyScript(localFundingPubKey, msg.fundingPubKey)
LiquidityAds.offerLease_opt(nodeParams, d.channelId, fundingScript, msg.feerate, msg.requestFunds_opt) match {
case Left(t) =>
log.info("rejecting splice request: {}", t.getMessage)
stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, t.getMessage)
case Right(liquidityLease_opt) =>
val spliceAck = SpliceAck(d.channelId,
fundingContribution = liquidityLease_opt.map(_.lease.amount).getOrElse(0.sat),
fundingPubKey = localFundingPubKey,
pushAmount = 0.msat,
addFunding_opt = liquidityLease_opt.map(_.willFund),
requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding,
)
val fundingParams = InteractiveTxParams(
channelId = d.channelId,
isInitiator = false,
localContribution = spliceAck.fundingContribution,
remoteContribution = msg.fundingContribution,
sharedInput_opt = Some(Multisig2of2Input(parentCommitment)),
remoteFundingPubKey = msg.fundingPubKey,
localOutputs = Nil,
lockTime = msg.lockTime,
dustLimit = d.commitments.params.localParams.dustLimit.max(d.commitments.params.remoteParams.dustLimit),
targetFeerate = msg.feerate,
requireConfirmedInputs = RequireConfirmedInputs(forLocal = msg.requireConfirmedInputs, forRemote = spliceAck.requireConfirmedInputs)
)
val sessionId = randomBytes32()
val txBuilder = context.spawnAnonymous(InteractiveTxBuilder(
sessionId,
nodeParams, fundingParams,
channelParams = d.commitments.params,
purpose = InteractiveTxBuilder.SpliceTx(parentCommitment),
localPushAmount = spliceAck.pushAmount, remotePushAmount = msg.pushAmount,
liquidityPurchased_opt = liquidityLease_opt.map(_.lease),
wallet
))
txBuilder ! InteractiveTxBuilder.Start(self)
stay() using d.copy(spliceStatus = SpliceStatus.SpliceInProgress(cmd_opt = None, sessionId, txBuilder, remoteCommitSig = None)) sending spliceAck
}
}
case SpliceStatus.SpliceAborted =>
log.info("rejecting splice attempt: our previous tx_abort was not acked")
Expand Down Expand Up @@ -1010,20 +1016,21 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
targetFeerate = spliceInit.feerate,
requireConfirmedInputs = RequireConfirmedInputs(forLocal = msg.requireConfirmedInputs, forRemote = spliceInit.requireConfirmedInputs)
)
LiquidityAds.validateLeaseRates_opt(remoteNodeId, d.channelId, msg.fundingPubKey, msg.fundingContribution, spliceInit.feerate, msg.willFund_opt, cmd.requestRemoteFunding_opt) match {
val fundingScript = Funding.makeFundingPubKeyScript(spliceInit.fundingPubKey, msg.fundingPubKey)
LiquidityAds.validateLease_opt(cmd.requestRemoteFunding_opt, remoteNodeId, d.channelId, fundingScript, msg.fundingContribution, spliceInit.feerate, msg.willFund_opt) match {
case Left(error) =>
log.info("rejecting splice attempt: invalid lease rates")
log.info("rejecting splice attempt: {}", error.getMessage)
cmd.replyTo ! RES_FAILURE(cmd, error)
stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, error.getMessage)
case Right(lease_opt) =>
case Right(liquidityLease_opt) =>
val sessionId = randomBytes32()
val txBuilder = context.spawnAnonymous(InteractiveTxBuilder(
sessionId,
nodeParams, fundingParams,
channelParams = d.commitments.params,
purpose = InteractiveTxBuilder.SpliceTx(parentCommitment),
localPushAmount = cmd.pushAmount, remotePushAmount = msg.pushAmount,
liquidityPurchased_opt = lease_opt.map(lease => LiquidityAds.LiquidityPurchased(isBuyer = true, lease)),
liquidityLease_opt,
wallet
))
txBuilder ! InteractiveTxBuilder.Start(self)
Expand Down
Loading

0 comments on commit c10aa95

Please sign in to comment.