Skip to content

Commit 1382cd4

Browse files
committed
Add APIs to purchase inbound liquidity
We allow purchasing liquidity from nodes that advertise liquidity ads by opening new channels or using a splice on an existing channel.
1 parent aa71036 commit 1382cd4

File tree

13 files changed

+218
-93
lines changed

13 files changed

+218
-93
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,59 @@
66

77
### Liquidity Ads
88

9-
This release includes an early prototype for [liquidity ads](https://github.com/lightning/bolts/pull/1153).
9+
This release includes support for the official version of [liquidity ads](https://github.com/lightning/bolts/pull/1153).
1010
Liquidity ads allow nodes to sell their liquidity in a trustless and decentralized manner.
1111
Every node advertizes the rates at which they sell their liquidity, and buyers connect to sellers that offer interesting rates.
1212

13-
The liquidity ads specification is still under review and will likely change.
14-
This feature isn't meant to be used on mainnet yet and is thus disabled by default.
13+
Node operators who want to sell their liquidity must configure their funding rates in `eclair.conf`:
14+
15+
```conf
16+
eclair.liquidity-ads.funding-rates = [
17+
{
18+
min-funding-amount-satoshis = 100000 // minimum funding amount at this rate
19+
max-funding-amount-satoshis = 500000 // maximum funding amount at this rate
20+
// The seller can ask the buyer to pay for some of the weight of the funding transaction (for the inputs and
21+
// outputs added by the seller). This field contains the transaction weight (in vbytes) that the seller asks the
22+
// buyer to pay for. The default value matches the weight of one p2wpkh input with one p2wpkh change output.
23+
funding-weight = 400
24+
fee-base-satoshis = 500 // flat fee that we will receive every time we accept a liquidity request
25+
fee-basis-points = 250 // proportional fee based on the amount requested by our peer (2.5%)
26+
channel-creation-fee-satoshis = 2500 // flat fee that is added when creating a new channel
27+
},
28+
{
29+
min-funding-amount-satoshis = 500000
30+
max-funding-amount-satoshis = 1000000
31+
funding-weight = 750
32+
fee-base-satoshis = 1000
33+
fee-basis-points = 200 // 2%
34+
channel-creation-fee-satoshis = 2000
35+
}
36+
]
37+
```
38+
39+
Node operators who want to purchase liquidity from other nodes must first choose a node that sells liquidity.
40+
The `nodes` API can be used to filter nodes that support liquidity ads:
41+
42+
```sh
43+
./eclair-cli nodes --liquidityProvider=true
44+
```
45+
46+
This will return the corresponding `node_announcement`s that contain the nodes' funding rates.
47+
After choosing a seller node, liquidity can be purchased on a new channel:
48+
49+
```sh
50+
./eclair-cli open --nodeId=<seller_node_id> --fundingSatoshis=<local_contribution> --requestFundingSatoshis=<remote_contribution>
51+
```
52+
53+
If the buyer already has a channel with the seller, and if the seller supports splicing, liquidity can be purchased with a splice:
54+
55+
```sh
56+
./eclair-cli splicein --channelId=<channel_id> --amountIn=<amount_in> --requestFundingSatoshis=<remote_contribution>
57+
./eclair-cli spliceout --channelId=<channel_id> --amountOut=<amount_out> --address=<output_address> --requestFundingSatoshis=<remote_contribution>
58+
```
59+
60+
Note that `amountIn` and `amountOut` can be set to `0` when purchasing liquidity without splicing in or out.
61+
It is however more efficient to batch operations and purchase liquidity at the same time as splicing in or out.
1562

1663
### Update minimal version of Bitcoin Core
1764

@@ -39,6 +86,7 @@ Eclair will not allow remote peers to open new obsolete channels that do not sup
3986
- `createinvoice` now takes an optional `--privateChannelIds` parameter that can be used to add routing hints through private channels. (#2909)
4087
- `nodes` allows filtering nodes that offer liquidity ads (#2848)
4188
- `rbfsplice` lets any channel participant RBF the current unconfirmed splice transaction (#2887)
89+
- `open`, `rbfopen`, `splicein` and `spliceout` now take an optional `--requestFundingSatoshis` parameter to purchase liquidity from the remote node. (#2926)
4290

4391
### Miscellaneous improvements and bug fixes
4492

eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import akka.pattern._
2424
import akka.util.Timeout
2525
import com.softwaremill.quicklens.ModifyPimp
2626
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
27-
import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, ByteVector64, Crypto, OutPoint, Satoshi, Script, TxId, addressToPublicKeyScript}
27+
import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, ByteVector64, Crypto, OutPoint, Satoshi, SatoshiLong, Script, TxId, addressToPublicKeyScript}
2828
import fr.acinq.eclair.ApiTypes.ChannelNotFound
2929
import fr.acinq.eclair.balance.CheckBalance.GlobalBalance
3030
import fr.acinq.eclair.balance.{BalanceActor, ChannelsListener}
@@ -88,13 +88,13 @@ trait Eclair {
8888

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

91-
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]
91+
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]
9292

93-
def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]]
93+
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]]
9494

