Skip to content

Commit

Permalink
Add support for experimental liquidity ads
Browse files Browse the repository at this point in the history
The initiator of `open_channel2`, `tx_init_rbf` and `splice_init` can
request funding from the remote node. The non-initiator node will:

- let the open-channel-interceptor plugin decide whether to lease
  liquidity for new channels or not, and how much
- always honor liquidity requests on existing channels (RBF and splice)

We currently don't modify commitment transactions to enforce the lease.
This is different from lightning/bolts#878 and
instead matches lightning/bolts#1145.

We currently use the temporary tlv tag 1337 while we're waiting for
feedback on our spec proposal.

Liquidity ads are included in the `node_announcement` message, which
lets buyers compare sellers and connect to sellers that provide rates
they are comfortable with.

We store every liquidity purchase (whether we're buyer or seller) in the
audit DB. This is important information when choosing which peers are
worth keeping channels with.
  • Loading branch information
t-bast committed Mar 29, 2024
1 parent c8184b3 commit 74f2c0d
Show file tree
Hide file tree
Showing 72 changed files with 1,578 additions and 454 deletions.
12 changes: 10 additions & 2 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@

## Major changes

<insert changes>
### Liquidity Ads

This release includes an early prototype for [liquidity ads](https://github.com/lightning/bolts/pull/878).
Liquidity ads allow nodes to rent their liquidity in a trustless and decentralized manner.
Every node advertizes the rates at which they lease their liquidity, and buyers connect to sellers that offer interesting rates.

The liquidity ads specification is still under review and will likely change.
This feature isn't meant to be used on mainnet yet and is thus disabled by default.

### API changes

<insert changes>
- `nodes` allows filtering nodes that offer liquidity ads (#2550)
- `open` allows requesting inbound liquidity from the remote node using liquidity ads (#2550)

### Miscellaneous improvements and bug fixes

Expand Down
31 changes: 31 additions & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,37 @@ eclair {
enabled = true // enable automatic purges of expired invoices from the database
interval = 24 hours // interval between expired invoice purges
}

// Liquidity Ads allow remote nodes to pay us to provide them with inbound liquidity.
liquidity-ads {
// 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%
}
]
}
}

akka {
Expand Down
25 changes: 14 additions & 11 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,13 @@ trait Eclair {

def disconnect(nodeId: PublicKey)(implicit timeout: Timeout): Future[String]

def open(nodeId: PublicKey, fundingAmount: Satoshi, pushAmount_opt: Option[MilliSatoshi], channelType_opt: Option[SupportedChannelType], fundingFeerate_opt: Option[FeeratePerByte], fundingFeeBudget_opt: Option[Satoshi], announceChannel_opt: Option[Boolean], openTimeout_opt: Option[Timeout])(implicit timeout: Timeout): Future[OpenChannelResponse]
def open(nodeId: PublicKey, fundingAmount: Satoshi, pushAmount_opt: Option[MilliSatoshi], channelType_opt: Option[SupportedChannelType], fundingFeerate_opt: Option[FeeratePerByte], fundingFeeBudget_opt: Option[Satoshi], requestRemoteFunding_opt: Option[LiquidityAds.RequestRemoteFundingParams], announceChannel_opt: Option[Boolean], openTimeout_opt: Option[Timeout])(implicit timeout: Timeout): Future[OpenChannelResponse]

def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]]
def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, requestRemoteFunding_opt: Option[LiquidityAds.RequestRemoteFundingParams], lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]]

def spliceIn(channelId: ByteVector32, amountIn: Satoshi, pushAmount_opt: Option[MilliSatoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]]
def spliceIn(channelId: ByteVector32, amountIn: Satoshi, pushAmount_opt: Option[MilliSatoshi], requestRemoteFunding_opt: Option[LiquidityAds.RequestRemoteFundingParams])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]]

def spliceOut(channelId: ByteVector32, amountOut: Satoshi, scriptOrAddress: Either[ByteVector, String])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]]
def spliceOut(channelId: ByteVector32, amountOut: Satoshi, scriptOrAddress: Either[ByteVector, String], requestRemoteFunding_opt: Option[LiquidityAds.RequestRemoteFundingParams])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]]

