Skip to content

Commit a615961

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 9fe82b0 commit a615961

File tree

14 files changed

+222
-97
lines changed

14 files changed

+222
-97
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

@@ -38,6 +85,7 @@ Eclair will not allow remote peers to open new obsolete channels that do not sup
3885
- `channelstats` now takes optional parameters `--count` and `--skip` to control pagination. By default, it will return first 10 entries. (#2890)
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)
88+
- `open`, `rbfopen`, `splicein` and `spliceout` now take an optional `--requestFundingSatoshis` parameter to purchase liquidity from the remote node. (#2926)
4189

4290
### Miscellaneous improvements and bug fixes
4391

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}
@@ -86,13 +86,13 @@ trait Eclair {
8686

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

89-
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]
89+
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]
9090

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

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

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

9797
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]]]]
9898

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

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

230-
override def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]] = {
231-
sendToChannelTyped(channel = Left(channelId),
232-
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong), requestFunding_opt = None))
230+
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]] = {
231+
for {
232+
purchaseFunding_opt <- createLiquidityRequest(channelId, requestFunding_opt)
233+
res <- sendToChannelTyped[CMD_BUMP_FUNDING_FEE, CommandResponse[CMD_BUMP_FUNDING_FEE]](
234+
channel = Left(channelId),
235+
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong), purchaseFunding_opt)
236+
)
237+
} yield res
233238
}
234239

