Skip to content

Commit

Permalink
Add APIs to purchase inbound liquidity
Browse files Browse the repository at this point in the history
We allow purchasing liquidity from nodes that advertise liquidity ads
by opening new channels or using a splice on an existing channel.
  • Loading branch information
t-bast committed Oct 10, 2024
1 parent 9fe82b0 commit a615961
Show file tree
Hide file tree
Showing 14 changed files with 222 additions and 97 deletions.
54 changes: 51 additions & 3 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,59 @@

### Liquidity Ads

This release includes an early prototype for [liquidity ads](https://github.com/lightning/bolts/pull/1153).
This release includes support for the official version of [liquidity ads](https://github.com/lightning/bolts/pull/1153).
Liquidity ads allow nodes to sell their liquidity in a trustless and decentralized manner.
Every node advertizes the rates at which they sell 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.
Node operators who want to sell their liquidity must configure their funding rates in `eclair.conf`:

```conf
eclair.liquidity-ads.funding-rates = [
{
min-funding-amount-satoshis = 100000 // minimum funding amount at this rate
max-funding-amount-satoshis = 500000 // maximum funding amount at this rate
// 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 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 = 1000000
funding-weight = 750
fee-base-satoshis = 1000
fee-basis-points = 200 // 2%
channel-creation-fee-satoshis = 2000
}
]
```

Node operators who want to purchase liquidity from other nodes must first choose a node that sells liquidity.
The `nodes` API can be used to filter nodes that support liquidity ads:

```sh
./eclair-cli nodes --liquidityProvider=true
```

This will return the corresponding `node_announcement`s that contain the nodes' funding rates.
After choosing a seller node, liquidity can be purchased on a new channel:

```sh
./eclair-cli open --nodeId=<seller_node_id> --fundingSatoshis=<local_contribution> --requestFundingSatoshis=<remote_contribution>
```

If the buyer already has a channel with the seller, and if the seller supports splicing, liquidity can be purchased with a splice:

```sh
./eclair-cli splicein --channelId=<channel_id> --amountIn=<amount_in> --requestFundingSatoshis=<remote_contribution>
./eclair-cli spliceout --channelId=<channel_id> --amountOut=<amount_out> --address=<output_address> --requestFundingSatoshis=<remote_contribution>
```

Note that `amountIn` and `amountOut` can be set to `0` when purchasing liquidity without splicing in or out.
It is however more efficient to batch operations and purchase liquidity at the same time as splicing in or out.

### Update minimal version of Bitcoin Core

Expand All @@ -38,6 +85,7 @@ Eclair will not allow remote peers to open new obsolete channels that do not sup
- `channelstats` now takes optional parameters `--count` and `--skip` to control pagination. By default, it will return first 10 entries. (#2890)
- `createinvoice` now takes an optional `--privateChannelIds` parameter that can be used to add routing hints through private channels. (#2909)
- `nodes` allows filtering nodes that offer liquidity ads (#2848)
- `open`, `rbfopen`, `splicein` and `spliceout` now take an optional `--requestFundingSatoshis` parameter to purchase liquidity from the remote node. (#2926)

### Miscellaneous improvements and bug fixes

Expand Down
98 changes: 73 additions & 25 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import akka.pattern._
import akka.util.Timeout
import com.softwaremill.quicklens.ModifyPimp
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, ByteVector64, Crypto, OutPoint, Satoshi, Script, TxId, addressToPublicKeyScript}
import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, ByteVector64, Crypto, OutPoint, Satoshi, SatoshiLong, Script, TxId, addressToPublicKeyScript}
import fr.acinq.eclair.ApiTypes.ChannelNotFound
import fr.acinq.eclair.balance.CheckBalance.GlobalBalance
import fr.acinq.eclair.balance.{BalanceActor, ChannelsListener}
Expand Down 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], requestFunding_opt: Option[Satoshi], 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, requestFunding_opt: Option[Satoshi], 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, requestFunding_opt: Option[Satoshi], pushAmount_opt: Option[MilliSatoshi])(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], requestFunding_opt: Option[Satoshi])(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,55 +206,72 @@ 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], requestFunding_opt: Option[Satoshi], 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
val fundingFeeBudget = fundingFeeBudget_opt.getOrElse(fundingAmount * 0.001)
for {
_ <- Future.successful(0)
purchaseFunding_opt <- createLiquidityRequest(nodeId, requestFunding_opt)
open = Peer.OpenChannel(
remoteNodeId = nodeId,
fundingAmount = fundingAmount,
channelType_opt = channelType_opt,
pushAmount_opt = pushAmount_opt,
fundingTxFeerate_opt = fundingFeerate_opt.map(FeeratePerKw(_)),
fundingTxFeeBudget_opt = Some(fundingFeeBudget),
requestFunding_opt = None,
requestFunding_opt = purchaseFunding_opt,
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]] = {
sendToChannelTyped(channel = Left(channelId),
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong), requestFunding_opt = None))
override def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, requestFunding_opt: Option[Satoshi], lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]] = {
for {
purchaseFunding_opt <- createLiquidityRequest(channelId, requestFunding_opt)
res <- sendToChannelTyped[CMD_BUMP_FUNDING_FEE, CommandResponse[CMD_BUMP_FUNDING_FEE]](
channel = Left(channelId),
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong), purchaseFunding_opt)
)
} yield res
}

