From cc62bb5a06199bb94d03dbb38a6c68a1d2679ce7 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 22 Nov 2023 12:01:14 +0100 Subject: [PATCH] WIP --- TODO.md | 33 +++++ docs/release-notes/eclair-vnext.md | 1 + .../main/scala/fr/acinq/eclair/Eclair.scala | 28 +++-- .../scala/fr/acinq/eclair/NodeParams.scala | 4 +- .../scala/fr/acinq/eclair/PluginParams.scala | 47 ++++--- .../fr/acinq/eclair/channel/ChannelData.scala | 9 +- .../eclair/channel/ChannelExceptions.scala | 7 +- .../fr/acinq/eclair/channel/Helpers.scala | 15 ++- .../fr/acinq/eclair/channel/fsm/Channel.scala | 49 +++++--- .../channel/fsm/ChannelOpenDualFunded.scala | 55 +++++--- .../channel/fund/InteractiveTxBuilder.scala | 12 +- .../eclair/io/ChannelFundingInterceptor.scala | 118 ++++++++++++++++++ .../eclair/io/OpenChannelInterceptor.scala | 23 ++-- .../main/scala/fr/acinq/eclair/io/Peer.scala | 42 +++++-- .../wire/protocol/LightningMessageTypes.scala | 24 +++- .../eclair/wire/protocol/LiquidityAds.scala | 72 ++++++++++- .../io/OpenChannelInterceptorSpec.scala | 6 +- .../api/directives/ExtraDirectives.scala | 16 ++- .../acinq/eclair/api/handlers/Channel.scala | 49 ++++---- 19 files changed, 485 insertions(+), 125 deletions(-) create mode 100644 TODO.md create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/io/ChannelFundingInterceptor.scala diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000000..1083164e88 --- /dev/null +++ b/TODO.md @@ -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? diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index ede157a96b..2bcb8aa419 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -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 diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index 86c0ec0162..56dd0ec32c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -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} @@ -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]]]] @@ -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 { @@ -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 { @@ -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)), )) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index bac93cc3a2..1df85651b6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -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))) @@ -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)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/PluginParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/PluginParams.scala index c98f62b587..651f836a5e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/PluginParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/PluginParams.scala @@ -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 { @@ -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 } \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala index 9b946c5092..ebb7249df3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala @@ -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 @@ -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, @@ -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, @@ -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) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala index 54407e9863..9b5b45b343 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala @@ -16,10 +16,10 @@ package fr.acinq.eclair.channel -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Satoshi, Transaction} 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 @@ -51,6 +51,9 @@ 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 LiquidityRatesRejected (override val channelId: ByteVector32) extends ChannelException(channelId, "rejecting liquidity ads proposed rates") 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}") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 348be40c6e..1cf1589fa5 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -218,7 +218,12 @@ object Helpers { } /** Called by the initiator of a dual-funded channel. */ - def validateParamsDualFundedInitiator(nodeParams: NodeParams, channelType: SupportedChannelType, localFeatures: Features[InitFeature], remoteFeatures: Features[InitFeature], open: OpenDualFundedChannel, accept: AcceptDualFundedChannel): Either[ChannelException, (ChannelFeatures, Option[ByteVector])] = { + def validateParamsDualFundedInitiator(nodeParams: NodeParams, + remoteNodeId: PublicKey, + channelType: SupportedChannelType, + localFeatures: Features[InitFeature], remoteFeatures: Features[InitFeature], + open: OpenDualFundedChannel, accept: AcceptDualFundedChannel, + requestedFunds_opt: Option[LiquidityAds.RequestRemoteFunding]): Either[ChannelException, (ChannelFeatures, Option[ByteVector], Option[LiquidityAds.Lease])] = { validateChannelType(open.temporaryChannelId, channelType, open.channelFlags, open.channelType_opt, accept.channelType_opt, localFeatures, remoteFeatures) match { case Some(t) => return Left(t) case None => // we agree on channel type @@ -240,8 +245,14 @@ object Helpers { // MAY reject the channel. if (accept.toSelfDelay > nodeParams.channelConf.maxToLocalDelay) return Left(ToSelfDelayTooHigh(accept.temporaryChannelId, accept.toSelfDelay, nodeParams.channelConf.maxToLocalDelay)) + // If we're purchasing liquidity, verify the liquidity ads: + val liquidityLease_opt = requestedFunds_opt.map(_.validateLeaseRates(remoteNodeId, accept.temporaryChannelId, accept.fundingPubkey, accept.fundingAmount, open.fundingFeerate, accept.willFund_opt) match { + case Left(t) => return Left(t) + case Right(lease) => lease // we agree on liquidity rates, if any + }) + val channelFeatures = ChannelFeatures(channelType, localFeatures, remoteFeatures, open.channelFlags.announceChannel) - extractShutdownScript(accept.temporaryChannelId, localFeatures, remoteFeatures, accept.upfrontShutdownScript_opt).map(script_opt => (channelFeatures, script_opt)) + extractShutdownScript(accept.temporaryChannelId, localFeatures, remoteFeatures, accept.upfrontShutdownScript_opt).map(script_opt => (channelFeatures, script_opt, liquidityLease_opt)) } /** diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 44cb81c11b..4d24327f87 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -175,6 +175,10 @@ object Channel { /** We don't immediately process [[CurrentBlockHeight]] to avoid herd effects */ case class ProcessCurrentBlockHeight(c: CurrentBlockHeight) + case class ReceiveTxInitRbf(txInitRbf: TxInitRbf, addFunding_opt: Option[LiquidityAds.AddFunding]) + + case class ReceiveSpliceInit(spliceInit: SpliceInit, addFunding_opt: Option[LiquidityAds.AddFunding]) + // @formatter:off /** What do we do if we have a local unhandled exception. */ sealed trait UnhandledExceptionStrategy @@ -928,7 +932,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Event(_: QuiescenceTimeout, d: DATA_NORMAL) => handleQuiescenceTimeout(d) - case Event(msg: SpliceInit, d: DATA_NORMAL) => + case Event(ReceiveSpliceInit(msg, addFunding_opt), d: DATA_NORMAL) => d.spliceStatus match { case SpliceStatus.NoSplice | SpliceStatus.NonInitiatorQuiescent => if (!d.commitments.isQuiescent) { @@ -940,11 +944,14 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with } else { log.info(s"accepting splice with remote.in.amount=${msg.fundingContribution} remote.in.push=${msg.pushAmount}") val parentCommitment = d.commitments.latest.commitment + val localFundingPubKey = nodeParams.channelKeyManager.fundingPublicKey(d.commitments.params.localParams.fundingKeyPath, parentCommitment.fundingTxIndex + 1).publicKey + val liquidityLease_opt = addFunding_opt.flatMap(_.signLease(nodeParams.privateKey, localFundingPubKey, msg.feerate, msg.requestFunds_opt)) val spliceAck = SpliceAck(d.channelId, - fundingContribution = 0.sat, // only remote contributes to the splice - fundingPubKey = keyManager.fundingPublicKey(d.commitments.params.localParams.fundingKeyPath, parentCommitment.fundingTxIndex + 1).publicKey, + fundingContribution = addFunding_opt.map(_.fundingAmount).getOrElse(0.sat), + fundingPubKey = localFundingPubKey, pushAmount = 0.msat, - requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding + addFunding_opt = liquidityLease_opt.map { case (willFund, _) => willFund }, + requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding, ) val fundingParams = InteractiveTxParams( channelId = d.channelId, @@ -966,6 +973,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with channelParams = d.commitments.params, purpose = InteractiveTxBuilder.SpliceTx(parentCommitment), localPushAmount = spliceAck.pushAmount, remotePushAmount = msg.pushAmount, + liquidityPurchased_opt = liquidityLease_opt.map { case (_, lease) => LiquidityPurchased(isBuyer = false, lease) }, wallet )) txBuilder ! InteractiveTxBuilder.Start(self) @@ -997,17 +1005,25 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with targetFeerate = spliceInit.feerate, requireConfirmedInputs = RequireConfirmedInputs(forLocal = msg.requireConfirmedInputs, forRemote = spliceInit.requireConfirmedInputs) ) - val sessionId = randomBytes32() - val txBuilder = context.spawnAnonymous(InteractiveTxBuilder( - sessionId, - nodeParams, fundingParams, - channelParams = d.commitments.params, - purpose = InteractiveTxBuilder.SpliceTx(parentCommitment), - localPushAmount = cmd.pushAmount, remotePushAmount = msg.pushAmount, - wallet - )) - txBuilder ! InteractiveTxBuilder.Start(self) - stay() using d.copy(spliceStatus = SpliceStatus.SpliceInProgress(cmd_opt = Some(cmd), sessionId, txBuilder, remoteCommitSig = None)) + LiquidityAds.validateLeaseRates_opt(remoteNodeId, d.channelId, msg.fundingPubKey, msg.fundingContribution, spliceInit.feerate, msg.willFund_opt, cmd.requestRemoteFunding_opt) match { + case Left(error) => + log.info("rejecting splice attempt: invalid lease rates") + cmd.replyTo ! RES_FAILURE(cmd, error) + stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, error.getMessage) + case Right(lease_opt) => + val sessionId = randomBytes32() + val txBuilder = context.spawnAnonymous(InteractiveTxBuilder( + sessionId, + nodeParams, fundingParams, + channelParams = d.commitments.params, + purpose = InteractiveTxBuilder.SpliceTx(parentCommitment), + localPushAmount = cmd.pushAmount, remotePushAmount = msg.pushAmount, + liquidityPurchased_opt = lease_opt.map(lease => LiquidityPurchased(isBuyer = true, lease)), + wallet + )) + txBuilder ! InteractiveTxBuilder.Start(self) + stay() using d.copy(spliceStatus = SpliceStatus.SpliceInProgress(cmd_opt = Some(cmd), sessionId, txBuilder, remoteCommitSig = None)) + } case _ => log.info(s"ignoring unexpected splice_ack=$msg") stay() @@ -2709,7 +2725,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with feerate = targetFeerate, fundingPubKey = keyManager.fundingPublicKey(d.commitments.params.localParams.fundingKeyPath, parentCommitment.fundingTxIndex + 1).publicKey, pushAmount = cmd.pushAmount, - requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding + requestFunding_opt = cmd.requestRemoteFunding_opt.map(_.requestFunds), + requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding, ) Right(spliceInit) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala index 833089a610..52f876723d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala @@ -22,7 +22,7 @@ import fr.acinq.bitcoin.scalacompat.SatoshiLong import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel._ -import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.{FullySignedSharedTransaction, InteractiveTxParams, PartiallySignedSharedTransaction, RequireConfirmedInputs} +import fr.acinq.eclair.channel.fund.InteractiveTxBuilder._ import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxSigningSession} import fr.acinq.eclair.channel.publish.TxPublisher.SetChannelId import fr.acinq.eclair.crypto.ShaChain @@ -110,8 +110,9 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { val tlvs: Set[OpenDualFundedChannelTlv] = Set( upfrontShutdownScript_opt, Some(ChannelTlv.ChannelTypeTlv(input.channelType)), - input.pushAmount_opt.map(amount => ChannelTlv.PushAmountTlv(amount)), if (input.requireConfirmedInputs) Some(ChannelTlv.RequireConfirmedInputsTlv()) else None, + input.requestRemoteFunding_opt.map(_.requestFunds), + input.pushAmount_opt.map(amount => ChannelTlv.PushAmountTlv(amount)), ).flatten val open = OpenDualFundedChannel( chainHash = nodeParams.chainHash, @@ -165,16 +166,18 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { // We've exchanged open_channel2 and accept_channel2, we now know the final channelId. val channelId = Helpers.computeChannelId(open.revocationBasepoint, revocationBasePoint) val channelParams = ChannelParams(channelId, d.init.channelConfig, channelFeatures, localParams, remoteParams, open.channelFlags) - val localAmount = d.init.fundingContribution_opt.getOrElse(0 sat) + val localAmount = d.init.fundingContribution_opt.map(_.fundingAmount).getOrElse(0 sat) val remoteAmount = open.fundingAmount // At this point, the min_depth is an estimate and may change after we know exactly how our peer contributes // to the funding transaction. Maybe they will contribute 0 satoshis to the shared output, but still add inputs // and outputs. val minDepth_opt = channelParams.minDepthFundee(nodeParams.channelConf.minDepthBlocks, localAmount + remoteAmount) val upfrontShutdownScript_opt = localParams.upfrontShutdownScript_opt.map(scriptPubKey => ChannelTlv.UpfrontShutdownScriptTlv(scriptPubKey)) + val liquidityLease_opt = d.init.fundingContribution_opt.flatMap(_.signLease(nodeParams.privateKey, localFundingPubkey, open.fundingFeerate, open.requestFunds_opt)) val tlvs: Set[AcceptDualFundedChannelTlv] = Set( upfrontShutdownScript_opt, Some(ChannelTlv.ChannelTypeTlv(d.init.channelType)), + liquidityLease_opt.map { case (willFund, _) => willFund }, d.init.pushAmount_opt.map(amount => ChannelTlv.PushAmountTlv(amount)), if (nodeParams.channelConf.requireConfirmedInputsForDualFunding) Some(ChannelTlv.RequireConfirmedInputsTlv()) else None, ).flatten @@ -218,6 +221,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { nodeParams, fundingParams, channelParams, purpose, localPushAmount = accept.pushAmount, remotePushAmount = open.pushAmount, + liquidityPurchased_opt = liquidityLease_opt.map { case (_, lease) => LiquidityPurchased(isBuyer = false, lease) }, wallet)) txBuilder ! InteractiveTxBuilder.Start(self) goto(WAIT_FOR_DUAL_FUNDING_CREATED) using DATA_WAIT_FOR_DUAL_FUNDING_CREATED(channelId, channelParams, open.secondPerCommitmentPoint, accept.pushAmount, open.pushAmount, txBuilder, deferred = None, replyTo_opt = None) sending accept @@ -233,11 +237,11 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { when(WAIT_FOR_ACCEPT_DUAL_FUNDED_CHANNEL)(handleExceptions { case Event(accept: AcceptDualFundedChannel, d: DATA_WAIT_FOR_ACCEPT_DUAL_FUNDED_CHANNEL) => import d.init.{localParams, remoteInit} - Helpers.validateParamsDualFundedInitiator(nodeParams, d.init.channelType, localParams.initFeatures, remoteInit.features, d.lastSent, accept) match { + Helpers.validateParamsDualFundedInitiator(nodeParams, remoteNodeId, d.init.channelType, localParams.initFeatures, remoteInit.features, d.lastSent, accept, d.init.requestRemoteFunding_opt) match { case Left(t) => d.init.replyTo ! OpenChannelResponse.Rejected(t.getMessage) handleLocalError(t, d, Some(accept)) - case Right((channelFeatures, remoteShutdownScript)) => + case Right((channelFeatures, remoteShutdownScript, liquidityLease_opt)) => // We've exchanged open_channel2 and accept_channel2, we now know the final channelId. val channelId = Helpers.computeChannelId(d.lastSent.revocationBasepoint, accept.revocationBasepoint) peer ! ChannelIdAssigned(self, remoteNodeId, accept.temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages @@ -281,6 +285,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { nodeParams, fundingParams, channelParams, purpose, localPushAmount = d.lastSent.pushAmount, remotePushAmount = accept.pushAmount, + liquidityPurchased_opt = liquidityLease_opt.map(lease => LiquidityPurchased(isBuyer = true, lease)), wallet)) txBuilder ! InteractiveTxBuilder.Start(self) goto(WAIT_FOR_DUAL_FUNDING_CREATED) using DATA_WAIT_FOR_DUAL_FUNDING_CREATED(channelId, channelParams, accept.secondPerCommitmentPoint, d.lastSent.pushAmount, accept.pushAmount, txBuilder, deferred = None, replyTo_opt = Some(d.init.replyTo)) @@ -500,7 +505,8 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { cmd.replyTo ! RES_FAILURE(cmd, InvalidRbfFeerate(d.channelId, cmd.targetFeerate, minNextFeerate)) stay() } else { - stay() using d.copy(rbfStatus = RbfStatus.RbfRequested(cmd)) sending TxInitRbf(d.channelId, cmd.lockTime, cmd.targetFeerate, d.latestFundingTx.fundingParams.localContribution) + val txInitRbf = TxInitRbf(d.channelId, cmd.lockTime, cmd.targetFeerate, d.latestFundingTx.fundingParams.localContribution, cmd.requestRemoteFunding_opt.map(_.requestFunds)) + stay() using d.copy(rbfStatus = RbfStatus.RbfRequested(cmd)) sending txInitRbf } case _ => log.warning("cannot initiate rbf, another one is already in progress") @@ -509,7 +515,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { } } - case Event(msg: TxInitRbf, d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED) => + case Event(ReceiveTxInitRbf(msg, addFunding_opt), d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED) => val zeroConf = d.commitments.params.minDepthDualFunding(nodeParams.channelConf.minDepthBlocks, d.latestFundingTx.sharedTx.tx).isEmpty if (d.latestFundingTx.fundingParams.isInitiator) { // Only the initiator is allowed to initiate RBF. @@ -538,21 +544,24 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { } else { log.info("our peer wants to raise the feerate of the funding transaction (previous={} target={})", d.latestFundingTx.fundingParams.targetFeerate, msg.feerate) val fundingParams = d.latestFundingTx.fundingParams.copy( - // we don't change our funding contribution + localContribution = addFunding_opt.map(_.fundingAmount).getOrElse(d.latestFundingTx.fundingParams.localContribution), remoteContribution = msg.fundingContribution, lockTime = msg.lockTime, targetFeerate = msg.feerate ) + val localFundingPubKey = nodeParams.channelKeyManager.fundingPublicKey(d.commitments.params.localParams.fundingKeyPath, d.commitments.active.head.fundingTxIndex).publicKey + val liquidityLease_opt = addFunding_opt.flatMap(_.signLease(nodeParams.privateKey, localFundingPubKey, msg.feerate, msg.requestFunds_opt)) val txBuilder = context.spawnAnonymous(InteractiveTxBuilder( randomBytes32(), nodeParams, fundingParams, channelParams = d.commitments.params, purpose = InteractiveTxBuilder.PreviousTxRbf(d.commitments.active.head, 0 msat, 0 msat, previousTransactions = d.allFundingTxs.map(_.sharedTx)), localPushAmount = d.localPushAmount, remotePushAmount = d.remotePushAmount, + liquidityPurchased_opt = liquidityLease_opt.map { case (_, lease) => LiquidityPurchased(isBuyer = false, lease) }, wallet)) txBuilder ! InteractiveTxBuilder.Start(self) val toSend = Seq( - Some(TxAckRbf(d.channelId, fundingParams.localContribution)), + Some(TxAckRbf(d.channelId, fundingParams.localContribution, liquidityLease_opt.map { case (willFund, _) => willFund })), if (remainingRbfAttempts <= 3) Some(Warning(d.channelId, s"will accept at most ${remainingRbfAttempts - 1} future rbf attempts")) else None, ).flatten stay() using d.copy(rbfStatus = RbfStatus.RbfInProgress(cmd_opt = None, txBuilder, remoteCommitSig = None)) sending toSend @@ -581,15 +590,23 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { lockTime = cmd.lockTime, targetFeerate = cmd.targetFeerate, ) - val txBuilder = context.spawnAnonymous(InteractiveTxBuilder( - randomBytes32(), - nodeParams, fundingParams, - channelParams = d.commitments.params, - purpose = InteractiveTxBuilder.PreviousTxRbf(d.commitments.active.head, 0 msat, 0 msat, previousTransactions = d.allFundingTxs.map(_.sharedTx)), - localPushAmount = d.localPushAmount, remotePushAmount = d.remotePushAmount, - wallet)) - txBuilder ! InteractiveTxBuilder.Start(self) - stay() using d.copy(rbfStatus = RbfStatus.RbfInProgress(cmd_opt = Some(cmd), txBuilder, remoteCommitSig = None)) + LiquidityAds.validateLeaseRates_opt(remoteNodeId, d.channelId, fundingParams.remoteFundingPubKey, msg.fundingContribution, cmd.targetFeerate, msg.willFund_opt, cmd.requestRemoteFunding_opt) match { + case Left(error) => + log.info("rejecting rbf attempt: invalid lease rates") + cmd.replyTo ! RES_FAILURE(cmd, error) + stay() using d.copy(rbfStatus = RbfStatus.RbfAborted) sending TxAbort(d.channelId, error.getMessage) + case Right(lease_opt) => + val txBuilder = context.spawnAnonymous(InteractiveTxBuilder( + randomBytes32(), + nodeParams, fundingParams, + channelParams = d.commitments.params, + purpose = InteractiveTxBuilder.PreviousTxRbf(d.commitments.active.head, 0 msat, 0 msat, previousTransactions = d.allFundingTxs.map(_.sharedTx)), + localPushAmount = d.localPushAmount, remotePushAmount = d.remotePushAmount, + liquidityPurchased_opt = lease_opt.map(lease => LiquidityPurchased(isBuyer = true, lease)), + wallet)) + txBuilder ! InteractiveTxBuilder.Start(self) + stay() using d.copy(rbfStatus = RbfStatus.RbfInProgress(cmd_opt = Some(cmd), txBuilder, remoteCommitSig = None)) + } case _ => log.info("ignoring unexpected tx_ack_rbf") stay() sending Warning(d.channelId, UnexpectedInteractiveTxMessage(d.channelId, msg).getMessage) @@ -745,7 +762,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { val d1 = receiveChannelReady(d.shortIds, channelReady, d.commitments) goto(NORMAL) using d1 storing() - case Event(_: TxInitRbf, d: DATA_WAIT_FOR_DUAL_FUNDING_READY) => + case Event(_: ReceiveTxInitRbf, d: DATA_WAIT_FOR_DUAL_FUNDING_READY) => // Our peer may not have received the funding transaction confirmation. stay() sending TxAbort(d.channelId, InvalidRbfTxConfirmed(d.channelId).getMessage) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala index f26c79051a..20fdc26ede 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala @@ -336,6 +336,8 @@ object InteractiveTxBuilder { } // @formatter:on + case class LiquidityPurchased(isBuyer: Boolean, lease: LiquidityAds.Lease) + def apply(sessionId: ByteVector32, nodeParams: NodeParams, fundingParams: InteractiveTxParams, @@ -343,6 +345,7 @@ object InteractiveTxBuilder { purpose: Purpose, localPushAmount: MilliSatoshi, remotePushAmount: MilliSatoshi, + liquidityPurchased_opt: Option[LiquidityPurchased], wallet: OnChainChannelFunder)(implicit ec: ExecutionContext): Behavior[Command] = { Behaviors.setup { context => // The stash is used to buffer messages that arrive while we're funding the transaction. @@ -362,7 +365,7 @@ object InteractiveTxBuilder { replyTo ! LocalFailure(InvalidFundingBalances(channelParams.channelId, fundingParams.fundingAmount, nextLocalBalance, nextRemoteBalance)) Behaviors.stopped } else { - val actor = new InteractiveTxBuilder(replyTo, sessionId, nodeParams, channelParams, fundingParams, purpose, localPushAmount, remotePushAmount, wallet, stash, context) + val actor = new InteractiveTxBuilder(replyTo, sessionId, nodeParams, channelParams, fundingParams, purpose, localPushAmount, remotePushAmount, liquidityPurchased_opt, wallet, stash, context) actor.start() } case Abort => Behaviors.stopped @@ -385,6 +388,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon purpose: Purpose, localPushAmount: MilliSatoshi, remotePushAmount: MilliSatoshi, + liquidityPurchased_opt: Option[InteractiveTxBuilder.LiquidityPurchased], wallet: OnChainChannelFunder, stash: StashBuffer[InteractiveTxBuilder.Command], context: ActorContext[InteractiveTxBuilder.Command])(implicit ec: ExecutionContext) { @@ -739,10 +743,12 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon private def signCommitTx(completeTx: SharedTransaction): Behavior[Command] = { val fundingTx = completeTx.buildUnsignedTx() val fundingOutputIndex = fundingTx.txOut.indexWhere(_.publicKeyScript == fundingPubkeyScript) + val localLiquidityFee = liquidityPurchased_opt.collect { case l if l.isBuyer => l.lease.fees }.getOrElse(0 sat) + val remoteLiquidityFee = liquidityPurchased_opt.collect { case l if !l.isBuyer => l.lease.fees }.getOrElse(0 sat) Funding.makeCommitTxs(keyManager, channelParams, fundingAmount = fundingParams.fundingAmount, - toLocal = completeTx.sharedOutput.localAmount - localPushAmount + remotePushAmount, - toRemote = completeTx.sharedOutput.remoteAmount - remotePushAmount + localPushAmount, + toLocal = completeTx.sharedOutput.localAmount - localPushAmount + remotePushAmount - localLiquidityFee + remoteLiquidityFee, + toRemote = completeTx.sharedOutput.remoteAmount - remotePushAmount + localPushAmount - remoteLiquidityFee + localLiquidityFee, localHtlcs = purpose.localHtlcs, purpose.commitTxFeerate, fundingTxIndex = purpose.fundingTxIndex, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/ChannelFundingInterceptor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/ChannelFundingInterceptor.scala new file mode 100644 index 0000000000..9e812619df --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/ChannelFundingInterceptor.scala @@ -0,0 +1,118 @@ +/* + * Copyright 2023 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.io + +import akka.actor.typed.scaladsl.adapter.TypedActorRefOps +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.{ActorRef, Behavior} +import fr.acinq.bitcoin.scalacompat.ByteVector32 +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.eclair.channel.{CMD_GET_CHANNEL_INFO, ChannelData, ChannelDataWithCommitments, Commitments} +import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{Features, InitFeature, InterceptChannelFundingPlugin, Logs, NodeParams} + +import scala.concurrent.duration.{DurationInt, FiniteDuration} + +/** + * Short-lived actor that handles accepting or rejecting an funding operation initiated by a remote peer on an existing + * channel (e.g. splice, RBF). + */ +object ChannelFundingInterceptor { + + // @formatter:off + sealed trait Command + case class InterceptRequest(msg: Either[TxInitRbf, SpliceInit]) extends Command { + val channelId: ByteVector32 = msg.fold(_.channelId, _.channelId) + } + private case class WrappedChannelData(data: ChannelData) extends Command + private case class WrappedPluginResponse(response: InterceptChannelFundingPlugin.InterceptChannelFundingAttemptResponse) extends Command + private case object PluginTimeout extends Command + // @formatter:on + + case class AcceptRequest(request: Either[TxInitRbf, SpliceInit], channel: ActorRef[Any], addFunding_opt: Option[LiquidityAds.AddFunding], peerConnection: ActorRef[Any]) + + def apply(nodeParams: NodeParams, + peer: ActorRef[Any], peerConnection: ActorRef[Any], channel: ActorRef[Any], + remoteNodeId: PublicKey, remoteFeatures: Features[InitFeature], remoteAddress: NodeAddress, + pluginTimeout: FiniteDuration = 1 minute): Behavior[Command] = { + Behaviors.setup { context => + Behaviors.withMdc(Logs.mdc(remoteNodeId_opt = Some(remoteNodeId))) { + new ChannelFundingInterceptor(nodeParams, peer, peerConnection, channel, remoteNodeId, remoteFeatures, remoteAddress, pluginTimeout, context).waitForRequest() + } + } + } + +} + +private class ChannelFundingInterceptor(nodeParams: NodeParams, + peer: ActorRef[Any], peerConnection: ActorRef[Any], channel: ActorRef[Any], + remoteNodeId: PublicKey, remoteFeatures: Features[InitFeature], remoteAddress: NodeAddress, + pluginTimeout: FiniteDuration, + context: ActorContext[ChannelFundingInterceptor.Command]) { + + import ChannelFundingInterceptor._ + + def waitForRequest(): Behavior[Command] = { + Behaviors.receiveMessagePartial { + case request: InterceptRequest => nodeParams.channelFundingInterceptor match { + case Some(plugin) => + channel ! CMD_GET_CHANNEL_INFO(context.messageAdapter(info => WrappedChannelData(info.data))) + waitForChannelData(request, plugin) + case None => + acceptRequest(request, None) + } + } + } + + private def waitForChannelData(request: InterceptRequest, plugin: InterceptChannelFundingPlugin): Behavior[Command] = { + Behaviors.receiveMessagePartial { + case WrappedChannelData(data: ChannelDataWithCommitments) => queryPlugin(request, plugin, data.commitments) + case _ => rejectRequest(request.channelId, "channel unavailable") + } + } + + private def queryPlugin(request: InterceptRequest, plugin: InterceptChannelFundingPlugin, commitments: Commitments): Behavior[Command] = { + Behaviors.withTimers { timers => + timers.startSingleTimer(PluginTimeout, pluginTimeout) + val pluginResponseAdaptor = context.messageAdapter[InterceptChannelFundingPlugin.InterceptChannelFundingAttemptResponse](WrappedPluginResponse) + val peerDetails = InterceptChannelFundingPlugin.PeerDetails(remoteNodeId, remoteFeatures, remoteAddress) + plugin.channelFundingInterceptor ! InterceptChannelFundingPlugin.InterceptChannelFundingAttempt(pluginResponseAdaptor, peerDetails, request.msg, commitments) + Behaviors.receiveMessagePartial { + case WrappedPluginResponse(r) => + r match { + case InterceptChannelFundingPlugin.AcceptChannelFundingAttempt(addFunding_opt) => acceptRequest(request, addFunding_opt) + case InterceptChannelFundingPlugin.RejectAttempt(reason) => rejectRequest(request.channelId, reason) + } + Behaviors.stopped + case PluginTimeout => + context.log.warn("timed out while waiting for plugin to accept or reject funding attempt: {}", plugin.name) + acceptRequest(request, None) + } + } + } + + private def acceptRequest(request: InterceptRequest, addFunding_opt: Option[LiquidityAds.AddFunding]): Behavior[Command] = { + peer ! AcceptRequest(request.msg, channel, addFunding_opt, peerConnection) + Behaviors.stopped + } + + private def rejectRequest(channelId: ByteVector32, reason: String): Behavior[Command] = { + peer ! Peer.OutgoingMessage(TxAbort(channelId, reason), peerConnection.toClassic) + Behaviors.stopped + } + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala index 52b9de2a34..4149be6db7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala @@ -31,7 +31,7 @@ import fr.acinq.eclair.io.Peer.{OpenChannelResponse, SpawnChannelNonInitiator} import fr.acinq.eclair.io.PendingChannelsRateLimiter.AddOrRejectChannel import fr.acinq.eclair.wire.protocol import fr.acinq.eclair.wire.protocol.{Error, NodeAddress} -import fr.acinq.eclair.{AcceptOpenChannel, CltvExpiryDelta, Features, InitFeature, InterceptOpenChannelPlugin, InterceptOpenChannelReceived, InterceptOpenChannelResponse, Logs, MilliSatoshi, NodeParams, RejectOpenChannel, ToMilliSatoshiConversion} +import fr.acinq.eclair.{CltvExpiryDelta, Features, InitFeature, InterceptChannelFundingPlugin, Logs, MilliSatoshi, NodeParams, ToMilliSatoshiConversion} import scodec.bits.ByteVector import scala.concurrent.duration.{DurationInt, FiniteDuration} @@ -64,7 +64,7 @@ object OpenChannelInterceptor { private case class PendingChannelsRateLimiterResponse(response: PendingChannelsRateLimiter.Response) extends CheckRateLimitsCommands private sealed trait QueryPluginCommands extends Command - private case class PluginOpenChannelResponse(pluginResponse: InterceptOpenChannelResponse) extends QueryPluginCommands + private case class PluginOpenChannelResponse(pluginResponse: InterceptChannelFundingPlugin.InterceptOpenChannelResponse) extends QueryPluginCommands private case object PluginTimeout extends QueryPluginCommands // @formatter:on @@ -164,10 +164,10 @@ private class OpenChannelInterceptor(peer: ActorRef[Any], pendingChannelsRateLimiter ! AddOrRejectChannel(adapter, request.remoteNodeId, request.temporaryChannelId) receiveCommandMessage[CheckRateLimitsCommands](context, "checkRateLimits") { case PendingChannelsRateLimiterResponse(PendingChannelsRateLimiter.AcceptOpenChannel) => - nodeParams.pluginOpenChannelInterceptor match { + nodeParams.channelFundingInterceptor match { case Some(plugin) => queryPlugin(plugin, request, localParams, ChannelConfig.standard, channelType) case None => - peer ! SpawnChannelNonInitiator(request.open, ChannelConfig.standard, channelType, localParams, request.peerConnection.toClassic) + peer ! SpawnChannelNonInitiator(request.open, ChannelConfig.standard, channelType, None, localParams, request.peerConnection.toClassic) waitForRequest() } case PendingChannelsRateLimiterResponse(PendingChannelsRateLimiter.ChannelRateLimited) => @@ -177,20 +177,21 @@ private class OpenChannelInterceptor(peer: ActorRef[Any], } } - private def queryPlugin(plugin: InterceptOpenChannelPlugin, request: OpenChannelInterceptor.OpenChannelNonInitiator, localParams: LocalParams, channelConfig: ChannelConfig, channelType: SupportedChannelType): Behavior[Command] = + private def queryPlugin(plugin: InterceptChannelFundingPlugin, request: OpenChannelInterceptor.OpenChannelNonInitiator, localParams: LocalParams, channelConfig: ChannelConfig, channelType: SupportedChannelType): Behavior[Command] = Behaviors.withTimers { timers => timers.startSingleTimer(PluginTimeout, pluginTimeout) - val pluginResponseAdapter = context.messageAdapter[InterceptOpenChannelResponse](PluginOpenChannelResponse) + val pluginResponseAdapter = context.messageAdapter[InterceptChannelFundingPlugin.InterceptOpenChannelResponse](PluginOpenChannelResponse) + val peerDetails = InterceptChannelFundingPlugin.PeerDetails(request.remoteNodeId, request.remoteFeatures, request.peerAddress) val defaultParams = DefaultParams(localParams.dustLimit, localParams.maxHtlcValueInFlightMsat, localParams.htlcMinimum, localParams.toSelfDelay, localParams.maxAcceptedHtlcs) - plugin.openChannelInterceptor ! InterceptOpenChannelReceived(pluginResponseAdapter, request, defaultParams) + plugin.channelFundingInterceptor ! InterceptChannelFundingPlugin.InterceptOpenChannelAttempt(pluginResponseAdapter, peerDetails, request.open, defaultParams) receiveCommandMessage[QueryPluginCommands](context, "queryPlugin") { - case PluginOpenChannelResponse(pluginResponse: AcceptOpenChannel) => + case PluginOpenChannelResponse(pluginResponse: InterceptChannelFundingPlugin.AcceptOpenChannelAttempt) => val localParams1 = updateLocalParams(localParams, pluginResponse.defaultParams) - peer ! SpawnChannelNonInitiator(request.open, channelConfig, channelType, localParams1, request.peerConnection.toClassic) + peer ! SpawnChannelNonInitiator(request.open, channelConfig, channelType, pluginResponse.addFunding_opt, localParams1, request.peerConnection.toClassic) timers.cancel(PluginTimeout) waitForRequest() - case PluginOpenChannelResponse(pluginResponse: RejectOpenChannel) => - sendFailure(pluginResponse.error.toAscii, request) + case PluginOpenChannelResponse(pluginResponse: InterceptChannelFundingPlugin.RejectAttempt) => + sendFailure(pluginResponse.reason, request) timers.cancel(PluginTimeout) waitForRequest() case PluginTimeout => diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala index 7f2cc36a2f..74a15436dc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala @@ -17,7 +17,7 @@ package fr.acinq.eclair.io import akka.actor.typed.scaladsl.Behaviors -import akka.actor.typed.scaladsl.adapter.{ClassicActorContextOps, ClassicActorRefOps} +import akka.actor.typed.scaladsl.adapter.{ClassicActorContextOps, ClassicActorRefOps, TypedActorRefOps} import akka.actor.{Actor, ActorContext, ActorRef, ExtendedActorSystem, FSM, OneForOneStrategy, PossiblyHarmful, Props, Status, SupervisorStrategy, Terminated, typed} import akka.event.Logging.MDC import akka.event.{BusLogging, DiagnosticLoggingAdapter} @@ -39,7 +39,7 @@ import fr.acinq.eclair.io.PeerConnection.KillReason import fr.acinq.eclair.message.OnionMessages import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes import fr.acinq.eclair.wire.protocol -import fr.acinq.eclair.wire.protocol.{Error, HasChannelId, HasTemporaryChannelId, LightningMessage, NodeAddress, OnionMessage, RoutingMessage, UnknownMessage, Warning} +import fr.acinq.eclair.wire.protocol.{Error, HasChannelId, HasTemporaryChannelId, LightningMessage, LiquidityAds, NodeAddress, OnionMessage, RoutingMessage, SpliceInit, TxInitRbf, UnknownMessage, Warning} /** * This actor represents a logical peer. There is one [[Peer]] per unique remote node id at all time. @@ -164,7 +164,7 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnchainP val fundingTxFeerate = c.fundingTxFeerate_opt.getOrElse(nodeParams.onChainFeeConf.getFundingFeerate(nodeParams.currentFeerates)) val commitTxFeerate = nodeParams.onChainFeeConf.getCommitmentFeerate(nodeParams.currentFeerates, remoteNodeId, channelType.commitmentFormat, c.fundingAmount) log.info(s"requesting a new channel with type=$channelType fundingAmount=${c.fundingAmount} dualFunded=$dualFunded pushAmount=${c.pushAmount_opt} fundingFeerate=$fundingTxFeerate temporaryChannelId=$temporaryChannelId localParams=$localParams") - channel ! INPUT_INIT_CHANNEL_INITIATOR(temporaryChannelId, c.fundingAmount, dualFunded, commitTxFeerate, fundingTxFeerate, c.pushAmount_opt, requireConfirmedInputs, localParams, d.peerConnection, d.remoteInit, c.channelFlags_opt.getOrElse(nodeParams.channelConf.channelFlags), channelConfig, channelType, c.channelOrigin, replyTo) + channel ! INPUT_INIT_CHANNEL_INITIATOR(temporaryChannelId, c.fundingAmount, dualFunded, commitTxFeerate, fundingTxFeerate, c.pushAmount_opt, c.requestRemoteFunding_opt, requireConfirmedInputs, localParams, d.peerConnection, d.remoteInit, c.channelFlags_opt.getOrElse(nodeParams.channelConf.channelFlags), channelConfig, channelType, c.channelOrigin, replyTo) stay() using d.copy(channels = d.channels + (TemporaryChannelId(temporaryChannelId) -> channel)) case Event(open: protocol.OpenChannel, d: ConnectedData) => @@ -191,7 +191,7 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnchainP stay() } - case Event(SpawnChannelNonInitiator(open, channelConfig, channelType, localParams, peerConnection), d: ConnectedData) => + case Event(SpawnChannelNonInitiator(open, channelConfig, channelType, addFunding_opt, localParams, peerConnection), d: ConnectedData) => val temporaryChannelId = open.fold(_.temporaryChannelId, _.temporaryChannelId) if (peerConnection == d.peerConnection) { val channel = spawnChannel() @@ -201,8 +201,7 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnchainP channel ! INPUT_INIT_CHANNEL_NON_INITIATOR(open.temporaryChannelId, None, dualFunded = false, None, localParams, d.peerConnection, d.remoteInit, channelConfig, channelType) channel ! open case Right(open) => - // NB: we don't add a contribution to the funding amount. - channel ! INPUT_INIT_CHANNEL_NON_INITIATOR(open.temporaryChannelId, None, dualFunded = true, None, localParams, d.peerConnection, d.remoteInit, channelConfig, channelType) + channel ! INPUT_INIT_CHANNEL_NON_INITIATOR(open.temporaryChannelId, addFunding_opt, dualFunded = true, None, localParams, d.peerConnection, d.remoteInit, channelConfig, channelType) channel ! open } stay() using d.copy(channels = d.channels + (TemporaryChannelId(temporaryChannelId) -> channel)) @@ -212,6 +211,34 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnchainP stay() } + case Event(msg: TxInitRbf, d: ConnectedData) => + d.channels.get(FinalChannelId(msg.channelId)) match { + case Some(channel) => + val interceptor = context.spawnAnonymous(ChannelFundingInterceptor(nodeParams, self.toTyped, d.peerConnection.toTyped, channel.toTyped, remoteNodeId, d.remoteFeatures, d.address)) + interceptor ! ChannelFundingInterceptor.InterceptRequest(Left(msg)) + case None => replyUnknownChannel(d.peerConnection, msg.channelId) + } + stay() + + case Event(msg: SpliceInit, d: ConnectedData) => + d.channels.get(FinalChannelId(msg.channelId)) match { + case Some(channel) => + val interceptor = context.spawnAnonymous(ChannelFundingInterceptor(nodeParams, self.toTyped, d.peerConnection.toTyped, channel.toTyped, remoteNodeId, d.remoteFeatures, d.address)) + interceptor ! ChannelFundingInterceptor.InterceptRequest(Right(msg)) + case None => replyUnknownChannel(d.peerConnection, msg.channelId) + } + stay() + + case Event(r: ChannelFundingInterceptor.AcceptRequest, d: ConnectedData) => + // We can simply ignore the message if we've been disconnected since we received it. + if (r.peerConnection.toClassic == d.peerConnection) { + r.request match { + case Left(txInitRbf) => r.channel ! Channel.ReceiveTxInitRbf(txInitRbf, r.addFunding_opt) + case Right(spliceInit) => r.channel ! Channel.ReceiveSpliceInit(spliceInit, r.addFunding_opt) + } + } + stay() + case Event(msg: HasChannelId, d: ConnectedData) => d.channels.get(FinalChannelId(msg.channelId)) match { case Some(channel) => channel forward msg @@ -505,6 +532,7 @@ object Peer { channelType_opt: Option[SupportedChannelType], pushAmount_opt: Option[MilliSatoshi], fundingTxFeerate_opt: Option[FeeratePerKw], + requestRemoteFunding_opt: Option[LiquidityAds.RequestRemoteFunding], channelFlags_opt: Option[ChannelFlags], timeout_opt: Option[Timeout], requireConfirmedInputsOverride_opt: Option[Boolean] = None, @@ -535,7 +563,7 @@ object Peer { } case class SpawnChannelInitiator(replyTo: akka.actor.typed.ActorRef[OpenChannelResponse], cmd: Peer.OpenChannel, channelConfig: ChannelConfig, channelType: SupportedChannelType, localParams: LocalParams) - case class SpawnChannelNonInitiator(open: Either[protocol.OpenChannel, protocol.OpenDualFundedChannel], channelConfig: ChannelConfig, channelType: SupportedChannelType, localParams: LocalParams, peerConnection: ActorRef) + case class SpawnChannelNonInitiator(open: Either[protocol.OpenChannel, protocol.OpenDualFundedChannel], channelConfig: ChannelConfig, channelType: SupportedChannelType, addFunding_opt: Option[LiquidityAds.AddFunding], localParams: LocalParams, peerConnection: ActorRef) case class GetPeerInfo(replyTo: Option[typed.ActorRef[PeerInfoResponse]]) sealed trait PeerInfoResponse { def nodeId: PublicKey } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index 6a65e22539..62aed3b63e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala @@ -138,8 +138,13 @@ case class TxInitRbf(channelId: ByteVector32, } object TxInitRbf { - def apply(channelId: ByteVector32, lockTime: Long, feerate: FeeratePerKw, fundingContribution: Satoshi): TxInitRbf = - TxInitRbf(channelId, lockTime, feerate, TlvStream[TxInitRbfTlv](TxRbfTlv.SharedOutputContributionTlv(fundingContribution))) + def apply(channelId: ByteVector32, lockTime: Long, feerate: FeeratePerKw, fundingContribution: Satoshi, requestFunding_opt: Option[ChannelTlv.RequestFunds]): TxInitRbf = { + val tlvs: Set[TxInitRbfTlv] = Set( + Some(TxRbfTlv.SharedOutputContributionTlv(fundingContribution)), + requestFunding_opt + ).flatten + TxInitRbf(channelId, lockTime, feerate, TlvStream(tlvs)) + } } case class TxAckRbf(channelId: ByteVector32, @@ -149,8 +154,13 @@ case class TxAckRbf(channelId: ByteVector32, } object TxAckRbf { - def apply(channelId: ByteVector32, fundingContribution: Satoshi): TxAckRbf = - TxAckRbf(channelId, TlvStream[TxAckRbfTlv](TxRbfTlv.SharedOutputContributionTlv(fundingContribution))) + def apply(channelId: ByteVector32, fundingContribution: Satoshi, addFunding_opt: Option[ChannelTlv.WillFund]): TxAckRbf = { + val tlvs: Set[TxAckRbfTlv] = Set( + Some(TxRbfTlv.SharedOutputContributionTlv(fundingContribution)), + addFunding_opt, + ) + TxAckRbf(channelId, TlvStream(tlvs)) + } } case class TxAbort(channelId: ByteVector32, @@ -296,10 +306,11 @@ case class SpliceInit(channelId: ByteVector32, } object SpliceInit { - def apply(channelId: ByteVector32, fundingContribution: Satoshi, lockTime: Long, feerate: FeeratePerKw, fundingPubKey: PublicKey, pushAmount: MilliSatoshi, requireConfirmedInputs: Boolean): SpliceInit = { + def apply(channelId: ByteVector32, fundingContribution: Satoshi, lockTime: Long, feerate: FeeratePerKw, fundingPubKey: PublicKey, pushAmount: MilliSatoshi, requestFunding_opt: Option[ChannelTlv.RequestFunds], requireConfirmedInputs: Boolean): SpliceInit = { val tlvs: Set[SpliceInitTlv] = Set( Some(ChannelTlv.PushAmountTlv(pushAmount)), if (requireConfirmedInputs) Some(ChannelTlv.RequireConfirmedInputsTlv()) else None, + requestFunding_opt, ).flatten SpliceInit(channelId, fundingContribution, feerate, lockTime, fundingPubKey, TlvStream(tlvs)) } @@ -315,10 +326,11 @@ case class SpliceAck(channelId: ByteVector32, } object SpliceAck { - def apply(channelId: ByteVector32, fundingContribution: Satoshi, fundingPubKey: PublicKey, pushAmount: MilliSatoshi, requireConfirmedInputs: Boolean): SpliceAck = { + def apply(channelId: ByteVector32, fundingContribution: Satoshi, fundingPubKey: PublicKey, pushAmount: MilliSatoshi, addFunding_opt: Option[ChannelTlv.WillFund], requireConfirmedInputs: Boolean): SpliceAck = { val tlvs: Set[SpliceAckTlv] = Set( Some(ChannelTlv.PushAmountTlv(pushAmount)), if (requireConfirmedInputs) Some(ChannelTlv.RequireConfirmedInputsTlv()) else None, + addFunding_opt, ).flatten SpliceAck(channelId, fundingContribution, fundingPubKey, TlvStream(tlvs)) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala index 83ca06b564..72dd52480b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala @@ -17,8 +17,9 @@ package fr.acinq.eclair.wire.protocol import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{ByteVector64, Crypto, Satoshi} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, Satoshi, SatoshiLong} import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.{ChannelException, InvalidLiquidityAdsSig, LiquidityRatesRejected, MissingLiquidityAds} import fr.acinq.eclair.payment.relay.Relayer.RelayFees import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.wire.protocol.CommonCodecs.{blockHeight, millisatoshi32, publicKey, satoshi32} @@ -41,6 +42,8 @@ import java.nio.charset.StandardCharsets */ object LiquidityAds { + private val DEFAULT_LEASE_DURATION = 4032 // ~1 month + case class Config(feeBase: Satoshi, feeProportional: Int, maxLeaseDuration: Int) { def leaseRates(relayFees: RelayFees): LeaseRates = { // We make the remote node pay for one p2wpkh input and one p2wpkh output. @@ -49,6 +52,67 @@ object LiquidityAds { } } + case class RequestRemoteFundingParams(fundingAmount: Satoshi, maxFee: Satoshi) { + def withDuration(leaseStart: BlockHeight, leaseDuration: Int = DEFAULT_LEASE_DURATION): RequestRemoteFunding = RequestRemoteFunding(fundingAmount, maxFee, leaseStart, leaseDuration) + } + + /** Request inbound liquidity from a remote peer that supports liquidity ads. */ + case class RequestRemoteFunding(fundingAmount: Satoshi, maxFee: Satoshi, leaseStart: BlockHeight, leaseDuration: Int) { + private val leaseExpiry: BlockHeight = leaseStart + leaseDuration + val requestFunds: ChannelTlv.RequestFunds = ChannelTlv.RequestFunds(fundingAmount, leaseStart + leaseDuration, leaseDuration) + + def validateLeaseRates(remoteNodeId: PublicKey, + channelId: ByteVector32, + remoteFundingPubKey: PublicKey, + remoteFundingAmount: Satoshi, + fundingFeerate: FeeratePerKw, + willFund_opt: Option[ChannelTlv.WillFund]): Either[ChannelException, Lease] = { + willFund_opt match { + case Some(willFund) => + val witness = LeaseWitness(remoteFundingPubKey, leaseExpiry, leaseDuration, willFund.leaseRates) + val fees = willFund.leaseRates.fees(fundingFeerate, fundingAmount, remoteFundingAmount) + if (!LeaseWitness.verify(remoteNodeId, willFund.sig, witness)) { + Left(InvalidLiquidityAdsSig(channelId)) + } else if (remoteFundingAmount <= 0.sat) { + Left(LiquidityRatesRejected(channelId)) + } else if (maxFee < fees) { + Left(LiquidityRatesRejected(channelId)) + } else { + Right(Lease(fees, willFund.sig, witness)) + } + case None => Left(MissingLiquidityAds(channelId)) + } + } + } + + def validateLeaseRates_opt(remoteNodeId: PublicKey, + channelId: ByteVector32, + remoteFundingPubKey: PublicKey, + remoteFundingAmount: Satoshi, + fundingFeerate: FeeratePerKw, + willFund_opt: Option[ChannelTlv.WillFund], + requestRemoteFunding_opt: Option[RequestRemoteFunding]): Either[ChannelException, Option[Lease]] = { + requestRemoteFunding_opt match { + case Some(requestRemoteFunding) => requestRemoteFunding.validateLeaseRates(remoteNodeId, channelId, remoteFundingPubKey, remoteFundingAmount, fundingFeerate, willFund_opt) match { + case Left(t) => Left(t) + case Right(lease) => Right(Some(lease)) + } + case None => Right(None) + } + } + + /** We propose adding funds to a channel for a fee at the given rates. */ + case class AddFunding(fundingAmount: Satoshi, rates: LeaseRates) { + def signLease(nodeKey: PrivateKey, localFundingPubKey: PublicKey, fundingFeerate: FeeratePerKw, requestFunds_opt: Option[ChannelTlv.RequestFunds]): Option[(ChannelTlv.WillFund, Lease)] = { + requestFunds_opt.map(requestFunds => { + val witness = LeaseWitness(localFundingPubKey, requestFunds.leaseExpiry, requestFunds.leaseDuration, rates) + val sig = LeaseWitness.sign(nodeKey, witness) + val fees = rates.fees(fundingFeerate, requestFunds.amount, fundingAmount) + (ChannelTlv.WillFund(sig, rates), Lease(fees, sig, witness)) + }) + } + } + /** * Liquidity is leased using the following rates: * @@ -109,4 +173,10 @@ object LiquidityAds { ("channel_fee_max_base_msat" | millisatoshi32) ).as[LeaseWitness] + /** + * Once a liquidity ads has been paid, we should keep track of the lease, and check that our peer doesn't raise their + * routing fees above the values they signed up for. + */ + case class Lease(fees: Satoshi, sellerSig: ByteVector64, witness: LeaseWitness) + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala index 1d4550f970..5646570eeb 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala @@ -35,7 +35,7 @@ import fr.acinq.eclair.io.PeerSpec.createOpenChannelMessage import fr.acinq.eclair.io.PendingChannelsRateLimiter.AddOrRejectChannel import fr.acinq.eclair.payment.Bolt11Invoice.defaultFeatures.initFeatures import fr.acinq.eclair.wire.protocol.{ChannelTlv, Error, IPAddress, NodeAddress, OpenChannel, OpenChannelTlv, TlvStream} -import fr.acinq.eclair.{AcceptOpenChannel, CltvExpiryDelta, Features, InterceptOpenChannelCommand, InterceptOpenChannelPlugin, InterceptOpenChannelReceived, MilliSatoshiLong, RejectOpenChannel, TestConstants, UnknownFeature, randomBytes32, randomKey} +import fr.acinq.eclair.{AcceptOpenChannel, CltvExpiryDelta, Features, InterceptOpenChannelCommand, InterceptChannelFundingPlugin, InterceptOpenChannelReceived, MilliSatoshiLong, RejectOpenChannel, TestConstants, UnknownFeature, randomBytes32, randomKey} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -54,10 +54,10 @@ class OpenChannelInterceptorSpec extends ScalaTestWithActorTestKit(ConfigFactory val pluginInterceptor = TestProbe[InterceptOpenChannelCommand]() val wallet = new DummyOnChainWallet() val pendingChannelsRateLimiter = TestProbe[PendingChannelsRateLimiter.Command]() - val plugin = new InterceptOpenChannelPlugin { + val plugin = new InterceptChannelFundingPlugin { override def name: String = "OpenChannelInterceptorPlugin" - override def openChannelInterceptor: ActorRef[InterceptOpenChannelCommand] = pluginInterceptor.ref + override def channelFundingInterceptor: ActorRef[InterceptOpenChannelCommand] = pluginInterceptor.ref } val pluginParams = TestConstants.Alice.nodeParams.pluginParams :+ plugin val nodeParams = TestConstants.Alice.nodeParams.copy(pluginParams = pluginParams) diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala index a41c02c4ec..5bff049acd 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala @@ -22,14 +22,16 @@ import akka.http.scaladsl.model.StatusCodes.NotFound import akka.http.scaladsl.model.{ContentTypes, HttpResponse} import akka.http.scaladsl.server.{Directive1, Directives, MalformedFormFieldRejection, Route} import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.{ByteVector32, OutPoint} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, OutPoint, Satoshi} import fr.acinq.eclair.ApiTypes.ChannelIdentifier import fr.acinq.eclair.api.serde.FormParamExtractors._ import fr.acinq.eclair.api.serde.JsonSupport._ import fr.acinq.eclair.blockchain.fee.ConfirmationPriority import fr.acinq.eclair.payment.Bolt11Invoice +import fr.acinq.eclair.wire.protocol.LiquidityAds import fr.acinq.eclair.wire.protocol.OfferTypes.Offer import fr.acinq.eclair.{MilliSatoshi, Paginated, ShortChannelId, TimestampSecond} +import scodec.bits.ByteVector import scala.concurrent.Future import scala.concurrent.duration.DurationInt @@ -72,6 +74,18 @@ trait ExtraDirectives extends Directives { case Failure(_) => reject } + def withRequestedRemoteFunding: Directive1[Option[LiquidityAds.RequestRemoteFundingParams]] = formFields("requestRemoteFundingSatoshis".as[Satoshi].?, "remoteFundingMaxFeeSatoshis".as[Satoshi].?).tflatMap { + case (Some(requestRemoteFunding), Some(remoteFundingMaxFee)) => provide(Some(LiquidityAds.RequestRemoteFundingParams(requestRemoteFunding, remoteFundingMaxFee))) + case (Some(_), None) => reject(MalformedFormFieldRejection("remoteFundingMaxFeeSatoshis", "You must specify the maximum fee you're willing to pay when requesting inbound liquidity from the remote node")) + case _ => provide(None) + } + + def withAddressOrScript: Directive1[Either[ByteVector, String]] = formFields("scriptPubKey".as[ByteVector](bytesUnmarshaller).?, "address".as[String].?).tflatMap { + case (Some(script), None) => provide(Left(script)) + case (None, Some(address)) => provide(Right(address)) + case _ => reject(MalformedFormFieldRejection("address/scriptPubKey", "You must provide a bitcoin address or a scriptPubKey.")) + } + def withPaginated: Directive1[Option[Paginated]] = formFields(countFormParam.?, skipFormParam.?).tflatMap { case (Some(count), Some(skip)) => provide(Some(Paginated(count = count, skip = skip))) case (Some(count), None) => provide(Some(Paginated(count = count, skip = 0))) diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Channel.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Channel.scala index c4a5e0b3a7..d4eaadd0a2 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Channel.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Channel.scala @@ -32,7 +32,7 @@ trait Channel { import fr.acinq.eclair.api.serde.JsonSupport.{formats, marshaller, serialization} - val supportedChannelTypes = Set( + private val supportedChannelTypes = Set( ChannelTypes.Standard(), ChannelTypes.Standard(zeroConf = true), ChannelTypes.Standard(scidAlias = true), @@ -53,41 +53,48 @@ trait Channel { val open: Route = postRequest("open") { implicit t => formFields(nodeIdFormParam, "fundingSatoshis".as[Satoshi], "pushMsat".as[MilliSatoshi].?, "channelType".?, "fundingFeerateSatByte".as[FeeratePerByte].?, "announceChannel".as[Boolean].?, "openTimeoutSeconds".as[Timeout].?) { - (nodeId, fundingSatoshis, pushMsat, channelTypeName_opt, fundingFeerateSatByte, announceChannel_opt, openTimeout_opt) => - val (channelTypeOk, channelType_opt) = channelTypeName_opt match { - case Some(channelTypeName) => supportedChannelTypes.get(channelTypeName) match { - case Some(channelType) => (true, Some(channelType)) - case None => (false, None) // invalid channel type name + (nodeId, fundingAmount, pushAmount, channelTypeName_opt, fundingFeerate, announceChannel_opt, openTimeout_opt) => + withRequestedRemoteFunding { requestRemoteFunding_opt => + val (channelTypeOk, channelType_opt) = channelTypeName_opt match { + case Some(channelTypeName) => supportedChannelTypes.get(channelTypeName) match { + case Some(channelType) => (true, Some(channelType)) + case None => (false, None) // invalid channel type name + } + case None => (true, None) + } + if (!channelTypeOk) { + reject(MalformedFormFieldRejection("channelType", s"Channel type not supported: must be one of ${supportedChannelTypes.keys.mkString(",")}")) + } else { + complete(eclairApi.open(nodeId, fundingAmount, pushAmount, channelType_opt, fundingFeerate, requestRemoteFunding_opt, announceChannel_opt, openTimeout_opt)) } - case None => (true, None) - } - if (!channelTypeOk) { - reject(MalformedFormFieldRejection("channelType", s"Channel type not supported: must be one of ${supportedChannelTypes.keys.mkString(",")}")) - } else { - complete(eclairApi.open(nodeId, fundingSatoshis, pushMsat, channelType_opt, fundingFeerateSatByte, announceChannel_opt, openTimeout_opt)) } } } val rbfOpen: Route = postRequest("rbfopen") { implicit f => - formFields(channelIdFormParam, "targetFeerateSatByte".as[FeeratePerByte], "lockTime".as[Long].?) { - (channelId, targetFeerateSatByte, lockTime_opt) => complete(eclairApi.rbfOpen(channelId, FeeratePerKw(targetFeerateSatByte), lockTime_opt)) + formFields(channelIdFormParam, "targetFeerateSatByte".as[FeeratePerByte], "lockTime".as[Long].?) { (channelId, targetFeerateSatByte, lockTime_opt) => + withRequestedRemoteFunding { requestRemoteFunding_opt => + complete(eclairApi.rbfOpen(channelId, FeeratePerKw(targetFeerateSatByte), requestRemoteFunding_opt, lockTime_opt)) + } } } val spliceIn: Route = postRequest("splicein") { implicit f => - formFields(channelIdFormParam, "amountIn".as[Satoshi], "pushMsat".as[MilliSatoshi].?) { - (channelId, amountIn, pushMsat_opt) => complete(eclairApi.spliceIn(channelId, amountIn, pushMsat_opt)) + formFields(channelIdFormParam, "amountIn".as[Satoshi], "pushMsat".as[MilliSatoshi].?) { (channelId, amountIn, pushMsat_opt) => + withRequestedRemoteFunding { requestRemoteFunding_opt => + complete(eclairApi.spliceIn(channelId, amountIn, pushMsat_opt, requestRemoteFunding_opt)) + } } } val spliceOut: Route = postRequest("spliceout") { implicit f => - formFields(channelIdFormParam, "amountOut".as[Satoshi], "scriptPubKey".as[ByteVector](bytesUnmarshaller)) { - (channelId, amountOut, scriptPubKey) => complete(eclairApi.spliceOut(channelId, amountOut, Left(scriptPubKey))) - } ~ - formFields(channelIdFormParam, "amountOut".as[Satoshi], "address".as[String]) { - (channelId, amountOut, address) => complete(eclairApi.spliceOut(channelId, amountOut, Right(address))) + formFields(channelIdFormParam, "amountOut".as[Satoshi]) { (channelId, amountOut) => + withAddressOrScript { destination => + withRequestedRemoteFunding { requestRemoteFunding_opt => + complete(eclairApi.spliceOut(channelId, amountOut, destination, requestRemoteFunding_opt)) + } } + } } val close: Route = postRequest("close") { implicit t =>