def close(channels: List[ApiTypes.ChannelIdentifier], scriptPubKey_opt: Option[ByteVector], closingFeerates_opt: Option[ClosingFeerates])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_CLOSE]]]]

Expand Down Expand Up @@ -206,7 +206,7 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
(appKit.switchboard ? Peer.Disconnect(nodeId)).mapTo[Peer.DisconnectResponse].map(_.toString)
}

override def open(nodeId: PublicKey, fundingAmount: Satoshi, pushAmount_opt: Option[MilliSatoshi], channelType_opt: Option[SupportedChannelType], fundingFeerate_opt: Option[FeeratePerByte], fundingFeeBudget_opt: Option[Satoshi], announceChannel_opt: Option[Boolean], openTimeout_opt: Option[Timeout])(implicit timeout: Timeout): Future[OpenChannelResponse] = {
override def open(nodeId: PublicKey, fundingAmount: Satoshi, pushAmount_opt: Option[MilliSatoshi], channelType_opt: Option[SupportedChannelType], fundingFeerate_opt: Option[FeeratePerByte], fundingFeeBudget_opt: Option[Satoshi], requestRemoteFunding_opt: Option[LiquidityAds.RequestRemoteFundingParams], announceChannel_opt: Option[Boolean], openTimeout_opt: Option[Timeout])(implicit timeout: Timeout): Future[OpenChannelResponse] = {
// we want the open timeout to expire *before* the default ask timeout, otherwise user will get a generic response
val openTimeout = openTimeout_opt.getOrElse(Timeout(20 seconds))
// if no budget is provided for the mining fee of the funding tx, we use a default of 0.1% of the funding amount as a safety measure
Expand All @@ -220,26 +220,28 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
pushAmount_opt = pushAmount_opt,
fundingTxFeerate_opt = fundingFeerate_opt.map(FeeratePerKw(_)),
fundingTxFeeBudget_opt = Some(fundingFeeBudget),
requestRemoteFunding_opt = requestRemoteFunding_opt.map(_.withLeaseStart(appKit.nodeParams.currentBlockHeight)),
channelFlags_opt = announceChannel_opt.map(announceChannel => ChannelFlags(announceChannel = announceChannel)),
timeout_opt = Some(openTimeout))
res <- (appKit.switchboard ? open).mapTo[OpenChannelResponse]
} yield res
}

override def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]] = {
override def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, requestRemoteFunding_opt: Option[LiquidityAds.RequestRemoteFundingParams], lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]] = {
sendToChannelTyped(channel = Left(channelId),
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong)))
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong), requestRemoteFunding_opt.map(_.withLeaseStart(appKit.nodeParams.currentBlockHeight))))
}

override def spliceIn(channelId: ByteVector32, amountIn: Satoshi, pushAmount_opt: Option[MilliSatoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
override def spliceIn(channelId: ByteVector32, amountIn: Satoshi, pushAmount_opt: Option[MilliSatoshi], requestRemoteFunding_opt: Option[LiquidityAds.RequestRemoteFundingParams])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
sendToChannelTyped(channel = Left(channelId),
cmdBuilder = CMD_SPLICE(_,
spliceIn_opt = Some(SpliceIn(additionalLocalFunding = amountIn, pushAmount = pushAmount_opt.getOrElse(0.msat))),
spliceOut_opt = None
spliceOut_opt = None,
requestRemoteFunding_opt = requestRemoteFunding_opt.map(_.withLeaseStart(appKit.nodeParams.currentBlockHeight)),
))
}

override def spliceOut(channelId: ByteVector32, amountOut: Satoshi, scriptOrAddress: Either[ByteVector, String])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
override def spliceOut(channelId: ByteVector32, amountOut: Satoshi, scriptOrAddress: Either[ByteVector, String], requestRemoteFunding_opt: Option[LiquidityAds.RequestRemoteFundingParams])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
val script = scriptOrAddress match {
case Left(script) => script
case Right(address) => addressToPublicKeyScript(this.appKit.nodeParams.chainHash, address) match {
Expand All @@ -250,7 +252,8 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
sendToChannelTyped(channel = Left(channelId),
cmdBuilder = CMD_SPLICE(_,
spliceIn_opt = None,
spliceOut_opt = Some(SpliceOut(amount = amountOut, scriptPubKey = script))
spliceOut_opt = Some(SpliceOut(amount = amountOut, scriptPubKey = script)),
requestRemoteFunding_opt = requestRemoteFunding_opt.map(_.withLeaseStart(appKit.nodeParams.currentBlockHeight)),
))
}

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

val nodeId: PublicKey = nodeKeyManager.nodeId
Expand Down Expand Up @@ -610,7 +611,24 @@ object NodeParams extends Logging {
revokedHtlcInfoCleanerConfig = RevokedHtlcInfoCleaner.Config(
batchSize = config.getInt("db.revoked-htlc-info-cleaner.batch-size"),
interval = FiniteDuration(config.getDuration("db.revoked-htlc-info-cleaner.interval").getSeconds, TimeUnit.SECONDS)
)
),
liquidityAdsConfig_opt = if (config.getBoolean("liquidity-ads.enabled")) {
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
},
)
}
}
4 changes: 2 additions & 2 deletions eclair-core/src/main/scala/fr/acinq/eclair/PluginParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi}
import fr.acinq.eclair.channel.Origin
import fr.acinq.eclair.io.OpenChannelInterceptor.{DefaultParams, OpenChannelNonInitiator}
import fr.acinq.eclair.payment.relay.PostRestartHtlcCleaner.IncomingHtlc
import fr.acinq.eclair.wire.protocol.Error
import fr.acinq.eclair.wire.protocol.{Error, LiquidityAds}

