Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
t-bast committed Nov 22, 2023
1 parent 1ad7a50 commit cc62bb5
Show file tree
Hide file tree
Showing 19 changed files with 485 additions and 125 deletions.
33 changes: 33 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Liquidity Ads

## Tasks

- when leasing liquidity:
- ensure we don't raise our relay fees above what was negotiated while the lease is active
- disallow mutual close and force-close commands during the lease
- disallow splice-out during the lease
- change the format of commit txs
- use a dedicated bitcoin wallet, on which we never lock utxos (abstract it away at the `BitcoinCoreClient` level)
- when buying liquidity:
- when doing an RBF, must pay for the lease `fundingWeight` at the new feerate (add test)
- verify our peer doesn't raise their relay fees above what was negotiated: if they do, send a `warning` and log it
- ignore remote `shutdown` messages? Send a `warning` and log?
- when the lease expires, "clean up" commitment tx?
- probably requires an `end_lease` message?
- lease renewal mechanism:
- unnecessary, it's just a splice that uses the `request_funds` tlv?

## Liquidity ads plugin

Implement the business logic that decides how much to contribute when accepting a `request_funds` in a plugin.
Requires validation of the following fields from `open_channel2`:

- feerate -> it shouldn't be too high unless the buyer is paying for our inputs
- lockTime -> must not be too far away in the future and must match the lease start
- remote amount must allow paying the lease fees and the commit tx fees

## Spec feedback

- restore base routing fee field
- specify RBF behavior
- HTLC output timelocks?
1 change: 1 addition & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ This feature leaks a bit of information about the balance when the channel is al