override def spliceIn(channelId: ByteVector32, amountIn: Satoshi, pushAmount_opt: Option[MilliSatoshi])(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,
requestFunding_opt = None,
))
override def spliceIn(channelId: ByteVector32, amountIn: Satoshi, requestFunding_opt: Option[Satoshi], pushAmount_opt: Option[MilliSatoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
for {
purchaseFunding_opt <- createLiquidityRequest(channelId, requestFunding_opt)
spliceIn_opt = if (amountIn > 0.sat) Some(SpliceIn(additionalLocalFunding = amountIn, pushAmount = pushAmount_opt.getOrElse(0 msat))) else None
res <- sendToChannelTyped[CMD_SPLICE, CommandResponse[CMD_SPLICE]](
channel = Left(channelId),
cmdBuilder = CMD_SPLICE(_,
spliceIn_opt = spliceIn_opt,
spliceOut_opt = None,
requestFunding_opt = purchaseFunding_opt,
)
)
} yield res
}

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], requestFunding_opt: Option[Satoshi])(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 {
case Left(failure) => throw new IllegalArgumentException(failure.toString)
case Right(script) => Script.write(script)
}
}
sendToChannelTyped(channel = Left(channelId),
cmdBuilder = CMD_SPLICE(_,
spliceIn_opt = None,
spliceOut_opt = Some(SpliceOut(amount = amountOut, scriptPubKey = script)),
requestFunding_opt = None,
))
for {
purchaseFunding_opt <- createLiquidityRequest(channelId, requestFunding_opt)
spliceOut_opt = if (amountOut > 0.sat) Some(SpliceOut(amount = amountOut, scriptPubKey = script)) else None
res <- sendToChannelTyped[CMD_SPLICE, CommandResponse[CMD_SPLICE]](
channel = Left(channelId),
cmdBuilder = CMD_SPLICE(_,
spliceIn_opt = None,
spliceOut_opt = spliceOut_opt,
requestFunding_opt = purchaseFunding_opt,
)
)
} yield res
}

override 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 @@ -616,6 +633,37 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
} yield res
}

private def createLiquidityRequest(nodeId: PublicKey, requestedAmount_opt: Option[Satoshi])(implicit timeout: Timeout): Future[Option[LiquidityAds.RequestFunding]] = {
requestedAmount_opt match {
case Some(requestedAmount) =>
getLiquidityRate(nodeId, requestedAmount)
.map(fundingRate => Some(LiquidityAds.RequestFunding(requestedAmount, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance)))
case None => Future.successful(Option.empty[LiquidityAds.RequestFunding])
}
}

private def createLiquidityRequest(channelId: ByteVector32, requestedAmount_opt: Option[Satoshi])(implicit timeout: Timeout): Future[Option[LiquidityAds.RequestFunding]] = {
requestedAmount_opt match {
case Some(requestedAmount) =>
channelInfo(Left(channelId)).map(_.nodeId)
.flatMap(nodeId => getLiquidityRate(nodeId, requestedAmount))
.map(fundingRate => Some(LiquidityAds.RequestFunding(requestedAmount, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance)))
case None => Future.successful(Option.empty[LiquidityAds.RequestFunding])
}
}

private def getLiquidityRate(nodeId: PublicKey, requestedAmount: Satoshi)(implicit timeout: Timeout): Future[LiquidityAds.FundingRate] = {
appKit.switchboard.toTyped.ask[Peer.PeerInfoResponse] { replyTo =>
Switchboard.GetPeerInfo(replyTo, nodeId)
}.map {
case p: PeerInfo => p.fundingRates_opt.flatMap(_.findRate(requestedAmount)) match {
case Some(fundingRate) => fundingRate
case None => throw new RuntimeException(s"peer $nodeId doesn't support funding $requestedAmount, please check their funding rates")
}
case _: Peer.PeerNotFound => throw new RuntimeException(s"peer $nodeId not connected")
}
}