235-
override def spliceIn(channelId: ByteVector32, amountIn: Satoshi, pushAmount_opt: Option[MilliSatoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
236-
sendToChannelTyped(channel = Left(channelId),
237-
cmdBuilder = CMD_SPLICE(_,
238-
spliceIn_opt = Some(SpliceIn(additionalLocalFunding = amountIn, pushAmount = pushAmount_opt.getOrElse(0.msat))),
239-
spliceOut_opt = None,
240-
requestFunding_opt = None,
241-
))
240+
override def spliceIn(channelId: ByteVector32, amountIn: Satoshi, requestFunding_opt: Option[Satoshi], pushAmount_opt: Option[MilliSatoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
241+
for {
242+
purchaseFunding_opt <- createLiquidityRequest(channelId, requestFunding_opt)
243+
spliceIn_opt = if (amountIn > 0.sat) Some(SpliceIn(additionalLocalFunding = amountIn, pushAmount = pushAmount_opt.getOrElse(0 msat))) else None
244+
res <- sendToChannelTyped[CMD_SPLICE, CommandResponse[CMD_SPLICE]](
245+
channel = Left(channelId),
246+
cmdBuilder = CMD_SPLICE(_,
247+
spliceIn_opt = spliceIn_opt,
248+
spliceOut_opt = None,
249+
requestFunding_opt = purchaseFunding_opt,
250+
)
251+
)
252+
} yield res
242253
}
243254

244-
override def spliceOut(channelId: ByteVector32, amountOut: Satoshi, scriptOrAddress: Either[ByteVector, String])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
255+
override def spliceOut(channelId: ByteVector32, amountOut: Satoshi, scriptOrAddress: Either[ByteVector, String], requestFunding_opt: Option[Satoshi])(implicit timeout: Timeout): Future[CommandResponse[CMD_SPLICE]] = {
245256
val script = scriptOrAddress match {
246257
case Left(script) => script
247258
case Right(address) => addressToPublicKeyScript(this.appKit.nodeParams.chainHash, address) match {
248259
case Left(failure) => throw new IllegalArgumentException(failure.toString)
249260
case Right(script) => Script.write(script)
250261
}
251262
}
252-
sendToChannelTyped(channel = Left(channelId),
253-
cmdBuilder = CMD_SPLICE(_,
254-
spliceIn_opt = None,
255-
spliceOut_opt = Some(SpliceOut(amount = amountOut, scriptPubKey = script)),
256-
requestFunding_opt = None,
257-
))
263+
for {
264+
purchaseFunding_opt <- createLiquidityRequest(channelId, requestFunding_opt)
265+
spliceOut_opt = if (amountOut > 0.sat) Some(SpliceOut(amount = amountOut, scriptPubKey = script)) else None
266+
res <- sendToChannelTyped[CMD_SPLICE, CommandResponse[CMD_SPLICE]](
267+
channel = Left(channelId),
268+
cmdBuilder = CMD_SPLICE(_,
269+
spliceIn_opt = None,
270+
spliceOut_opt = spliceOut_opt,
271+
requestFunding_opt = purchaseFunding_opt,
272+
)
273+
)
274+
} yield res
258275
}
259276

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

636+
private def createLiquidityRequest(nodeId: PublicKey, requestedAmount_opt: Option[Satoshi])(implicit timeout: Timeout): Future[Option[LiquidityAds.RequestFunding]] = {
637+
requestedAmount_opt match {
638+
case Some(requestedAmount) =>
639+
getLiquidityRate(nodeId, requestedAmount)
640+
.map(fundingRate => Some(LiquidityAds.RequestFunding(requestedAmount, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance)))
641+
case None => Future.successful(Option.empty[LiquidityAds.RequestFunding])
642+
}
643+
}
644+
645+
private def createLiquidityRequest(channelId: ByteVector32, requestedAmount_opt: Option[Satoshi])(implicit timeout: Timeout): Future[Option[LiquidityAds.RequestFunding]] = {
646+
requestedAmount_opt match {
647+
case Some(requestedAmount) =>
648+
channelInfo(Left(channelId)).map(_.nodeId)
649+
.flatMap(nodeId => getLiquidityRate(nodeId, requestedAmount))
650+
.map(fundingRate => Some(LiquidityAds.RequestFunding(requestedAmount, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance)))
651+
case None => Future.successful(Option.empty[LiquidityAds.RequestFunding])
652+
}
653+
}
654+
655+
private def getLiquidityRate(nodeId: PublicKey, requestedAmount: Satoshi)(implicit timeout: Timeout): Future[LiquidityAds.FundingRate] = {
656+
appKit.switchboard.toTyped.ask[Peer.PeerInfoResponse] { replyTo =>
657+
Switchboard.GetPeerInfo(replyTo, nodeId)
658+
}.map {
659+
case p: PeerInfo => p.fundingRates_opt.flatMap(_.findRate(requestedAmount)) match {
660+
case Some(fundingRate) => fundingRate
661+
case None => throw new RuntimeException(s"peer $nodeId doesn't support funding $requestedAmount, please check their funding rates")
662+
}
663+
case _: Peer.PeerNotFound => throw new RuntimeException(s"peer $nodeId not connected")
664+
}
665+
}
666+
619667
override def getInfo()(implicit timeout: Timeout): Future[GetInfoResponse] = Future.successful(
620668
GetInfoResponse(
621669
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
@@ -234,7 +234,7 @@ final case class CMD_BUMP_FUNDING_FEE(replyTo: akka.actor.typed.ActorRef[Command
234234
case class SpliceIn(additionalLocalFunding: Satoshi, pushAmount: MilliSatoshi = 0 msat)
235235
case class SpliceOut(amount: Satoshi, scriptPubKey: ByteVector)
236236
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 {
237-
require(spliceIn_opt.isDefined || spliceOut_opt.isDefined, "there must be a splice-in or a splice-out")
237+
require(spliceIn_opt.isDefined || spliceOut_opt.isDefined || requestFunding_opt.isDefined, "there must be a splice-in, a splice-out or a liquidity purchase")
238238
val additionalLocalFunding: Satoshi = spliceIn_opt.map(_.additionalLocalFunding).getOrElse(0 sat)
239239
val pushAmount: MilliSatoshi = spliceIn_opt.map(_.pushAmount).getOrElse(0 msat)
240240
val spliceOutputs: List[TxOut] = spliceOut_opt.toList.map(s => TxOut(s.amount, s.scriptPubKey))

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,14 +207,14 @@ private class OpenChannelInterceptor(peer: ActorRef[Any],
207207
nodeParams.pluginOpenChannelInterceptor match {
208208
case Some(plugin) => queryPlugin(plugin, request, localParams, ChannelConfig.standard, channelType)
209209
case None =>
210+
val addFunding_opt = request.open.fold(_ => None, _.requestFunding_opt).map(requestFunding => LiquidityAds.AddFunding(requestFunding.requestedAmount, nodeParams.willFundRates_opt))
210211
request.open.fold(_ => None, _.requestFunding_opt) match {
211212
case Some(requestFunding) if Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.OnTheFlyFunding) && localParams.paysCommitTxFees =>
212-
val addFunding = LiquidityAds.AddFunding(requestFunding.requestedAmount, nodeParams.willFundRates_opt)
213-
val accept = SpawnChannelNonInitiator(request.open, ChannelConfig.standard, channelType, Some(addFunding), localParams, request.peerConnection.toClassic)
213+
val accept = SpawnChannelNonInitiator(request.open, ChannelConfig.standard, channelType, addFunding_opt, localParams, request.peerConnection.toClassic)
214214
checkNoExistingChannel(request, accept)
215215
case _ =>
216-
// We don't honor liquidity ads for new channels: node operators should use plugin for that.
217-
peer ! SpawnChannelNonInitiator(request.open, ChannelConfig.standard, channelType, addFunding_opt = None, localParams, request.peerConnection.toClassic)
216+
// TODO: we must change the utxo locking behavior before releasing that change to protect against liquidity griefing.
217+
peer ! SpawnChannelNonInitiator(request.open, ChannelConfig.standard, channelType, addFunding_opt, localParams, request.peerConnection.toClassic)
218218
waitForRequest()
219219
}
220220
}

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
@@ -529,8 +529,8 @@ class Peer(val nodeParams: NodeParams,
529529
case Event(r: GetPeerInfo, d) =>
530530
val replyTo = r.replyTo.getOrElse(sender().toTyped)
531531
val peerInfo = d match {
532-
case c: ConnectedData => PeerInfo(self, remoteNodeId, stateName, Some(c.remoteFeatures), Some(c.address), c.channels.values.toSet)
533-
case _ => PeerInfo(self, remoteNodeId, stateName, None, None, d.channels.values.toSet)
532+
case c: ConnectedData => PeerInfo(self, remoteNodeId, stateName, Some(c.remoteFeatures), c.remoteInit.fundingRates_opt, Some(c.address), c.channels.values.toSet)
533+
case _ => PeerInfo(self, remoteNodeId, stateName, None, None, None, d.channels.values.toSet)
534534
}
535535
replyTo ! peerInfo
536536
stay()
@@ -963,7 +963,7 @@ object Peer {
963963

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

969969
/** 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)