- `bumpforceclose` can be used to make a force-close confirm faster, by spending the anchor output (#2743)
- `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
28 changes: 15 additions & 13 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,8 @@ import fr.acinq.eclair.balance.CheckBalance.GlobalBalance
import fr.acinq.eclair.balance.{BalanceActor, ChannelsListener}
import fr.acinq.eclair.blockchain.OnChainWallet.OnChainBalance
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.WalletTx
import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerByte, FeeratePerKw}
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{Descriptors, WalletTx}
import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerByte, FeeratePerKw}
import fr.acinq.eclair.channel._
import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.db.AuditDb.{NetworkFee, Stats}
Expand Down Expand Up @@ -87,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], fundingFeeratePerByte_opt: Option[FeeratePerByte], 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], fundingFeeratePerByte_opt: Option[FeeratePerByte], requestRemoteFunding_opt: Option[LiquidityAds.RequestRemoteFundingParams], announceChannel_opt: Option[Boolean], openTimeout_opt: Option[Timeout])(implicit timeout: Timeout): Future[OpenChannelResponse]

def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]]
def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, 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 @@ -205,7 +204,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], fundingFeeratePerByte_opt: Option[FeeratePerByte], 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], fundingFeeratePerByte_opt: Option[FeeratePerByte], 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))
for {
Expand All @@ -216,26 +215,28 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
channelType_opt = channelType_opt,
pushAmount_opt = pushAmount_opt,
fundingTxFeerate_opt = fundingFeeratePerByte_opt.map(FeeratePerKw(_)),
requestRemoteFunding_opt = requestRemoteFunding_opt.map(_.withDuration(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, lockTime_opt: Option[Long])(implicit timeout: Timeout): Future[CommandResponse[CMD_BUMP_FUNDING_FEE]] = {
override def rbfOpen(channelId: ByteVector32, targetFeerate: FeeratePerKw, 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, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong)))
cmdBuilder = CMD_BUMP_FUNDING_FEE(_, targetFeerate, lockTime_opt.getOrElse(appKit.nodeParams.currentBlockHeight.toLong), requestRemoteFunding_opt.map(_.withDuration(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(_.withDuration(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 @@ -246,7 +247,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(_.withDuration(appKit.nodeParams.currentBlockHeight)),
))
}

Expand Down
4 changes: 2 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 @@ -96,7 +96,7 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,

val pluginMessageTags: Set[Int] = pluginParams.collect { case p: CustomFeaturePlugin => p.messageTags }.toSet.flatten

val pluginOpenChannelInterceptor: Option[InterceptOpenChannelPlugin] = pluginParams.collectFirst { case p: InterceptOpenChannelPlugin => p }
val channelFundingInterceptor: Option[InterceptChannelFundingPlugin] = pluginParams.collectFirst { case p: InterceptChannelFundingPlugin => p }

val liquidityRates_opt: Option[LiquidityAds.LeaseRates] = liquidityAdsConfig_opt.map(_.leaseRates(relayParams.defaultFees(announceChannel = true)))

Expand Down Expand Up @@ -362,7 +362,7 @@ object NodeParams extends Logging {
require(Features.knownFeatures.map(_.mandatory).intersect(pluginFeatureSet).isEmpty, "Plugin feature bit overlaps with known feature bit")
require(pluginFeatureSet.size == pluginMessageParams.size, "Duplicate plugin feature bits found")

val interceptOpenChannelPlugins = pluginParams.collect { case p: InterceptOpenChannelPlugin => p }
val interceptOpenChannelPlugins = pluginParams.collect { case p: InterceptChannelFundingPlugin => p }
require(interceptOpenChannelPlugins.size <= 1, s"At most one plugin is allowed to intercept channel open messages, but multiple such plugins were registered: ${interceptOpenChannelPlugins.map(_.getClass.getSimpleName).mkString(", ")}. Disable conflicting plugins and restart eclair.")

val coreAndPluginFeatures: Features[Feature] = features.copy(unknown = features.unknown ++ pluginMessageParams.map(_.pluginFeature))
Expand Down
47 changes: 33 additions & 14 deletions eclair-core/src/main/scala/fr/acinq/eclair/PluginParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ package fr.acinq.eclair

import akka.actor.typed.ActorRef
import akka.event.LoggingAdapter
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
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.channel.{Commitments, Origin}
import fr.acinq.eclair.io.OpenChannelInterceptor.DefaultParams
import fr.acinq.eclair.payment.relay.PostRestartHtlcCleaner.IncomingHtlc
import fr.acinq.eclair.wire.protocol.Error
import fr.acinq.eclair.wire.protocol._

/** Custom plugin parameters. */
trait PluginParams {
Expand Down Expand Up @@ -59,18 +60,36 @@ trait CustomCommitmentsPlugin extends PluginParams {
def getHtlcsRelayedOut(htlcsIn: Seq[IncomingHtlc], nodeParams: NodeParams, log: LoggingAdapter): Map[Origin, Set[(ByteVector32, Long)]]
}

// @formatter:off
trait InterceptOpenChannelCommand
case class InterceptOpenChannelReceived(replyTo: ActorRef[InterceptOpenChannelResponse], openChannelNonInitiator: OpenChannelNonInitiator, defaultParams: DefaultParams) extends InterceptOpenChannelCommand {
val remoteFundingAmount: Satoshi = openChannelNonInitiator.open.fold(_.fundingSatoshis, _.fundingAmount)
val temporaryChannelId: ByteVector32 = openChannelNonInitiator.open.fold(_.temporaryChannelId, _.temporaryChannelId)
/**
* Plugins implementing this trait can intercept funding attempts initiated by a remote peer:
* - new channel creation
* - splicing on an existing channel
* - RBF attempt (on new channel creation or splice)
*
* Plugins can either accept or reject the funding attempt, and decide to contribute some funds.
*/
trait InterceptChannelFundingPlugin extends PluginParams {
def channelFundingInterceptor: ActorRef[InterceptChannelFundingPlugin.Command]
}

sealed trait InterceptOpenChannelResponse
case class AcceptOpenChannel(temporaryChannelId: ByteVector32, defaultParams: DefaultParams) extends InterceptOpenChannelResponse
case class RejectOpenChannel(temporaryChannelId: ByteVector32, error: Error) extends InterceptOpenChannelResponse
// @formatter:on
object InterceptChannelFundingPlugin {
/** Details about the remote peer. */
case class PeerDetails(nodeId: PublicKey, features: Features[InitFeature], address: NodeAddress)

// @formatter:off
sealed trait Command {
def peer: PeerDetails
}
case class InterceptOpenChannelAttempt(replyTo: ActorRef[InterceptOpenChannelResponse], peer: PeerDetails, open: Either[OpenChannel, OpenDualFundedChannel], defaultParams: DefaultParams) extends Command {
val temporaryChannelId: ByteVector32 = open.fold(_.temporaryChannelId, _.temporaryChannelId)
val remoteFundingAmount: Satoshi = open.fold(_.fundingSatoshis, _.fundingAmount)
}
case class InterceptChannelFundingAttempt(replyTo: ActorRef[InterceptChannelFundingAttemptResponse], peer: PeerDetails, request: Either[TxInitRbf, SpliceInit], commitments: Commitments) extends Command

trait InterceptOpenChannelPlugin extends PluginParams {
def openChannelInterceptor: ActorRef[InterceptOpenChannelCommand]
sealed trait InterceptOpenChannelResponse
case class AcceptOpenChannelAttempt(temporaryChannelId: ByteVector32, defaultParams: DefaultParams, addFunding_opt: Option[LiquidityAds.AddFunding]) extends InterceptOpenChannelResponse
sealed trait InterceptChannelFundingAttemptResponse
case class AcceptChannelFundingAttempt(addFunding_opt: Option[LiquidityAds.AddFunding]) extends InterceptChannelFundingAttemptResponse
case class RejectAttempt(reason: String) extends InterceptOpenChannelResponse with InterceptChannelFundingAttemptResponse
// @formatter:on
}
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 @@ -97,6 +97,7 @@ case class INPUT_INIT_CHANNEL_INITIATOR(temporaryChannelId: ByteVector32,
commitTxFeerate: FeeratePerKw,
fundingTxFeerate: FeeratePerKw,
pushAmount_opt: Option[MilliSatoshi],
requestRemoteFunding_opt: Option[LiquidityAds.RequestRemoteFunding],
requireConfirmedInputs: Boolean,
localParams: LocalParams,
remote: ActorRef,
Expand All @@ -109,7 +110,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 @@ -207,10 +208,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, lockTime: Long) extends Command
final case class CMD_BUMP_FUNDING_FEE(replyTo: akka.actor.typed.ActorRef[CommandResponse[CMD_BUMP_FUNDING_FEE]], targetFeerate: FeeratePerKw, 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
Loading

0 comments on commit cc62bb5

Please sign in to comment.