/** Custom plugin parameters. */
trait PluginParams {
Expand Down Expand Up @@ -67,7 +67,7 @@ case class InterceptOpenChannelReceived(replyTo: ActorRef[InterceptOpenChannelRe
}

sealed trait InterceptOpenChannelResponse
case class AcceptOpenChannel(temporaryChannelId: ByteVector32, defaultParams: DefaultParams, localFundingAmount_opt: Option[Satoshi]) extends InterceptOpenChannelResponse
case class AcceptOpenChannel(temporaryChannelId: ByteVector32, defaultParams: DefaultParams, addFunding_opt: Option[LiquidityAds.AddFunding]) extends InterceptOpenChannelResponse
case class RejectOpenChannel(temporaryChannelId: ByteVector32, error: Error) extends InterceptOpenChannelResponse
// @formatter:on

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import fr.acinq.eclair.io.Peer
import fr.acinq.eclair.payment.OutgoingPaymentPacket.Upstream
import fr.acinq.eclair.transactions.CommitmentSpec
import fr.acinq.eclair.transactions.Transactions._
import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelReady, ChannelReestablish, ChannelUpdate, ClosingSigned, CommitSig, FailureMessage, FundingCreated, FundingSigned, Init, OnionRoutingPacket, OpenChannel, OpenDualFundedChannel, Shutdown, SpliceInit, Stfu, TxSignatures, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFulfillHtlc}
import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelReady, ChannelReestablish, ChannelUpdate, ClosingSigned, CommitSig, FailureMessage, FundingCreated, FundingSigned, Init, LiquidityAds, OnionRoutingPacket, OpenChannel, OpenDualFundedChannel, Shutdown, SpliceInit, Stfu, TxSignatures, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFulfillHtlc}
import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Features, InitFeature, MilliSatoshi, MilliSatoshiLong, RealShortChannelId, UInt64}
import scodec.bits.ByteVector