95-
def spliceIn(channelId: ByteVector32, amountIn: Satoshi, pushAmount_opt: Option[MilliSatoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]]
95+
def spliceIn(channelId: ByteVector32, amountIn: Satoshi, requestFunding_opt: Option[Satoshi], pushAmount_opt: Option[MilliSatoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]]
9696

97-
def spliceOut(channelId: ByteVector32, amountOut: Satoshi, scriptOrAddress: Either[ByteVector, String])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]]
97+
def spliceOut(channelId: ByteVector32, amountOut: Satoshi, scriptOrAddress: Either[ByteVector, String], requestFunding_opt: Option[Satoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]]
9898

9999
def rbfSplice(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]]
100100

@@ -212,55 +212,72 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
212212
(appKit.switchboard ? Peer.Disconnect(nodeId)).mapTo[Peer.DisconnectResponse].map(_.toString)
213213
}
214214

215-
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] = {
215+
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] = {
216216
// we want the open timeout to expire *before* the default ask timeout, otherwise user will get a generic response
217217
val openTimeout = openTimeout_opt.getOrElse(Timeout(20 seconds))
218218
// 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
219219
val fundingFeeBudget = fundingFeeBudget_opt.getOrElse(fundingAmount * 0.001)
220220
for {
221-
_ <- Future.successful(0)
221+
purchaseFunding_opt <- createLiquidityRequest(nodeId, requestFunding_opt)
222222
open = Peer.OpenChannel(
223223
remoteNodeId = nodeId,
224224
fundingAmount = fundingAmount,
225225
channelType_opt = channelType_opt,
226226
pushAmount_opt = pushAmount_opt,
227227
fundingTxFeerate_opt = fundingFeerate_opt.map(FeeratePerKw(_)),
228228
fundingTxFeeBudget_opt = Some(fundingFeeBudget),
229-
requestFunding_opt = None,
229+
requestFunding_opt = purchaseFunding_opt,
230230
channelFlags_opt = announceChannel_opt.map(announceChannel => ChannelFlags(announceChannel = announceChannel)),
231231
timeout_opt = Some(openTimeout))
232232
res <- (appKit.switchboard ? open).mapTo[OpenChannelResponse]
233233
} yield res
234234
}
235235

236-
override def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]] = {
237-
sendToChannelTyped(
238-
channel = Left(channelId),
239-
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong), requestFunding_opt = None)
240-
)
236+
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]] = {
237+
for {
238+
purchaseFunding_opt <- createLiquidityRequest(channelId, requestFunding_opt)
239+
res <- sendToChannelTyped[CMD_BUMP_FUNDING_FEE, CommandResponse[CMD_BUMP_FUNDING_FEE]](
240+
channel = Left(channelId),
241+
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong), purchaseFunding_opt)
242+
)
243+
} yield res
241244
}
242245