override def getInfo()(implicit timeout: Timeout): Future[GetInfoResponse] = Future.successful(
GetInfoResponse(
version = Kit.getVersionLong,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ final case class CMD_BUMP_FUNDING_FEE(replyTo: akka.actor.typed.ActorRef[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], requestFunding_opt: Option[LiquidityAds.RequestFunding]) extends Command {
require(spliceIn_opt.isDefined || spliceOut_opt.isDefined, "there must be a splice-in or a splice-out")
require(spliceIn_opt.isDefined || spliceOut_opt.isDefined || requestFunding_opt.isDefined, "there must be a splice-in, a splice-out or a liquidity purchase")
val additionalLocalFunding: Satoshi = spliceIn_opt.map(_.additionalLocalFunding).getOrElse(0 sat)
val pushAmount: MilliSatoshi = spliceIn_opt.map(_.pushAmount).getOrElse(0 msat)
val spliceOutputs: List[TxOut] = spliceOut_opt.toList.map(s => TxOut(s.amount, s.scriptPubKey))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,14 +207,14 @@ private class OpenChannelInterceptor(peer: ActorRef[Any],
nodeParams.pluginOpenChannelInterceptor match {
case Some(plugin) => queryPlugin(plugin, request, localParams, ChannelConfig.standard, channelType)
case None =>
val addFunding_opt = request.open.fold(_ => None, _.requestFunding_opt).map(requestFunding => LiquidityAds.AddFunding(requestFunding.requestedAmount, nodeParams.willFundRates_opt))
request.open.fold(_ => None, _.requestFunding_opt) match {
case Some(requestFunding) if Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.OnTheFlyFunding) && localParams.paysCommitTxFees =>
val addFunding = LiquidityAds.AddFunding(requestFunding.requestedAmount, nodeParams.willFundRates_opt)
val accept = SpawnChannelNonInitiator(request.open, ChannelConfig.standard, channelType, Some(addFunding), localParams, request.peerConnection.toClassic)
val accept = SpawnChannelNonInitiator(request.open, ChannelConfig.standard, channelType, addFunding_opt, localParams, request.peerConnection.toClassic)
checkNoExistingChannel(request, accept)
case _ =>
// We don't honor liquidity ads for new channels: node operators should use plugin for that.
peer ! SpawnChannelNonInitiator(request.open, ChannelConfig.standard, channelType, addFunding_opt = None, localParams, request.peerConnection.toClassic)
// TODO: we must change the utxo locking behavior before releasing that change to protect against liquidity griefing.
peer ! SpawnChannelNonInitiator(request.open, ChannelConfig.standard, channelType, addFunding_opt, localParams, request.peerConnection.toClassic)
waitForRequest()
}
}
Expand Down
6 changes: 3 additions & 3 deletions eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -529,8 +529,8 @@ class Peer(val nodeParams: NodeParams,
case Event(r: GetPeerInfo, d) =>
val replyTo = r.replyTo.getOrElse(sender().toTyped)
val peerInfo = d match {
case c: ConnectedData => PeerInfo(self, remoteNodeId, stateName, Some(c.remoteFeatures), Some(c.address), c.channels.values.toSet)
case _ => PeerInfo(self, remoteNodeId, stateName, None, None, d.channels.values.toSet)
case c: ConnectedData => PeerInfo(self, remoteNodeId, stateName, Some(c.remoteFeatures), c.remoteInit.fundingRates_opt, Some(c.address), c.channels.values.toSet)
case _ => PeerInfo(self, remoteNodeId, stateName, None, None, None, d.channels.values.toSet)
}
replyTo ! peerInfo
stay()
Expand Down Expand Up @@ -963,7 +963,7 @@ object Peer {

case class GetPeerInfo(replyTo: Option[typed.ActorRef[PeerInfoResponse]])
sealed trait PeerInfoResponse { def nodeId: PublicKey }
case class PeerInfo(peer: ActorRef, nodeId: PublicKey, state: State, features: Option[Features[InitFeature]], address: Option[NodeAddress], channels: Set[ActorRef]) extends PeerInfoResponse
case class PeerInfo(peer: ActorRef, nodeId: PublicKey, state: State, features: Option[Features[InitFeature]], fundingRates_opt: Option[LiquidityAds.WillFundRates], address: Option[NodeAddress], channels: Set[ActorRef]) extends PeerInfoResponse
case class PeerNotFound(nodeId: PublicKey) extends PeerInfoResponse with DisconnectResponse { override def toString: String = s"peer $nodeId not found" }

/** Return the peer's current channels: note that the data may change concurrently, never assume it is fully up-to-date. */
Expand Down
Loading

0 comments on commit a615961

Please sign in to comment.