Skip to content

Commit

Permalink
Add support for extensible 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 provide
  liquidity for new channels or not, and how much
- always honor liquidity requests on existing channels (RBF and splice)
  when funding rates have been configured

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. They are also included in the `init` message
which allows providing different rates to specific peers.

This implements lightning/bolts#1153. We
currently use the temporary tlv tag 1339 while we're waiting for
feedback on the spec proposal.
  • Loading branch information
t-bast committed Jun 14, 2024
1 parent 8da0fc6 commit e23a6f5
Show file tree
Hide file tree
Showing 58 changed files with 1,322 additions and 322 deletions.
11 changes: 10 additions & 1 deletion docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,23 @@

## Major changes

### Liquidity Ads

This release includes an early prototype for [liquidity ads](https://github.com/lightning/bolts/pull/1153).
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.

### Update minimal version of Bitcoin Core

With this release, eclair requires using Bitcoin Core 26.1.
Newer versions of Bitcoin Core may be used, but haven't been extensively tested.

### API changes

<insert changes>
- `nodes` allows filtering nodes that offer liquidity ads (#2848)

### Miscellaneous improvements and bug fixes

Expand Down
32 changes: 32 additions & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,38 @@ eclair {
update-fee-min-diff-ratio = 0.1
}

// Liquidity Ads allow remote nodes to pay us to provide them with inbound liquidity.
liquidity-ads {
// Multiple funding rates can be provided, for different funding amounts.
funding-rates = []
// Sample funding rates:
// 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 lease request
// fee-basis-points = 250 // proportional fee based on the amount requested by our peer (2.5%)
// },
// {
// min-funding-amount-satoshis = 500000
// max-funding-amount-satoshis = 5000000
// funding-weight = 750
// fee-base-satoshis = 1000
// fee-basis-points = 200 // 2%
// }
// ]
// Multiple ways of paying the liquidity fees can be provided.
payment-types = [
// Liquidity fees must be paid from the buyer's channel balance during the transaction creation.
// This doesn't involve trust from the buyer or the seller.
"from_channel_balance"
]
}

peer-connection {
auth-timeout = 15 seconds // will disconnect if connection authentication doesn't happen within that timeframe
init-timeout = 15 seconds // will disconnect if initialization doesn't happen within that timeframe
Expand Down
9 changes: 6 additions & 3 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
pushAmount_opt = pushAmount_opt,
fundingTxFeerate_opt = fundingFeerate_opt.map(FeeratePerKw(_)),
fundingTxFeeBudget_opt = Some(fundingFeeBudget),
requestFunding_opt = None,
channelFlags_opt = announceChannel_opt.map(announceChannel => ChannelFlags(announceChannel = announceChannel)),
timeout_opt = Some(openTimeout))
res <- (appKit.switchboard ? open).mapTo[OpenChannelResponse]
Expand All @@ -228,14 +229,15 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {

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)))
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, fundingFeeBudget, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong), requestFunding_opt = None))
}

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
spliceOut_opt = None,
requestFunding_opt = None,
))
}

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)),
requestFunding_opt = None,
))
}

Expand Down
30 changes: 29 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ package fr.acinq.eclair