243-
override def spliceIn(channelId: ByteVector32, amountIn: Satoshi, pushAmount_opt: Option[MilliSatoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
244-
val spliceIn = SpliceIn(additionalLocalFunding = amountIn, pushAmount = pushAmount_opt.getOrElse(0.msat))
245-
sendToChannelTyped(
246-
channel = Left(channelId),
247-
cmdBuilder = CMD_SPLICE(_, spliceIn_opt = Some(spliceIn), spliceOut_opt = None, requestFunding_opt = None)
248-
)
246+
override def spliceIn(channelId: ByteVector32, amountIn: Satoshi, requestFunding_opt: Option[Satoshi], pushAmount_opt: Option[MilliSatoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
247+
for {
248+
purchaseFunding_opt <- createLiquidityRequest(channelId, requestFunding_opt)
249+
spliceIn_opt = if (amountIn > 0.sat) Some(SpliceIn(additionalLocalFunding = amountIn, pushAmount = pushAmount_opt.getOrElse(0 msat))) else None
250+
res <- sendToChannelTyped[CMD_SPLICE, CommandResponse[CMD_SPLICE]](
251+
channel = Left(channelId),
252+
cmdBuilder = CMD_SPLICE(_,
253+
spliceIn_opt = spliceIn_opt,
254+
spliceOut_opt = None,
255+
requestFunding_opt = purchaseFunding_opt,
256+
)
257+
)
258+
} yield res
249259
}
250260

251-
override def spliceOut(channelId: ByteVector32, amountOut: Satoshi, scriptOrAddress: Either[ByteVector, String])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
261+
override def spliceOut(channelId: ByteVector32, amountOut: Satoshi, scriptOrAddress: Either[ByteVector, String], requestFunding_opt: Option[Satoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
252262
val script = scriptOrAddress match {
253263
case Left(script) => script
254264
case Right(address) => addressToPublicKeyScript(this.appKit.nodeParams.chainHash, address) match {
255265
case Left(failure) => throw new IllegalArgumentException(failure.toString)
256266
case Right(script) => Script.write(script)
257267
}
258268
}
259-
val spliceOut = SpliceOut(amount = amountOut, scriptPubKey = script)
260-
sendToChannelTyped(
261-
channel = Left(channelId),
262-
cmdBuilder = CMD_SPLICE(_, spliceIn_opt = None, spliceOut_opt = Some(spliceOut), requestFunding_opt = None)
263-
)
269+
for {
270+
purchaseFunding_opt <- createLiquidityRequest(channelId, requestFunding_opt)
271+
spliceOut_opt = if (amountOut > 0.sat) Some(SpliceOut(amount = amountOut, scriptPubKey = script)) else None
272+
res <- sendToChannelTyped[CMD_SPLICE, CommandResponse[CMD_SPLICE]](
273+
channel = Left(channelId),
274+
cmdBuilder = CMD_SPLICE(_,
275+
spliceIn_opt = None,
276+
spliceOut_opt = spliceOut_opt,
277+
requestFunding_opt = purchaseFunding_opt,
278+
)
279+
)
280+
} yield res
264281
}
265282

266283
override def rbfSplice(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]] = {
@@ -629,6 +646,37 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
629646
} yield res
630647
}
631648

649+
private def createLiquidityRequest(nodeId: PublicKey, requestedAmount_opt: Option[Satoshi])(implicit timeout: Timeout): Future[Option[LiquidityAds.RequestFunding]] = {
650+
requestedAmount_opt match {
651+
case Some(requestedAmount) =>
652+
getLiquidityRate(nodeId, requestedAmount)
653+
.map(fundingRate => Some(LiquidityAds.RequestFunding(requestedAmount, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance)))
654+
case None => Future.successful(Option.empty[LiquidityAds.RequestFunding])
655+
}
656+
}
657+
658+
private def createLiquidityRequest(channelId: ByteVector32, requestedAmount_opt: Option[Satoshi])(implicit timeout: Timeout): Future[Option[LiquidityAds.RequestFunding]] = {
659+
requestedAmount_opt match {
660+
case Some(requestedAmount) =>
661+
channelInfo(Left(channelId)).map(_.nodeId)
662+
.flatMap(nodeId => getLiquidityRate(nodeId, requestedAmount))
663+
.map(fundingRate => Some(LiquidityAds.RequestFunding(requestedAmount, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance)))
664+
case None => Future.successful(Option.empty[LiquidityAds.RequestFunding])
665+
}
666+
}
667+
668+
private def getLiquidityRate(nodeId: PublicKey, requestedAmount: Satoshi)(implicit timeout: Timeout): Future[LiquidityAds.FundingRate] = {
669+
appKit.switchboard.toTyped.ask[Peer.PeerInfoResponse] { replyTo =>
670+
Switchboard.GetPeerInfo(replyTo, nodeId)
671+
}.map {
672+
case p: PeerInfo => p.fundingRates_opt.flatMap(_.findRate(requestedAmount)) match {
673+
case Some(fundingRate) => fundingRate
674+
case None => throw new RuntimeException(s"peer $nodeId doesn't support funding $requestedAmount, please check their funding rates")
675+
}
676+
case _: Peer.PeerNotFound => throw new RuntimeException(s"peer $nodeId not connected")
677+
}
678+
}
679+
632680
override def getInfo()(implicit timeout: Timeout): Future[GetInfoResponse] = Future.successful(
633681
GetInfoResponse(
634682
version = Kit.getVersionLong,

eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ sealed trait ChannelFundingCommand extends Command {
236236
case class SpliceIn(additionalLocalFunding: Satoshi, pushAmount: MilliSatoshi = 0 msat)
237237
case class SpliceOut(amount: Satoshi, scriptPubKey: ByteVector)
238238
final case class CMD_SPLICE(replyTo: akka.actor.typed.ActorRef[CommandResponse[ChannelFundingCommand]], spliceIn_opt: Option[SpliceIn], spliceOut_opt: Option[SpliceOut], requestFunding_opt: Option[LiquidityAds.RequestFunding]) extends ChannelFundingCommand {
239-
require(spliceIn_opt.isDefined || spliceOut_opt.isDefined, "there must be a splice-in or a splice-out")
239+
require(spliceIn_opt.isDefined || spliceOut_opt.isDefined || requestFunding_opt.isDefined, "there must be a splice-in, a splice-out or a liquidity purchase")
240240
val additionalLocalFunding: Satoshi = spliceIn_opt.map(_.additionalLocalFunding).getOrElse(0 sat)
241241
val pushAmount: MilliSatoshi = spliceIn_opt.map(_.pushAmount).getOrElse(0 msat)
242242
val spliceOutputs: List[TxOut] = spliceOut_opt.toList.map(s => TxOut(s.amount, s.scriptPubKey))

eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -536,8 +536,8 @@ class Peer(val nodeParams: NodeParams,
536536
case Event(r: GetPeerInfo, d) =>
537537
val replyTo = r.replyTo.getOrElse(sender().toTyped)
538538
val peerInfo = d match {
539-
case c: ConnectedData => PeerInfo(self, remoteNodeId, stateName, Some(c.remoteFeatures), Some(c.address), c.channels.values.toSet)
540-
case _ => PeerInfo(self, remoteNodeId, stateName, None, None, d.channels.values.toSet)
539+
case c: ConnectedData => PeerInfo(self, remoteNodeId, stateName, Some(c.remoteFeatures), c.remoteInit.fundingRates_opt, Some(c.address), c.channels.values.toSet)
540+
case _ => PeerInfo(self, remoteNodeId, stateName, None, None, None, d.channels.values.toSet)
541541
}
542542
replyTo ! peerInfo
543543
stay()
@@ -994,7 +994,7 @@ object Peer {
994994

995995
case class GetPeerInfo(replyTo: Option[typed.ActorRef[PeerInfoResponse]])
996996
sealed trait PeerInfoResponse { def nodeId: PublicKey }
997-
case class PeerInfo(peer: ActorRef, nodeId: PublicKey, state: State, features: Option[Features[InitFeature]], address: Option[NodeAddress], channels: Set[ActorRef]) extends PeerInfoResponse
997+
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
998998
case class PeerNotFound(nodeId: PublicKey) extends PeerInfoResponse with DisconnectResponse { override def toString: String = s"peer $nodeId not found" }
999999

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

0 commit comments

Comments
 (0)