Expand Down Expand Up @@ -99,6 +99,7 @@ case class INPUT_INIT_CHANNEL_INITIATOR(temporaryChannelId: ByteVector32,
fundingTxFeeBudget_opt: Option[Satoshi],
pushAmount_opt: Option[MilliSatoshi],
requireConfirmedInputs: Boolean,
requestRemoteFunding_opt: Option[LiquidityAds.RequestRemoteFunding],
localParams: LocalParams,
remote: ActorRef,
remoteInit: Init,
Expand All @@ -110,7 +111,7 @@ case class INPUT_INIT_CHANNEL_INITIATOR(temporaryChannelId: ByteVector32,
require(!(channelType.features.contains(Features.ScidAlias) && channelFlags.announceChannel), "option_scid_alias is not compatible with public channels")
}
case class INPUT_INIT_CHANNEL_NON_INITIATOR(temporaryChannelId: ByteVector32,
fundingContribution_opt: Option[Satoshi],
fundingContribution_opt: Option[LiquidityAds.AddFunding],
dualFunded: Boolean,
pushAmount_opt: Option[MilliSatoshi],
localParams: LocalParams,
Expand Down Expand Up @@ -208,10 +209,10 @@ final case class CMD_CLOSE(replyTo: ActorRef, scriptPubKey: Option[ByteVector],
final case class CMD_FORCECLOSE(replyTo: ActorRef) extends CloseCommand
final case class CMD_BUMP_FORCE_CLOSE_FEE(replyTo: akka.actor.typed.ActorRef[CommandResponse[CMD_BUMP_FORCE_CLOSE_FEE]], confirmationTarget: ConfirmationTarget) extends Command

final case class CMD_BUMP_FUNDING_FEE(replyTo: akka.actor.typed.ActorRef[CommandResponse[CMD_BUMP_FUNDING_FEE]], targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime: Long) extends Command
final case class CMD_BUMP_FUNDING_FEE(replyTo: akka.actor.typed.ActorRef[CommandResponse[CMD_BUMP_FUNDING_FEE]], targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime: Long, requestRemoteFunding_opt: Option[LiquidityAds.RequestRemoteFunding]) extends Command
case class SpliceIn(additionalLocalFunding: Satoshi, pushAmount: MilliSatoshi = 0 msat)
case class SpliceOut(amount: Satoshi, scriptPubKey: ByteVector)
final case class CMD_SPLICE(replyTo: akka.actor.typed.ActorRef[CommandResponse[CMD_SPLICE]], spliceIn_opt: Option[SpliceIn], spliceOut_opt: Option[SpliceOut]) extends Command {
final case class CMD_SPLICE(replyTo: akka.actor.typed.ActorRef[CommandResponse[CMD_SPLICE]], spliceIn_opt: Option[SpliceIn], spliceOut_opt: Option[SpliceOut], requestRemoteFunding_opt: Option[LiquidityAds.RequestRemoteFunding]) extends Command {
require(spliceIn_opt.isDefined || spliceOut_opt.isDefined, "there must be a splice-in or a splice-out")
val additionalLocalFunding: Satoshi = spliceIn_opt.map(_.additionalLocalFunding).getOrElse(0 sat)
val pushAmount: MilliSatoshi = spliceIn_opt.map(_.pushAmount).getOrElse(0 msat)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,11 @@ package fr.acinq.eclair.channel

import akka.actor.ActorRef
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, Transaction}
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, Transaction, TxId}
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.channel.Helpers.Closing.ClosingType
import fr.acinq.eclair.io.Peer.OpenChannelResponse
import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelUpdate}
import fr.acinq.eclair.{BlockHeight, Features, ShortChannelId}
import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelUpdate, LiquidityAds}
import fr.acinq.eclair.{BlockHeight, Features, MilliSatoshi, ShortChannelId}

/**
* Created by PM on 17/08/2016.
Expand Down Expand Up @@ -79,6 +78,14 @@ case class ChannelSignatureSent(channel: ActorRef, commitments: Commitments) ext

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

case class LiquidityPurchase(fundingTxId: TxId, fundingTxIndex: Long, isBuyer: Boolean, lease: LiquidityAds.Lease, capacity: Satoshi, localContribution: Satoshi, remoteContribution: Satoshi, localBalance: MilliSatoshi, remoteBalance: MilliSatoshi, outgoingHtlcCount: Long, incomingHtlcCount: Long) {
val previousCapacity: Satoshi = capacity - localContribution - remoteContribution
val previousLocalBalance: MilliSatoshi = if (isBuyer) localBalance - localContribution + lease.fees.total else localBalance - localContribution - lease.fees.total
val previousRemoteBalance: MilliSatoshi = if (isBuyer) remoteBalance - remoteContribution + lease.fees.total else remoteBalance - remoteContribution - lease.fees.total
}

case class ChannelLiquidityPurchased(channel: ActorRef, channelId: ByteVector32, remoteNodeId: PublicKey, purchase: LiquidityPurchase) extends ChannelEvent

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

// NB: the fee should be set to 0 when we're not paying it.
Expand Down
Loading

0 comments on commit 74f2c0d

Please sign in to comment.