import com.typesafe.config.{Config, ConfigFactory, ConfigValueType}
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.bitcoin.scalacompat.{Block, BlockHash, Crypto, Satoshi}
import fr.acinq.bitcoin.scalacompat.{Block, BlockHash, Crypto, Satoshi, SatoshiLong}
import fr.acinq.eclair.Setup.Seeds
import fr.acinq.eclair.blockchain.fee._
import fr.acinq.eclair.channel.ChannelFlags
Expand Down Expand Up @@ -88,6 +88,7 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
onionMessageConfig: OnionMessageConfig,
purgeInvoicesInterval: Option[FiniteDuration],
revokedHtlcInfoCleanerConfig: RevokedHtlcInfoCleaner.Config,
willFundRates_opt: Option[LiquidityAds.WillFundRates],
wakeUpTimeout: FiniteDuration) {
val privateKey: Crypto.PrivateKey = nodeKeyManager.nodeKey.privateKey

Expand Down Expand Up @@ -477,6 +478,32 @@ object NodeParams extends Logging {
val maxNoChannels = config.getInt("peer-connection.max-no-channels")
require(maxNoChannels > 0, "peer-connection.max-no-channels must be > 0")

val willFundRates_opt = {
val supportedPaymentTypes = Map(
LiquidityAds.PaymentType.FromChannelBalance.rfcName -> LiquidityAds.PaymentType.FromChannelBalance
)
val paymentTypes: Set[LiquidityAds.PaymentType] = config.getStringList("liquidity-ads.payment-types").asScala.map(s => {
supportedPaymentTypes.get(s) match {
case Some(paymentType) => paymentType
case None => throw new IllegalArgumentException(s"unknown liquidity ads payment type: $s")
}
}).toSet
val fundingRates: List[LiquidityAds.FundingLease] = config.getConfigList("liquidity-ads.funding-rates").asScala.map { r =>
LiquidityAds.FundingLease.Basic(
minAmount = r.getLong("min-funding-amount-satoshis").sat,
maxAmount = r.getLong("max-funding-amount-satoshis").sat,
fundingWeight = r.getInt("funding-weight"),
leaseFeeBase = r.getLong("fee-base-satoshis").sat,
leaseFeeProportional = r.getInt("fee-basis-points")
)
}.toList
if (fundingRates.nonEmpty && paymentTypes.nonEmpty) {
Some(LiquidityAds.WillFundRates(fundingRates, paymentTypes))
} else {
None
}
}

NodeParams(
nodeKeyManager = nodeKeyManager,
channelKeyManager = channelKeyManager,
Expand Down Expand Up @@ -612,6 +639,7 @@ object NodeParams extends Logging {
batchSize = config.getInt("db.revoked-htlc-info-cleaner.batch-size"),
interval = FiniteDuration(config.getDuration("db.revoked-htlc-info-cleaner.interval").getSeconds, TimeUnit.SECONDS)
),
willFundRates_opt = willFundRates_opt,
wakeUpTimeout = 30 seconds,
)
}
Expand Down
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,
requestFunding_opt: Option[LiquidityAds.RequestFunding],
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, requestFunding_opt: Option[LiquidityAds.RequestFunding]) 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], requestFunding_opt: Option[LiquidityAds.RequestFunding]) 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 @@ -19,7 +19,7 @@ package fr.acinq.eclair.channel
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, UpdateAddHtlc}
import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, InteractiveTxMessage, LiquidityAds, UpdateAddHtlc}
import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshi, UInt64}
import scodec.bits.ByteVector

Expand Down Expand Up @@ -51,6 +51,11 @@ case class ToSelfDelayTooHigh (override val channelId: Byte
case class ChannelReserveTooHigh (override val channelId: ByteVector32, channelReserve: Satoshi, reserveToFundingRatio: Double, maxReserveToFundingRatio: Double) extends ChannelException(channelId, s"channelReserve too high: reserve=$channelReserve fundingRatio=$reserveToFundingRatio maxFundingRatio=$maxReserveToFundingRatio")
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 InvalidLiquidityAdsSig (override val channelId: ByteVector32) extends ChannelException(channelId, "liquidity ads signature is invalid")
case class InvalidLiquidityAdsAmount (override val channelId: ByteVector32, proposed: Satoshi, min: Satoshi) extends ChannelException(channelId, "liquidity ads funding amount is too low (expected at least $min, got $proposed)")
case class InvalidLiquidityAdsPaymentType (override val channelId: ByteVector32, proposed: LiquidityAds.PaymentType, allowed: Set[LiquidityAds.PaymentType]) extends ChannelException(channelId, s"liquidity ads ${proposed.rfcName} payment type is not supported (allowed=${allowed.map(_.rfcName).mkString(", ")})")
case class InvalidLiquidityAdsLease (override val channelId: ByteVector32) extends ChannelException(channelId, "liquidity ads selected lease rate does not match a lease we offer")
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
Loading

0 comments on commit e23a6f5

Please sign in to comment.