diff --git a/.gitignore b/.gitignore index f72ed6206a..a25470c558 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,7 @@ project/target DeleteMe*.* *~ jdbcUrlFile_*.tmp +.metals/ +.vscode/ .DS_Store diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md new file mode 100644 index 0000000000..f270f90792 --- /dev/null +++ b/docs/release-notes/eclair-vnext.md @@ -0,0 +1,84 @@ +# Eclair vnext + + + +## Major changes + + + +### bLIP-18 Inbound Routing Fees + +Eclair now supports [bLIP-18 inbound routing fees](https://github.com/lightning/blips/pull/18) which proposes an optional +TLV for channel updates that allows node operators to set (and optionally advertise) inbound routing fee discounts, enabling +more flexible fee policies and incentivizing desired incoming traffic. + +#### Configuration + +| Configuration Parameter | Default Value | Description | +|----------------------------------------------------------------------------------|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| `eclair.router.path-finding.default.blip18-inbound-fees` | `false` | enables support for bLIP-18 inbound routing fees | +| `eclair.router.path-finding.default.exclude-channels-with-positive-inbound-fees` | `false` | enables exclusion of channels with positive inbound fees from path finding, helping to prevent `FeeInsufficient` errors and ensure more reliable routing | + +The routing logic considers inbound fees during route selection if enabled. New logic is added to exclude channels with +positive inbound fees from route finding when configured. The relay and route calculation logic now computes total fees +as the sum of the regular (outbound) and inbound fees when applicable. + +The wire protocol is updated to include the new TLV (0x55555) type for bLIP-18 inbound fees in ChannelUpdate messages. +Code that (de)serializes channel updates now handles these new fields. + +New database tables and migration updates for storing inbound fee information per peer. + +### API changes + + + +- `updaterelayfee` now accepts optional `--inboundFeeBaseMsat` and `--inboundFeeProportionalMillionths` parameters. If omitted, existing inbound fees will be preserved. + +### Miscellaneous improvements and bug fixes + + + +## Verifying signatures + +You will need `gpg` and our release signing key E04E48E72C205463. Note that you can get it: + +- from our website: https://acinq.co/pgp/drouinf2.asc +- from github user @sstone, a committer on eclair: https://api.github.com/users/sstone/gpg_keys + +To import our signing key: + +```sh +$ gpg --import drouinf2.asc +``` + +To verify the release file checksums and signatures: + +```sh +$ gpg -d SHA256SUMS.asc > SHA256SUMS.stripped +$ sha256sum -c SHA256SUMS.stripped +``` + +## Building + +Eclair builds are deterministic. To reproduce our builds, please use the following environment (*): + +- Ubuntu 24.04.1 +- Adoptium OpenJDK 21.0.6 + +Use the following command to generate the eclair-node package: + +```sh +./mvnw clean install -DskipTests +``` + +That should generate `eclair-node/target/eclair-node--XXXXXXX-bin.zip` with sha256 checksums that match the one we provide and sign in `SHA256SUMS.asc` + +(*) You may be able to build the exact same artefacts with other operating systems or versions of JDK 21, we have not tried everything. + +## Upgrading + +This release is fully compatible with previous eclair versions. You don't need to close your channels, just stop eclair, upgrade and restart. + +## Changelog + + diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index a6c7b837bd..c32f31faa2 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -455,6 +455,8 @@ eclair { } path-finding { + blip18-inbound-fees = false + exclude-channels-with-positive-inbound-fees = false default { randomize-route-selection = true // when computing a route for a payment we randomize the final selection 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 1c7ec9a859..c16cc83756 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -42,7 +42,7 @@ import fr.acinq.eclair.message.{OnionMessages, Postman} import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.offer.{OfferCreator, OfferManager} import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceiveStandardPayment -import fr.acinq.eclair.payment.relay.Relayer.{ChannelBalance, GetOutgoingChannels, OutgoingChannels, RelayFees} +import fr.acinq.eclair.payment.relay.Relayer.{ChannelBalance, GetOutgoingChannels, InboundFees, OutgoingChannels, RelayFees} import fr.acinq.eclair.payment.send.PaymentInitiator._ import fr.acinq.eclair.payment.send.{ClearRecipient, OfferPayment, PaymentIdentifier} import fr.acinq.eclair.router.Router @@ -112,6 +112,8 @@ trait Eclair { def updateRelayFee(nodes: List[PublicKey], feeBase: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] + def updateRelayFee(nodes: List[PublicKey], feeBase: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi], inboundFeeProportional_opt: Option[Long])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] + def channelsInfo(toRemoteNode_opt: Option[PublicKey])(implicit timeout: Timeout): Future[Iterable[RES_GET_CHANNEL_INFO]] def channelInfo(channel: ApiTypes.ChannelIdentifier)(implicit timeout: Timeout): Future[CommandResponse[CMD_GET_CHANNEL_INFO]] @@ -306,11 +308,21 @@ class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChan sendToChannelsTyped(channels, cmdBuilder = CMD_BUMP_FORCE_CLOSE_FEE(_, confirmationTarget)) } - override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] = { - for (nodeId <- nodes) { - appKit.nodeParams.db.peers.addOrUpdateRelayFees(nodeId, RelayFees(feeBaseMsat, feeProportionalMillionths)) + override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] = + updateRelayFee(nodes, feeBaseMsat, feeProportionalMillionths, None, None) + + override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi], inboundFeeProportional_opt: Option[Long])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] = { + if ((inboundFeeBase_opt.isDefined || inboundFeeProportional_opt.isDefined) && !appKit.nodeParams.routerConf.blip18InboundFees) { + Future.failed(new IllegalArgumentException("Cannot specify inbound fees when bLIP-18 support is disabled")) + } else { + for (nodeId <- nodes) { + appKit.nodeParams.db.peers.addOrUpdateRelayFees(nodeId, RelayFees(feeBaseMsat, feeProportionalMillionths)) + InboundFees.fromOptions(inboundFeeBase_opt, inboundFeeProportional_opt).foreach { inboundFees => + appKit.nodeParams.db.inboundFees.addOrUpdateInboundFees(nodeId, inboundFees) + } + } + sendToNodes(nodes, CMD_UPDATE_RELAY_FEE(ActorRef.noSender, feeBaseMsat, feeProportionalMillionths, inboundFeeBase_opt, inboundFeeProportional_opt)) } - sendToNodes(nodes, CMD_UPDATE_RELAY_FEE(ActorRef.noSender, feeBaseMsat, feeProportionalMillionths)) } override def peers()(implicit timeout: Timeout): Future[Iterable[PeerInfo]] = for { @@ -484,7 +496,7 @@ class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChan for { ignoredChannels <- getChannelDescs(ignoreShortChannelIds.toSet) ignore = Ignore(ignoreNodeIds.toSet, ignoredChannels) - response <- appKit.router.toTyped.ask[PaymentRouteResponse](replyTo => RouteRequest(replyTo, sourceNodeId, target, routeParams1, ignore)).flatMap { + response <- appKit.router.toTyped.ask[PaymentRouteResponse](replyTo => RouteRequest(replyTo, sourceNodeId, target, routeParams1, ignore, blip18InboundFees = appKit.nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = appKit.nodeParams.routerConf.excludePositiveInboundFees)).flatMap { case r: RouteResponse => Future.successful(r) case PaymentRouteNotFound(error) => Future.failed(error) } 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 e824e684dd..eaa707968e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -477,7 +477,6 @@ object NodeParams extends Logging { experimentName = name, experimentPercentage = config.getInt("percentage")) - def getPathFindingExperimentConf(config: Config): PathFindingExperimentConf = { val experiments = config.root.asScala.keys.map(name => name -> getPathFindingConf(config.getConfig(name), name)) PathFindingExperimentConf(experiments.toMap) @@ -679,7 +678,9 @@ object NodeParams extends Logging { pathFindingExperimentConf = getPathFindingExperimentConf(config.getConfig("router.path-finding.experiments")), messageRouteParams = getMessageRouteParams(config.getConfig("router.message-path-finding")), balanceEstimateHalfLife = FiniteDuration(config.getDuration("router.balance-estimate-half-life").getSeconds, TimeUnit.SECONDS), - ), + blip18InboundFees = config.getBoolean("router.path-finding.blip18-inbound-fees"), + excludePositiveInboundFees = config.getBoolean("router.path-finding.exclude-channels-with-positive-inbound-fees"), + ), socksProxy_opt = socksProxy_opt, maxPaymentAttempts = config.getInt("max-payment-attempts"), paymentFinalExpiry = PaymentFinalExpiryConf(CltvExpiryDelta(config.getInt("send.recipient-final-expiry.min-delta")), CltvExpiryDelta(config.getInt("send.recipient-final-expiry.max-delta"))), 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 1b0b28637d..d0006a700d 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 @@ -268,7 +268,7 @@ final case class CMD_SPLICE(replyTo: akka.actor.typed.ActorRef[CommandResponse[C val spliceOutputs: List[TxOut] = spliceOut_opt.toList.map(s => TxOut(s.amount, s.scriptPubKey)) } final case class CMD_BUMP_FUNDING_FEE(replyTo: akka.actor.typed.ActorRef[CommandResponse[ChannelFundingCommand]], targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime: Long, requestFunding_opt: Option[LiquidityAds.RequestFunding]) extends ChannelFundingCommand -final case class CMD_UPDATE_RELAY_FEE(replyTo: ActorRef, feeBase: MilliSatoshi, feeProportionalMillionths: Long) extends HasReplyToCommand +final case class CMD_UPDATE_RELAY_FEE(replyTo: ActorRef, feeBase: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi] = None, inboundFeeProportionalMillionths_opt: Option[Long]= None) extends HasReplyToCommand final case class CMD_GET_CHANNEL_STATE(replyTo: ActorRef) extends HasReplyToCommand final case class CMD_GET_CHANNEL_DATA(replyTo: ActorRef) extends HasReplyToCommand final case class CMD_GET_CHANNEL_INFO(replyTo: akka.actor.typed.ActorRef[RES_GET_CHANNEL_INFO]) extends Command 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 230b9befee..fd740c0dea 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 @@ -30,7 +30,7 @@ import fr.acinq.eclair.channel.fund.InteractiveTxSigningSession import fr.acinq.eclair.crypto.keymanager.{ChannelKeys, LocalCommitmentKeys, RemoteCommitmentKeys} import fr.acinq.eclair.crypto.{NonceGenerator, ShaChain} import fr.acinq.eclair.db.ChannelsDb -import fr.acinq.eclair.payment.relay.Relayer.RelayFees +import fr.acinq.eclair.payment.relay.Relayer.{InboundFees, RelayFees} import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.transactions.DirectedHtlc._ import fr.acinq.eclair.transactions.Transactions._ @@ -338,7 +338,7 @@ object Helpers { } } - def channelUpdate(nodeParams: NodeParams, shortChannelId: ShortChannelId, commitments: Commitments, relayFees: RelayFees, enable: Boolean): ChannelUpdate = { + def channelUpdate(nodeParams: NodeParams, shortChannelId: ShortChannelId, commitments: Commitments, relayFees: RelayFees, enable: Boolean, inboundFees_opt: Option[InboundFees]): ChannelUpdate = { Announcements.makeChannelUpdate( chainHash = nodeParams.chainHash, nodeSecret = nodeParams.privateKey, @@ -351,6 +351,8 @@ object Helpers { htlcMaximumMsat = maxHtlcAmount(nodeParams, commitments), isPrivate = !commitments.announceChannel, enable = enable, + timestamp = TimestampSecond.now(), + inboundFees_opt = inboundFees_opt ) } @@ -391,9 +393,9 @@ object Helpers { commitments.maxHtlcValueInFlight } - def getRelayFees(nodeParams: NodeParams, remoteNodeId: PublicKey, announceChannel: Boolean): RelayFees = { + def getRelayFees(nodeParams: NodeParams, remoteNodeId: PublicKey, announceChannel: Boolean): (RelayFees, Option[InboundFees]) = { val defaultFees = nodeParams.relayParams.defaultFees(announceChannel) - nodeParams.db.peers.getRelayFees(remoteNodeId).getOrElse(defaultFees) + (nodeParams.db.peers.getRelayFees(remoteNodeId).getOrElse(defaultFees), nodeParams.db.inboundFees.getInboundFees(remoteNodeId)) } object Funding { 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 bef8a0c3aa..7ea7662cb9 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 @@ -48,6 +48,7 @@ import fr.acinq.eclair.db.PendingCommandsDb import fr.acinq.eclair.io.Peer import fr.acinq.eclair.io.Peer.LiquidityPurchaseSigned import fr.acinq.eclair.payment.relay.Relayer +import fr.acinq.eclair.payment.relay.Relayer.InboundFees import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSettlingOnChain} import fr.acinq.eclair.reputation.Reputation import fr.acinq.eclair.router.Announcements @@ -459,12 +460,13 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case normal: DATA_NORMAL => context.system.eventStream.publish(ShortChannelIdAssigned(self, normal.channelId, normal.lastAnnouncement_opt, normal.aliases, remoteNodeId)) // we check the configuration because the values for channel_update may have changed while eclair was down - val fees = getRelayFees(nodeParams, remoteNodeId, normal.commitments.announceChannel) + val (fees, inboundFees_opt) = getRelayFees(nodeParams, remoteNodeId, normal.commitments.announceChannel) if (fees.feeBase != normal.channelUpdate.feeBaseMsat || fees.feeProportionalMillionths != normal.channelUpdate.feeProportionalMillionths || + inboundFees_opt != normal.channelUpdate.blip18InboundFees_opt || nodeParams.channelConf.expiryDelta != normal.channelUpdate.cltvExpiryDelta) { log.debug("refreshing channel_update due to configuration changes") - self ! CMD_UPDATE_RELAY_FEE(ActorRef.noSender, fees.feeBase, fees.feeProportionalMillionths) + self ! CMD_UPDATE_RELAY_FEE(ActorRef.noSender, fees.feeBase, fees.feeProportionalMillionths, inboundFees_opt.map(_.feeBase), inboundFees_opt.map(_.feeProportionalMillionths)) } // we need to periodically re-send channel updates, otherwise channel will be considered stale and get pruned by network // we take into account the date of the last update so that we don't send superfluous updates when we restart the app @@ -905,7 +907,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall log.info("announcing channelId={} on the network with shortChannelId={} for fundingTxIndex={}", d.channelId, localAnnSigs.shortChannelId, c.fundingTxIndex) // We generate a new channel_update because we can now use the scid of the announced funding transaction. val scidForChannelUpdate = Helpers.scidForChannelUpdate(Some(channelAnn), d.aliases.localAlias) - val channelUpdate = Helpers.channelUpdate(nodeParams, scidForChannelUpdate, d.commitments, d.channelUpdate.relayFees, enable = true) + val channelUpdate = Helpers.channelUpdate(nodeParams, scidForChannelUpdate, d.commitments, d.channelUpdate.relayFees, enable = true, d.channelUpdate.blip18InboundFees_opt) context.system.eventStream.publish(ShortChannelIdAssigned(self, d.channelId, Some(channelAnn), d.aliases, remoteNodeId)) // We use goto() instead of stay() because we want to fire transitions. goto(NORMAL) using d.copy(lastAnnouncement_opt = Some(channelAnn), channelUpdate = channelUpdate) storing() @@ -927,7 +929,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } case Event(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL) => - val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), enable = true) + val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), enable = true, InboundFees.fromOptions(c.inboundFeeBase_opt, c.inboundFeeProportionalMillionths_opt)) log.debug(s"updating relay fees: prev={} next={}", d.channelUpdate.toStringShort, channelUpdate1.toStringShort) val replyTo = if (c.replyTo == ActorRef.noSender) sender() else c.replyTo replyTo ! RES_SUCCESS(c, d.channelId) @@ -936,7 +938,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Event(BroadcastChannelUpdate(reason), d: DATA_NORMAL) => val age = TimestampSecond.now() - d.channelUpdate.timestamp - val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, d.channelUpdate.relayFees, enable = true) + val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, d.channelUpdate.relayFees, enable = true, d.channelUpdate.blip18InboundFees_opt) reason match { case Reconnected if d.commitments.announceChannel && Announcements.areSame(channelUpdate1, d.channelUpdate) && age < REFRESH_CHANNEL_UPDATE_INTERVAL => // we already sent an identical channel_update not long ago (flapping protection in case we keep being disconnected/reconnected) @@ -1562,7 +1564,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall // if we have pending unsigned htlcs, then we cancel them and generate an update with the disabled flag set, that will be returned to the sender in a temporary channel failure if (d.commitments.changes.localChanges.proposed.collectFirst { case add: UpdateAddHtlc => add }.isDefined) { log.debug("updating channel_update announcement (reason=disabled)") - val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, d.channelUpdate.relayFees, enable = false) + val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, d.channelUpdate.relayFees, enable = false, d.channelUpdate.blip18InboundFees_opt) // NB: the htlcs stay in the commitments.localChange, they will be cleaned up after reconnection d.commitments.changes.localChanges.proposed.collect { case add: UpdateAddHtlc => relayer ! RES_ADD_SETTLED(d.commitments.originChannels(add.id), add, HtlcResult.DisconnectedBeforeSigned(channelUpdate1)) @@ -3133,7 +3135,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall log.debug("emitting channel down event") if (d.lastAnnouncement_opt.nonEmpty) { // We tell the rest of the network that this channel shouldn't be used anymore. - val disabledUpdate = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, d.channelUpdate.relayFees, enable = false) + val disabledUpdate = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, d.channelUpdate.relayFees, enable = false, d.channelUpdate.blip18InboundFees_opt) context.system.eventStream.publish(LocalChannelUpdate(self, d.channelId, d.aliases, remoteNodeId, d.lastAnnouncedCommitment_opt, disabledUpdate, d.commitments)) } val lcd = LocalChannelDown(self, d.channelId, d.commitments.all.flatMap(_.shortChannelId_opt), d.aliases, remoteNodeId) @@ -3317,7 +3319,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall if (d.channelUpdate.channelFlags.isEnabled) { // if the channel isn't disabled we generate a new channel_update log.debug("updating channel_update announcement (reason=disabled)") - val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, d.channelUpdate.relayFees, enable = false) + val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, d.channelUpdate.relayFees, enable = false, d.channelUpdate.blip18InboundFees_opt) // then we update the state and replay the request self forward c // we use goto() to fire transitions @@ -3330,7 +3332,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } private def handleUpdateRelayFeeDisconnected(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL) = { - val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), enable = false) + val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), enable = false, InboundFees.fromOptions(c.inboundFeeBase_opt, c.inboundFeeProportionalMillionths_opt)) log.debug(s"updating relay fees: prev={} next={}", d.channelUpdate.toStringShort, channelUpdate1.toStringShort) val replyTo = if (c.replyTo == ActorRef.noSender) sender() else c.replyTo replyTo ! RES_SUCCESS(c, d.channelId) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala index 0cdfb2a4fa..e5106e08bf 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala @@ -146,8 +146,8 @@ trait CommonFundingHandlers extends CommonHandlers { // We create a channel_update early so that we can use it to send payments through this channel, but it won't be propagated to other nodes since the channel is not yet announced. val scidForChannelUpdate = Helpers.scidForChannelUpdate(channelAnnouncement_opt = None, aliases1.localAlias) log.info("using shortChannelId={} for initial channel_update", scidForChannelUpdate) - val relayFees = getRelayFees(nodeParams, remoteNodeId, commitments.announceChannel) - val initialChannelUpdate = Helpers.channelUpdate(nodeParams, scidForChannelUpdate, commitments, relayFees, enable = true) + val (relayFees, inboundFees_opt) = getRelayFees(nodeParams, remoteNodeId, commitments.announceChannel) + val initialChannelUpdate = Helpers.channelUpdate(nodeParams, scidForChannelUpdate, commitments, relayFees, enable = true, inboundFees_opt) // We need to periodically re-send channel updates, otherwise channel will be considered stale and get pruned by network. context.system.scheduler.scheduleWithFixedDelay(initialDelay = REFRESH_CHANNEL_UPDATE_INTERVAL, delay = REFRESH_CHANNEL_UPDATE_INTERVAL, receiver = self, message = BroadcastChannelUpdate(PeriodicRefresh)) val commitments1 = commitments.copy( diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala index 8d67fc700e..2c76b9b131 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala @@ -45,6 +45,7 @@ trait Databases { def offers: OffersDb def pendingCommands: PendingCommandsDb def liquidity: LiquidityDb + def inboundFees: InboundFeesDb //@formatter:on } @@ -68,6 +69,7 @@ object Databases extends Logging { payments: SqlitePaymentsDb, offers: SqliteOffersDb, pendingCommands: SqlitePendingCommandsDb, + inboundFees: SqliteInboundFeesDb, private val backupConnection: Connection) extends Databases with FileBackup { override def backup(backupFile: File): Unit = SqliteUtils.using(backupConnection.createStatement()) { statement => { @@ -77,7 +79,7 @@ object Databases extends Logging { } object SqliteDatabases { - def apply(auditJdbc: Connection, networkJdbc: Connection, eclairJdbc: Connection, jdbcUrlFile_opt: Option[File]): SqliteDatabases = { + def apply(auditJdbc: Connection, networkJdbc: Connection, eclairJdbc: Connection, inboundFeesJdbc: Connection, jdbcUrlFile_opt: Option[File]): SqliteDatabases = { jdbcUrlFile_opt.foreach(checkIfDatabaseUrlIsUnchanged("sqlite", _)) // We check whether the node operator needs to run an intermediate eclair version first. using(eclairJdbc.createStatement(), inTransaction = true) { statement => checkChannelsDbVersion(statement, SqliteChannelsDb.DB_NAME, minimum = 7) } @@ -90,6 +92,7 @@ object Databases extends Logging { payments = new SqlitePaymentsDb(eclairJdbc), offers = new SqliteOffersDb(eclairJdbc), pendingCommands = new SqlitePendingCommandsDb(eclairJdbc), + inboundFees = new SqliteInboundFeesDb(inboundFeesJdbc), backupConnection = eclairJdbc ) } @@ -103,6 +106,7 @@ object Databases extends Logging { payments: PgPaymentsDb, offers: PgOffersDb, pendingCommands: PgPendingCommandsDb, + inboundFees: PgInboundFeesDb, dataSource: HikariDataSource, lock: PgLock) extends Databases with ExclusiveLock { override def obtainExclusiveLock(): Unit = lock.obtainExclusiveLock(dataSource) @@ -169,6 +173,7 @@ object Databases extends Logging { payments = new PgPaymentsDb, offers = new PgOffersDb, pendingCommands = new PgPendingCommandsDb, + inboundFees = new PgInboundFeesDb, dataSource = ds, lock = lock) @@ -300,6 +305,7 @@ object Databases extends Logging { eclairJdbc = SqliteUtils.openSqliteFile(dbdir, "eclair.sqlite", exclusiveLock = true, journalMode = "wal", syncFlag = "full"), // there should only be one process writing to this file networkJdbc = SqliteUtils.openSqliteFile(dbdir, "network.sqlite", exclusiveLock = false, journalMode = "wal", syncFlag = "normal"), // we don't need strong durability guarantees on the network db auditJdbc = SqliteUtils.openSqliteFile(dbdir, "audit.sqlite", exclusiveLock = false, journalMode = "wal", syncFlag = "full"), + inboundFeesJdbc = SqliteUtils.openSqliteFile(dbdir, "inboundfees.sqlite", exclusiveLock = false, journalMode = "wal", syncFlag = "full"), jdbcUrlFile_opt = jdbcUrlFile_opt ) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala new file mode 100644 index 0000000000..475eb71510 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala @@ -0,0 +1,539 @@ +package fr.acinq.eclair.db + +import com.google.common.util.concurrent.ThreadFactoryBuilder +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, TxId} +import fr.acinq.eclair.channel._ +import fr.acinq.eclair.db.AuditDb.PublishedTransaction +import fr.acinq.eclair.db.Databases.{FileBackup, PostgresDatabases, SqliteDatabases} +import fr.acinq.eclair.db.DbEventHandler.ChannelEvent +import fr.acinq.eclair.db.DualDatabases.runAsync +import fr.acinq.eclair.payment._ +import fr.acinq.eclair.payment.relay.OnTheFlyFunding +import fr.acinq.eclair.payment.relay.Relayer.{InboundFees, RelayFees} +import fr.acinq.eclair.router.Router +import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{CltvExpiry, Features, InitFeature, MilliSatoshi, Paginated, RealShortChannelId, ShortChannelId, TimestampMilli, TimestampSecond} +import grizzled.slf4j.Logging +import scodec.bits.ByteVector + +import java.io.File +import java.util.UUID +import java.util.concurrent.Executors +import scala.collection.immutable.SortedMap +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success, Try} + +/** + * An implementation of [[Databases]] where there are two separate underlying db, one primary and one secondary. + * All calls to primary are replicated asynchronously to secondary. + * Calls to secondary are made asynchronously in a dedicated thread pool, so that it doesn't have any performance impact. + */ +case class DualDatabases(primary: Databases, secondary: Databases) extends Databases with FileBackup { + + override val network: NetworkDb = DualNetworkDb(primary.network, secondary.network) + override val audit: AuditDb = DualAuditDb(primary.audit, secondary.audit) + override val channels: ChannelsDb = DualChannelsDb(primary.channels, secondary.channels) + override val peers: PeersDb = DualPeersDb(primary.peers, secondary.peers) + override val payments: PaymentsDb = DualPaymentsDb(primary.payments, secondary.payments) + override val offers: OffersDb = DualOffersDb(primary.offers, secondary.offers) + override val pendingCommands: PendingCommandsDb = DualPendingCommandsDb(primary.pendingCommands, secondary.pendingCommands) + override val liquidity: LiquidityDb = DualLiquidityDb(primary.liquidity, secondary.liquidity) + override val inboundFees: InboundFeesDb = DualInboundFeesDb(primary.inboundFees, secondary.inboundFees) + + /** if one of the database supports file backup, we use it */ + override def backup(backupFile: File): Unit = (primary, secondary) match { + case (f: FileBackup, _) => f.backup(backupFile) + case (_, f: FileBackup) => f.backup(backupFile) + case _ => () + } +} + +object DualDatabases extends Logging { + + /** Run asynchronously and print errors */ + def runAsync[T](f: => T)(implicit ec: ExecutionContext): Future[T] = Future { + Try(f) match { + case Success(res) => res + case Failure(t) => + logger.error("postgres error:\n", t) + throw t + } + } + + def getDatabases(dualDatabases: DualDatabases): (SqliteDatabases, PostgresDatabases) = + (dualDatabases.primary, dualDatabases.secondary) match { + case (sqliteDb: SqliteDatabases, postgresDb: PostgresDatabases) => + (sqliteDb, postgresDb) + case (postgresDb: PostgresDatabases, sqliteDb: SqliteDatabases) => + (sqliteDb, postgresDb) + case _ => throw new IllegalArgumentException("there must be one sqlite and one postgres in dual db mode") + } +} + +case class DualNetworkDb(primary: NetworkDb, secondary: NetworkDb) extends NetworkDb { + + private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-network").build())) + + override def addNode(n: NodeAnnouncement): Unit = { + runAsync(secondary.addNode(n)) + primary.addNode(n) + } + + override def updateNode(n: NodeAnnouncement): Unit = { + runAsync(secondary.updateNode(n)) + primary.updateNode(n) + } + + override def getNode(nodeId: Crypto.PublicKey): Option[NodeAnnouncement] = { + runAsync(secondary.getNode(nodeId)) + primary.getNode(nodeId) + } + + override def removeNode(nodeId: Crypto.PublicKey): Unit = { + runAsync(secondary.removeNode(nodeId)) + primary.removeNode(nodeId) + } + + override def listNodes(): Seq[NodeAnnouncement] = { + runAsync(secondary.listNodes()) + primary.listNodes() + } + + override def addChannel(c: ChannelAnnouncement, txid: TxId, capacity: Satoshi): Unit = { + runAsync(secondary.addChannel(c, txid, capacity)) + primary.addChannel(c, txid, capacity) + } + + override def updateChannel(u: ChannelUpdate): Unit = { + runAsync(secondary.updateChannel(u)) + primary.updateChannel(u) + } + + override def removeChannels(shortChannelIds: Iterable[ShortChannelId]): Unit = { + runAsync(secondary.removeChannels(shortChannelIds)) + primary.removeChannels(shortChannelIds) + } + + override def getChannel(shortChannelId: RealShortChannelId): Option[Router.PublicChannel] = { + runAsync(secondary.getChannel(shortChannelId)) + primary.getChannel(shortChannelId) + } + + override def listChannels(): SortedMap[RealShortChannelId, Router.PublicChannel] = { + runAsync(secondary.listChannels()) + primary.listChannels() + } + +} + +case class DualAuditDb(primary: AuditDb, secondary: AuditDb) extends AuditDb { + + private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-audit").build())) + + override def add(channelLifecycle: DbEventHandler.ChannelEvent): Unit = { + runAsync(secondary.add(channelLifecycle)) + primary.add(channelLifecycle) + } + + override def add(paymentSent: PaymentSent): Unit = { + runAsync(secondary.add(paymentSent)) + primary.add(paymentSent) + } + + override def add(paymentReceived: PaymentReceived): Unit = { + runAsync(secondary.add(paymentReceived)) + primary.add(paymentReceived) + } + + override def add(paymentRelayed: PaymentRelayed): Unit = { + runAsync(secondary.add(paymentRelayed)) + primary.add(paymentRelayed) + } + + override def add(txPublished: TransactionPublished): Unit = { + runAsync(secondary.add(txPublished)) + primary.add(txPublished) + } + + override def add(txConfirmed: TransactionConfirmed): Unit = { + runAsync(secondary.add(txConfirmed)) + primary.add(txConfirmed) + } + + override def add(channelErrorOccurred: ChannelErrorOccurred): Unit = { + runAsync(secondary.add(channelErrorOccurred)) + primary.add(channelErrorOccurred) + } + + override def addChannelUpdate(channelUpdateParametersChanged: ChannelUpdateParametersChanged): Unit = { + runAsync(secondary.addChannelUpdate(channelUpdateParametersChanged)) + primary.addChannelUpdate(channelUpdateParametersChanged) + } + + override def addPathFindingExperimentMetrics(metrics: PathFindingExperimentMetrics): Unit = { + runAsync(secondary.addPathFindingExperimentMetrics(metrics)) + primary.addPathFindingExperimentMetrics(metrics) + } + + override def listPublished(channelId: ByteVector32): Seq[PublishedTransaction] = { + runAsync(secondary.listPublished(channelId)) + primary.listPublished(channelId) + } + + override def listSent(from: TimestampMilli, to: TimestampMilli, paginated_opt: Option[Paginated]): Seq[PaymentSent] = { + runAsync(secondary.listSent(from, to, paginated_opt)) + primary.listSent(from, to, paginated_opt) + } + + override def listReceived(from: TimestampMilli, to: TimestampMilli, paginated_opt: Option[Paginated]): Seq[PaymentReceived] = { + runAsync(secondary.listReceived(from, to, paginated_opt)) + primary.listReceived(from, to, paginated_opt) + } + + override def listRelayed(from: TimestampMilli, to: TimestampMilli, paginated_opt: Option[Paginated]): Seq[PaymentRelayed] = { + runAsync(secondary.listRelayed(from, to, paginated_opt)) + primary.listRelayed(from, to, paginated_opt) + } + + override def listNetworkFees(from: TimestampMilli, to: TimestampMilli): Seq[AuditDb.NetworkFee] = { + runAsync(secondary.listNetworkFees(from, to)) + primary.listNetworkFees(from, to) + } + + override def stats(from: TimestampMilli, to: TimestampMilli, paginated_opt: Option[Paginated]): Seq[AuditDb.Stats] = { + runAsync(secondary.stats(from, to, paginated_opt)) + primary.stats(from, to, paginated_opt) + } +} + +case class DualChannelsDb(primary: ChannelsDb, secondary: ChannelsDb) extends ChannelsDb { + + private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-channels").build())) + + override def addOrUpdateChannel(data: PersistentChannelData): Unit = { + runAsync(secondary.addOrUpdateChannel(data)) + primary.addOrUpdateChannel(data) + } + + override def getChannel(channelId: ByteVector32): Option[PersistentChannelData] = { + runAsync(secondary.getChannel(channelId)) + primary.getChannel(channelId) + } + + override def updateChannelMeta(channelId: ByteVector32, event: ChannelEvent.EventType): Unit = { + runAsync(secondary.updateChannelMeta(channelId, event)) + primary.updateChannelMeta(channelId, event) + } + + override def removeChannel(channelId: ByteVector32, data_opt: Option[DATA_CLOSED]): Unit = { + runAsync(secondary.removeChannel(channelId, data_opt)) + primary.removeChannel(channelId, data_opt) + } + + override def markHtlcInfosForRemoval(channelId: ByteVector32, beforeCommitIndex: Long): Unit = { + runAsync(secondary.markHtlcInfosForRemoval(channelId, beforeCommitIndex)) + primary.markHtlcInfosForRemoval(channelId, beforeCommitIndex) + } + + override def removeHtlcInfos(batchSize: Int): Unit = { + runAsync(secondary.removeHtlcInfos(batchSize)) + primary.removeHtlcInfos(batchSize) + } + + override def listLocalChannels(): Seq[PersistentChannelData] = { + runAsync(secondary.listLocalChannels()) + primary.listLocalChannels() + } + + override def listClosedChannels(remoteNodeId_opt: Option[PublicKey], paginated_opt: Option[Paginated]): Seq[DATA_CLOSED] = { + runAsync(secondary.listClosedChannels(remoteNodeId_opt, paginated_opt)) + primary.listClosedChannels(remoteNodeId_opt, paginated_opt) + } + + override def addHtlcInfo(channelId: ByteVector32, commitmentNumber: Long, paymentHash: ByteVector32, cltvExpiry: CltvExpiry): Unit = { + runAsync(secondary.addHtlcInfo(channelId, commitmentNumber, paymentHash, cltvExpiry)) + primary.addHtlcInfo(channelId, commitmentNumber, paymentHash, cltvExpiry) + } + + override def listHtlcInfos(channelId: ByteVector32, commitmentNumber: Long): Seq[(ByteVector32, CltvExpiry)] = { + runAsync(secondary.listHtlcInfos(channelId, commitmentNumber)) + primary.listHtlcInfos(channelId, commitmentNumber) + } +} + +case class DualPeersDb(primary: PeersDb, secondary: PeersDb) extends PeersDb { + + private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-peers").build())) + + override def addOrUpdatePeer(nodeId: Crypto.PublicKey, address: NodeAddress, features: Features[InitFeature]): Unit = { + runAsync(secondary.addOrUpdatePeer(nodeId, address, features)) + primary.addOrUpdatePeer(nodeId, address, features) + } + + override def addOrUpdatePeerFeatures(nodeId: Crypto.PublicKey, features: Features[InitFeature]): Unit = { + runAsync(secondary.addOrUpdatePeerFeatures(nodeId, features)) + primary.addOrUpdatePeerFeatures(nodeId, features) + } + + override def removePeer(nodeId: Crypto.PublicKey): Unit = { + runAsync(secondary.removePeer(nodeId)) + primary.removePeer(nodeId) + } + + override def getPeer(nodeId: Crypto.PublicKey): Option[NodeInfo] = { + runAsync(secondary.getPeer(nodeId)) + primary.getPeer(nodeId) + } + + override def listPeers(): Map[Crypto.PublicKey, NodeInfo] = { + runAsync(secondary.listPeers()) + primary.listPeers() + } + + override def addOrUpdateRelayFees(nodeId: Crypto.PublicKey, fees: RelayFees): Unit = { + runAsync(secondary.addOrUpdateRelayFees(nodeId, fees)) + primary.addOrUpdateRelayFees(nodeId, fees) + } + + override def getRelayFees(nodeId: Crypto.PublicKey): Option[RelayFees] = { + runAsync(secondary.getRelayFees(nodeId)) + primary.getRelayFees(nodeId) + } + + override def updateStorage(nodeId: PublicKey, data: ByteVector): Unit = { + runAsync(secondary.updateStorage(nodeId, data)) + primary.updateStorage(nodeId, data) + } + + override def getStorage(nodeId: PublicKey): Option[ByteVector] = { + runAsync(secondary.getStorage(nodeId)) + primary.getStorage(nodeId) + } + + override def removePeerStorage(peerRemovedBefore: TimestampSecond): Unit = { + runAsync(secondary.removePeerStorage(peerRemovedBefore)) + primary.removePeerStorage(peerRemovedBefore) + } +} + +case class DualPaymentsDb(primary: PaymentsDb, secondary: PaymentsDb) extends PaymentsDb { + + private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-payments").build())) + + override def addIncomingPayment(pr: Bolt11Invoice, preimage: ByteVector32, paymentType: String): Unit = { + runAsync(secondary.addIncomingPayment(pr, preimage, paymentType)) + primary.addIncomingPayment(pr, preimage, paymentType) + } + + override def receiveIncomingPayment(paymentHash: ByteVector32, amount: MilliSatoshi, receivedAt: TimestampMilli): Boolean = { + runAsync(secondary.receiveIncomingPayment(paymentHash, amount, receivedAt)) + primary.receiveIncomingPayment(paymentHash, amount, receivedAt) + } + + override def receiveIncomingOfferPayment(pr: MinimalBolt12Invoice, preimage: ByteVector32, amount: MilliSatoshi, receivedAt: TimestampMilli, paymentType: String): Unit = { + runAsync(secondary.receiveIncomingOfferPayment(pr, preimage, amount, receivedAt, paymentType)) + primary.receiveIncomingOfferPayment(pr, preimage, amount, receivedAt, paymentType) + } + + override def getIncomingPayment(paymentHash: ByteVector32): Option[IncomingPayment] = { + runAsync(secondary.getIncomingPayment(paymentHash)) + primary.getIncomingPayment(paymentHash) + } + + override def removeIncomingPayment(paymentHash: ByteVector32): Try[Unit] = { + runAsync(secondary.removeIncomingPayment(paymentHash)) + primary.removeIncomingPayment(paymentHash) + } + + override def listIncomingPayments(from: TimestampMilli, to: TimestampMilli, paginated_opt: Option[Paginated]): Seq[IncomingPayment] = { + runAsync(secondary.listIncomingPayments(from, to, paginated_opt)) + primary.listIncomingPayments(from, to, paginated_opt) + } + + override def listPendingIncomingPayments(from: TimestampMilli, to: TimestampMilli, paginated_opt: Option[Paginated]): Seq[IncomingPayment] = { + runAsync(secondary.listPendingIncomingPayments(from, to, paginated_opt)) + primary.listPendingIncomingPayments(from, to, paginated_opt) + } + + override def listExpiredIncomingPayments(from: TimestampMilli, to: TimestampMilli, paginated_opt: Option[Paginated]): Seq[IncomingPayment] = { + runAsync(secondary.listExpiredIncomingPayments(from, to, paginated_opt)) + primary.listExpiredIncomingPayments(from, to, paginated_opt) + } + + override def listReceivedIncomingPayments(from: TimestampMilli, to: TimestampMilli, paginated_opt: Option[Paginated]): Seq[IncomingPayment] = { + runAsync(secondary.listReceivedIncomingPayments(from, to, paginated_opt)) + primary.listReceivedIncomingPayments(from, to, paginated_opt) + } + + override def addOutgoingPayment(outgoingPayment: OutgoingPayment): Unit = { + runAsync(secondary.addOutgoingPayment(outgoingPayment)) + primary.addOutgoingPayment(outgoingPayment) + } + + override def updateOutgoingPayment(paymentResult: PaymentSent): Unit = { + runAsync(secondary.updateOutgoingPayment(paymentResult)) + primary.updateOutgoingPayment(paymentResult) + } + + override def updateOutgoingPayment(paymentResult: PaymentFailed): Unit = { + runAsync(secondary.updateOutgoingPayment(paymentResult)) + primary.updateOutgoingPayment(paymentResult) + } + + override def getOutgoingPayment(id: UUID): Option[OutgoingPayment] = { + runAsync(secondary.getOutgoingPayment(id)) + primary.getOutgoingPayment(id) + } + + override def listOutgoingPayments(parentId: UUID): Seq[OutgoingPayment] = { + runAsync(secondary.listOutgoingPayments(parentId)) + primary.listOutgoingPayments(parentId) + } + + override def listOutgoingPayments(paymentHash: ByteVector32): Seq[OutgoingPayment] = { + runAsync(secondary.listOutgoingPayments(paymentHash)) + primary.listOutgoingPayments(paymentHash) + } + + override def listOutgoingPayments(from: TimestampMilli, to: TimestampMilli): Seq[OutgoingPayment] = { + runAsync(secondary.listOutgoingPayments(from, to)) + primary.listOutgoingPayments(from, to) + } + + override def listOutgoingPaymentsToOffer(offerId: ByteVector32): Seq[OutgoingPayment] = { + runAsync(secondary.listOutgoingPaymentsToOffer(offerId)) + primary.listOutgoingPaymentsToOffer(offerId) + } +} + +case class DualOffersDb(primary: OffersDb, secondary: OffersDb) extends OffersDb { + + private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-offers").build())) + + override def addOffer(offer: OfferTypes.Offer, pathId_opt: Option[ByteVector32], createdAt: TimestampMilli = TimestampMilli.now()): Option[OfferData] = { + runAsync(secondary.addOffer(offer, pathId_opt, createdAt)) + primary.addOffer(offer, pathId_opt, createdAt) + } + + override def disableOffer(offer: OfferTypes.Offer, disabledAt: TimestampMilli = TimestampMilli.now()): Unit = { + runAsync(secondary.disableOffer(offer, disabledAt)) + primary.disableOffer(offer, disabledAt) + } + + override def listOffers(onlyActive: Boolean): Seq[OfferData] = { + runAsync(secondary.listOffers(onlyActive)) + primary.listOffers(onlyActive) + } +} + +case class DualPendingCommandsDb(primary: PendingCommandsDb, secondary: PendingCommandsDb) extends PendingCommandsDb { + + private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-pending-commands").build())) + + override def addSettlementCommand(channelId: ByteVector32, cmd: HtlcSettlementCommand): Unit = { + runAsync(secondary.addSettlementCommand(channelId, cmd)) + primary.addSettlementCommand(channelId, cmd) + } + + override def removeSettlementCommand(channelId: ByteVector32, htlcId: Long): Unit = { + runAsync(secondary.removeSettlementCommand(channelId, htlcId)) + primary.removeSettlementCommand(channelId, htlcId) + } + + override def listSettlementCommands(channelId: ByteVector32): Seq[HtlcSettlementCommand] = { + runAsync(secondary.listSettlementCommands(channelId)) + primary.listSettlementCommands(channelId) + } + + override def listSettlementCommands(): Seq[(ByteVector32, HtlcSettlementCommand)] = { + runAsync(secondary.listSettlementCommands()) + primary.listSettlementCommands() + } +} + +case class DualLiquidityDb(primary: LiquidityDb, secondary: LiquidityDb) extends LiquidityDb { + + private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-liquidity").build())) + + override def addPurchase(liquidityPurchase: ChannelLiquidityPurchased): Unit = { + runAsync(secondary.addPurchase(liquidityPurchase)) + primary.addPurchase(liquidityPurchase) + } + + override def setConfirmed(remoteNodeId: PublicKey, txId: TxId): Unit = { + runAsync(secondary.setConfirmed(remoteNodeId, txId)) + primary.setConfirmed(remoteNodeId, txId) + } + + override def listPurchases(remoteNodeId: PublicKey): Seq[LiquidityPurchase] = { + runAsync(secondary.listPurchases(remoteNodeId)) + primary.listPurchases(remoteNodeId) + } + + override def addPendingOnTheFlyFunding(remoteNodeId: PublicKey, pending: OnTheFlyFunding.Pending): Unit = { + runAsync(secondary.addPendingOnTheFlyFunding(remoteNodeId, pending)) + primary.addPendingOnTheFlyFunding(remoteNodeId, pending) + } + + override def removePendingOnTheFlyFunding(remoteNodeId: PublicKey, paymentHash: ByteVector32): Unit = { + runAsync(secondary.removePendingOnTheFlyFunding(remoteNodeId, paymentHash)) + primary.removePendingOnTheFlyFunding(remoteNodeId, paymentHash) + } + + override def listPendingOnTheFlyFunding(remoteNodeId: PublicKey): Map[ByteVector32, OnTheFlyFunding.Pending] = { + runAsync(secondary.listPendingOnTheFlyFunding(remoteNodeId)) + primary.listPendingOnTheFlyFunding(remoteNodeId) + } + + override def listPendingOnTheFlyFunding(): Map[PublicKey, Map[ByteVector32, OnTheFlyFunding.Pending]] = { + runAsync(secondary.listPendingOnTheFlyFunding()) + primary.listPendingOnTheFlyFunding() + } + + override def listPendingOnTheFlyPayments(): Map[PublicKey, Set[ByteVector32]] = { + runAsync(secondary.listPendingOnTheFlyPayments()) + primary.listPendingOnTheFlyPayments() + } + + override def addOnTheFlyFundingPreimage(preimage: ByteVector32): Unit = { + runAsync(secondary.addOnTheFlyFundingPreimage(preimage)) + primary.addOnTheFlyFundingPreimage(preimage) + } + + override def getOnTheFlyFundingPreimage(paymentHash: ByteVector32): Option[ByteVector32] = { + runAsync(secondary.getOnTheFlyFundingPreimage(paymentHash)) + primary.getOnTheFlyFundingPreimage(paymentHash) + } + + override def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli): MilliSatoshi = { + runAsync(secondary.addFeeCredit(nodeId, amount, receivedAt)) + primary.addFeeCredit(nodeId, amount, receivedAt) + } + + override def getFeeCredit(nodeId: PublicKey): MilliSatoshi = { + runAsync(secondary.getFeeCredit(nodeId)) + primary.getFeeCredit(nodeId) + } + + override def removeFeeCredit(nodeId: PublicKey, amountUsed: MilliSatoshi): MilliSatoshi = { + runAsync(secondary.removeFeeCredit(nodeId, amountUsed)) + primary.removeFeeCredit(nodeId, amountUsed) + } + +} + + +case class DualInboundFeesDb(primary: InboundFeesDb, secondary: InboundFeesDb) extends InboundFeesDb { + private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-liquidity").build())) + + override def addOrUpdateInboundFees(nodeId: PublicKey, fees: InboundFees): Unit = { + runAsync(secondary.addOrUpdateInboundFees(nodeId, fees)) + primary.addOrUpdateInboundFees(nodeId, fees) + } + + override def getInboundFees(nodeId: PublicKey): Option[InboundFees] = { + runAsync(secondary.getInboundFees(nodeId)) + primary.getInboundFees(nodeId) + } +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala new file mode 100644 index 0000000000..e97d49f7d3 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala @@ -0,0 +1,13 @@ +package fr.acinq.eclair.db + +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.eclair.payment.relay.Relayer.InboundFees + +/** The PeersDb contains information about our direct peers, with whom we have or had channels. */ +trait InboundFeesDb { + + def addOrUpdateInboundFees(nodeId: PublicKey, fees: InboundFees): Unit + + def getInboundFees(nodeId: PublicKey): Option[InboundFees] + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgInboundFeesDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgInboundFeesDb.scala new file mode 100644 index 0000000000..bcbef5111f --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgInboundFeesDb.scala @@ -0,0 +1,70 @@ +package fr.acinq.eclair.db.pg + +import fr.acinq.bitcoin.scalacompat.Crypto +import fr.acinq.eclair.MilliSatoshi +import fr.acinq.eclair.db.InboundFeesDb +import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics +import fr.acinq.eclair.db.Monitoring.Tags.DbBackends +import fr.acinq.eclair.db.pg.PgUtils.PgLock +import fr.acinq.eclair.db.pg.PgUtils.PgLock.NoLock.withLock +import fr.acinq.eclair.payment.relay.Relayer.InboundFees +import grizzled.slf4j.Logging + +import javax.sql.DataSource + +object PgInboundFeesDb { + val DB_NAME = "inboundfees" + val CURRENT_VERSION = 1 +} + +class PgInboundFeesDb(implicit ds: DataSource, lock: PgLock) extends InboundFeesDb with Logging { + + import PgUtils._ + import ExtendedResultSet._ + import PgInboundFeesDb._ + + inTransaction { pg => + using(pg.createStatement()) { statement => + getVersion(statement, DB_NAME) match { + case None => + statement.executeUpdate("CREATE SCHEMA inboundfees") + statement.executeUpdate("CREATE TABLE inboundfees.inbound_fees (node_id TEXT NOT NULL PRIMARY KEY, fee_base_msat BIGINT NOT NULL, fee_proportional_millionths BIGINT NOT NULL)") + case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do + case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion") + } + setVersion(statement, DB_NAME, CURRENT_VERSION) + } + } + + override def addOrUpdateInboundFees(nodeId: Crypto.PublicKey, fees: InboundFees): Unit = withMetrics("peers/add-or-update-relay-fees", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement( + """ + INSERT INTO inboundfees.inbound_fees (node_id, fee_base_msat, fee_proportional_millionths) + VALUES (?, ?, ?) + ON CONFLICT (node_id) + DO UPDATE SET fee_base_msat = EXCLUDED.fee_base_msat, fee_proportional_millionths = EXCLUDED.fee_proportional_millionths + """)) { statement => + statement.setString(1, nodeId.value.toHex) + statement.setLong(2, fees.feeBase.toLong) + statement.setLong(3, fees.feeProportionalMillionths) + statement.executeUpdate() + } + } + } + + override def getInboundFees(nodeId: Crypto.PublicKey): Option[InboundFees] = withMetrics("peers/get-relay-fees", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("SELECT fee_base_msat, fee_proportional_millionths FROM inboundfees.inbound_fees WHERE node_id=?")) { statement => + statement.setString(1, nodeId.value.toHex) + statement.executeQuery() + .headOption + .map(rs => + InboundFees(MilliSatoshi(rs.getLong("fee_base_msat")), rs.getLong("fee_proportional_millionths")) + ) + } + } + } + +} + diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteInboundFeesDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteInboundFeesDb.scala new file mode 100644 index 0000000000..90c12e8c75 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteInboundFeesDb.scala @@ -0,0 +1,61 @@ +package fr.acinq.eclair.db.sqlite + +import fr.acinq.bitcoin.scalacompat.Crypto +import fr.acinq.eclair.MilliSatoshi +import fr.acinq.eclair.db.InboundFeesDb +import fr.acinq.eclair.db.sqlite.SqliteUtils.{getVersion, setVersion, using} +import fr.acinq.eclair.payment.relay.Relayer +import fr.acinq.eclair.payment.relay.Relayer.{InboundFees, RelayFees} +import grizzled.slf4j.Logging + +import java.sql.Connection + +object SqliteInboundFeesDb { + val DB_NAME = "inboundfees" + val CURRENT_VERSION = 1 +} + +class SqliteInboundFeesDb(val sqlite: Connection) extends InboundFeesDb with Logging { + + import SqliteInboundFeesDb._ + import SqliteUtils.ExtendedResultSet._ + + using(sqlite.createStatement(), inTransaction = true) { statement => + getVersion(statement, DB_NAME) match { + case None => + statement.executeUpdate("CREATE TABLE inbound_fees (node_id BLOB NOT NULL PRIMARY KEY, fee_base_msat INTEGER NOT NULL, fee_proportional_millionths INTEGER NOT NULL)") + case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do + case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion") + } + setVersion(statement, DB_NAME, CURRENT_VERSION) + } + + override def addOrUpdateInboundFees(nodeId: Crypto.PublicKey, fees: Relayer.InboundFees): Unit = { + using(sqlite.prepareStatement("UPDATE inbound_fees SET fee_base_msat=?, fee_proportional_millionths=? WHERE node_id=?")) { update => + update.setLong(1, fees.feeBase.toLong) + update.setLong(2, fees.feeProportionalMillionths) + update.setBytes(3, nodeId.value.toArray) + if (update.executeUpdate() == 0) { + using(sqlite.prepareStatement("INSERT INTO inbound_fees VALUES (?, ?, ?)")) { statement => + statement.setBytes(1, nodeId.value.toArray) + statement.setLong(2, fees.feeBase.toLong) + statement.setLong(3, fees.feeProportionalMillionths) + statement.executeUpdate() + } + } + } + } + + override def getInboundFees(nodeId: Crypto.PublicKey): Option[Relayer.InboundFees] = { + using(sqlite.prepareStatement("SELECT fee_base_msat, fee_proportional_millionths FROM inbound_fees WHERE node_id=?")) { statement => + statement.setBytes(1, nodeId.value.toArray) + statement.executeQuery() + .headOption + .map(rs => + InboundFees(MilliSatoshi(rs.getLong("fee_base_msat")), rs.getLong("fee_proportional_millionths")) + ) + } + + } + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/package.scala b/eclair-core/src/main/scala/fr/acinq/eclair/package.scala index 810a5527f0..ac41e02ffe 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/package.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/package.scala @@ -20,7 +20,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey import fr.acinq.bitcoin.scalacompat.KotlinUtils._ import fr.acinq.bitcoin.scalacompat._ import fr.acinq.eclair.crypto.StrongRandom -import fr.acinq.eclair.payment.relay.Relayer.RelayFees +import fr.acinq.eclair.payment.relay.Relayer.{InboundFees, RelayFees} import scodec.Attempt import scodec.bits.{BitVector, ByteVector} @@ -71,6 +71,19 @@ package object eclair { def nodeFee(relayFees: RelayFees, paymentAmount: MilliSatoshi): MilliSatoshi = nodeFee(relayFees.feeBase, relayFees.feeProportionalMillionths, paymentAmount) + def totalFee(amount: MilliSatoshi, baseFee: MilliSatoshi, proportionalFee: Long, inboundBaseFee_opt: Option[MilliSatoshi], inboundProportionalFee_opt: Option[Long]): MilliSatoshi = { + val outFee = nodeFee(baseFee, proportionalFee, amount) + val inFee = (for { + inboundBaseFee <- inboundBaseFee_opt + inboundProportionalFee <- inboundProportionalFee_opt + } yield nodeFee(inboundBaseFee, inboundProportionalFee, amount + outFee)).getOrElse(0 msat) + val totalFee = outFee + inFee + if (totalFee.toLong < 0) 0 msat else totalFee + } + + def totalFee(amount: MilliSatoshi, relayFees: RelayFees, inboundFees_opt: Option[InboundFees]): MilliSatoshi = + totalFee(amount, relayFees.feeBase, relayFees.feeProportionalMillionths, inboundFees_opt.map(_.feeBase), inboundFees_opt.map(_.feeProportionalMillionths)) + /** * @param baseFee fixed fee * @param proportionalFee proportional fee (millionths) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala index 32393813d3..123ce4a0af 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala @@ -89,7 +89,7 @@ object DefaultOfferHandler { maxRouteLength = nodeParams.offersConfig.paymentPathLength, maxCltv = nodeParams.offersConfig.paymentPathCltvExpiryDelta )) - router ! BlindedRouteRequest(context.messageAdapter(WrappedRouteResponse), blindedPathFirstNodeId, nodeParams.nodeId, invoiceRequest.amount, routeParams, nodeParams.offersConfig.paymentPathCount) + router ! BlindedRouteRequest(context.messageAdapter(WrappedRouteResponse), blindedPathFirstNodeId, nodeParams.nodeId, invoiceRequest.amount, routeParams, nodeParams.offersConfig.paymentPathCount, blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) waitForRoute(nodeParams, replyTo, invoiceRequest, blindedPathFirstNodeId, context) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala index d4c1ae46c3..28688f9876 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala @@ -36,7 +36,8 @@ import fr.acinq.eclair.reputation.ReputationRecorder.GetConfidence import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.createBadOnionFailure import fr.acinq.eclair.wire.protocol.PaymentOnion.IntermediatePayload import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{EncodedNodeId, Features, InitFeature, Logs, NodeParams, TimestampMilli, TimestampSecond, channel, nodeFee} +import fr.acinq.eclair.{EncodedNodeId, Features, InitFeature, Logs, NodeParams, TimestampMilli, TimestampSecond, channel, totalFee} +import fr.acinq.eclair.channel.{Command => ChannelCommand} import java.util.UUID import java.util.concurrent.TimeUnit @@ -50,9 +51,10 @@ object ChannelRelay { private case object DoRelay extends Command private case class WrappedPeerReadyResult(result: PeerReadyNotifier.Result) extends Command private case class WrappedReputationScore(score: Reputation.Score) extends Command - private case class WrappedForwardFailure(failure: Register.ForwardFailure[CMD_ADD_HTLC]) extends Command + private case class WrappedForwardFailure(failure: Register.ForwardFailure[ChannelCommand]) extends Command private case class WrappedAddResponse(res: CommandResponse[CMD_ADD_HTLC]) extends Command private case class WrappedOnTheFlyFundingResponse(result: Peer.ProposeOnTheFlyFundingResponse) extends Command + private case class WrappedChannelInfo(result: RES_GET_CHANNEL_INFO) extends Command // @formatter:on // @formatter:off @@ -136,10 +138,11 @@ class ChannelRelay private(nodeParams: NodeParams, import ChannelRelay._ - private val forwardFailureAdapter = context.messageAdapter[Register.ForwardFailure[CMD_ADD_HTLC]](WrappedForwardFailure) + private val forwardFailureAdapter = context.messageAdapter[Register.ForwardFailure[ChannelCommand]](WrappedForwardFailure) private val addResponseAdapter = context.messageAdapter[CommandResponse[CMD_ADD_HTLC]](WrappedAddResponse) private val forwardNodeIdFailureAdapter = context.messageAdapter[Register.ForwardNodeIdFailure[Peer.ProposeOnTheFlyFunding]](_ => WrappedOnTheFlyFundingResponse(Peer.ProposeOnTheFlyFundingResponse.NotAvailable("peer not found"))) private val onTheFlyFundingResponseAdapter = context.messageAdapter[Peer.ProposeOnTheFlyFundingResponse](WrappedOnTheFlyFundingResponse) + private val channelInfoAdapter = context.messageAdapter[RES_GET_CHANNEL_INFO](WrappedChannelInfo) private val nextPathKey_opt = r.payload match { case payload: IntermediatePayload.ChannelRelay.Blinded => Some(payload.nextPathKey) @@ -187,29 +190,52 @@ class ChannelRelay private(nodeParams: NodeParams, } } - def relay(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried]): Behavior[Command] = { + def relay(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried], inboundChannelUpdate_opt: Option[Either[Unit, ChannelUpdate]] = None): Behavior[Command] = { Behaviors.receiveMessagePartial { case DoRelay => - if (previousFailures.isEmpty) { - val nextNodeId_opt = channels.headOption.map(_._2.nextNodeId) - context.log.info("relaying htlc #{} from channelId={} to requestedShortChannelId={} nextNode={}", r.add.id, r.add.channelId, requestedShortChannelId_opt, nextNodeId_opt.getOrElse("")) + if (nodeParams.routerConf.blip18InboundFees && inboundChannelUpdate_opt.isEmpty) { + register ! Register.Forward(forwardFailureAdapter, r.add.channelId, CMD_GET_CHANNEL_INFO(channelInfoAdapter)) + waitForInboundChannelInfo(remoteFeatures_opt, previousFailures) + } else { + if (previousFailures.isEmpty) { + val nextNodeId_opt = channels.headOption.map(_._2.nextNodeId) + context.log.info("relaying htlc #{} from channelId={} to requestedShortChannelId={} nextNode={}", r.add.id, r.add.channelId, requestedShortChannelId_opt, nextNodeId_opt.getOrElse("")) + } + context.log.debug("attempting relay previousAttempts={}", previousFailures.size) + handleRelay(remoteFeatures_opt, previousFailures, inboundChannelUpdate_opt.flatMap(_.toOption)) match { + case RelayFailure(cmdFail) => + Metrics.recordPaymentRelayFailed(Tags.FailureType(cmdFail), Tags.RelayType.Channel) + context.log.info("rejecting htlc reason={}", cmdFail.reason) + safeSendAndStop(r.add.channelId, cmdFail) + case RelayNeedsFunding(nextNodeId, cmdFail) => + // Note that in the channel relay case, we don't have any outgoing onion shared secrets. + val cmd = Peer.ProposeOnTheFlyFunding(onTheFlyFundingResponseAdapter, r.amountToForward, r.add.paymentHash, r.outgoingCltv, r.nextPacket, Nil, nextPathKey_opt, upstream) + register ! Register.ForwardNodeId(forwardNodeIdFailureAdapter, nextNodeId, cmd) + waitForOnTheFlyFundingResponse(cmdFail) + case RelaySuccess(selectedChannelId, cmdAdd) => + context.log.info("forwarding htlc #{} from channelId={} to channelId={}", r.add.id, r.add.channelId, selectedChannelId) + register ! Register.Forward(forwardFailureAdapter, selectedChannelId, cmdAdd) + waitForAddResponse(selectedChannelId, remoteFeatures_opt, previousFailures) + } } - context.log.debug("attempting relay previousAttempts={}", previousFailures.size) - handleRelay(remoteFeatures_opt, previousFailures) match { - case RelayFailure(cmdFail) => - Metrics.recordPaymentRelayFailed(Tags.FailureType(cmdFail), Tags.RelayType.Channel) - context.log.info("rejecting htlc reason={}", cmdFail.reason) - safeSendAndStop(r.add.channelId, cmdFail) - case RelayNeedsFunding(nextNodeId, cmdFail) => - // Note that in the channel relay case, we don't have any outgoing onion shared secrets. - val cmd = Peer.ProposeOnTheFlyFunding(onTheFlyFundingResponseAdapter, r.amountToForward, r.add.paymentHash, r.outgoingCltv, r.nextPacket, Nil, nextPathKey_opt, upstream) - register ! Register.ForwardNodeId(forwardNodeIdFailureAdapter, nextNodeId, cmd) - waitForOnTheFlyFundingResponse(cmdFail) - case RelaySuccess(selectedChannelId, cmdAdd) => - context.log.info("forwarding htlc #{} from channelId={} to channelId={}", r.add.id, r.add.channelId, selectedChannelId) - register ! Register.Forward(forwardFailureAdapter, selectedChannelId, cmdAdd) - waitForAddResponse(selectedChannelId, remoteFeatures_opt, previousFailures) + } + } + + private def waitForInboundChannelInfo(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried]): Behavior[Command] = { + Behaviors.receiveMessagePartial { + case WrappedChannelInfo(res) => + val inboundChannelUpdate_opt = res.data match { + case d: DATA_NORMAL => Some(Right(d.channelUpdate)) + case _ => + context.log.error("couldn't get channel info for channel {}: invalid channel state {}", res.channelId, res.state) + Some(Left(())) } + context.self ! DoRelay + relay(remoteFeatures_opt, previousFailures, inboundChannelUpdate_opt) + case WrappedForwardFailure(failure) => + context.log.error("couldn't get channel info for {}", failure.fwd.channelId) + context.self ! DoRelay + relay(remoteFeatures_opt, previousFailures, Some(Left(()))) } } @@ -315,10 +341,10 @@ class ChannelRelay private(nodeParams: NodeParams, * - a CMD_FAIL_HTLC to be sent back upstream * - a CMD_ADD_HTLC to propagate downstream */ - private def handleRelay(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried]): RelayResult = { + private def handleRelay(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried], inboundChannelUpdate_opt: Option[ChannelUpdate]): RelayResult = { val alreadyTried = previousFailures.map(_.channelId) - selectPreferredChannel(alreadyTried) match { - case Some(outgoingChannel) => relayOrFail(outgoingChannel) + selectPreferredChannel(alreadyTried, inboundChannelUpdate_opt) match { + case Some(outgoingChannel) => relayOrFail(outgoingChannel, inboundChannelUpdate_opt) case None => // No more channels to try. val cmdFail = if (previousFailures.nonEmpty) { @@ -333,7 +359,7 @@ class ChannelRelay private(nodeParams: NodeParams, makeCmdFailHtlc(r.add.id, UnknownNextPeer()) } walletNodeId_opt match { - case Some(walletNodeId) if shouldAttemptOnTheFlyFunding(remoteFeatures_opt, previousFailures) => RelayNeedsFunding(walletNodeId, cmdFail) + case Some(walletNodeId) if shouldAttemptOnTheFlyFunding(remoteFeatures_opt, previousFailures, inboundChannelUpdate_opt) => RelayNeedsFunding(walletNodeId, cmdFail) case _ => RelayFailure(cmdFail) } } @@ -345,7 +371,7 @@ class ChannelRelay private(nodeParams: NodeParams, * * If no suitable channel is found we default to the originally requested channel. */ - private def selectPreferredChannel(alreadyTried: Seq[ByteVector32]): Option[OutgoingChannel] = { + private def selectPreferredChannel(alreadyTried: Seq[ByteVector32], inboundChannelUpdate_opt: Option[ChannelUpdate]): Option[OutgoingChannel] = { context.log.debug("selecting next channel with requestedShortChannelId={}", requestedShortChannelId_opt) // we filter out channels that we have already tried val candidateChannels: Map[ByteVector32, OutgoingChannel] = channels -- alreadyTried @@ -353,7 +379,7 @@ class ChannelRelay private(nodeParams: NodeParams, candidateChannels .values .map { channel => - val relayResult = relayOrFail(channel) + val relayResult = relayOrFail(channel, inboundChannelUpdate_opt) context.log.debug("candidate channel: channelId={} availableForSend={} capacity={} channelUpdate={} result={}", channel.channelId, channel.commitments.availableBalanceForSend, @@ -402,9 +428,9 @@ class ChannelRelay private(nodeParams: NodeParams, * channel, because some parameters don't match with our settings for that channel. In that case we directly fail the * htlc. */ - private def relayOrFail(outgoingChannel: OutgoingChannelParams): RelayResult = { + private def relayOrFail(outgoingChannel: OutgoingChannelParams, inboundChannelUpdate_opt: Option[ChannelUpdate]): RelayResult = { val update = outgoingChannel.channelUpdate - validateRelayParams(outgoingChannel) match { + validateRelayParams(outgoingChannel, inboundChannelUpdate_opt) match { case Some(fail) => RelayFailure(fail) case None if !update.channelFlags.isEnabled => @@ -415,14 +441,15 @@ class ChannelRelay private(nodeParams: NodeParams, } } - private def validateRelayParams(outgoingChannel: OutgoingChannelParams): Option[CMD_FAIL_HTLC] = { + private def validateRelayParams(outgoingChannel: OutgoingChannelParams, inboundChannelUpdate_opt: Option[ChannelUpdate]): Option[CMD_FAIL_HTLC] = { val update = outgoingChannel.channelUpdate // If our current channel update was recently created, we accept payments that used our previous channel update. val allowPreviousUpdate = TimestampSecond.now() - update.timestamp <= nodeParams.relayParams.enforcementDelay val prevUpdate_opt = if (allowPreviousUpdate) outgoingChannel.prevChannelUpdate else None val htlcMinimumOk = update.htlcMinimumMsat <= r.amountToForward || prevUpdate_opt.exists(_.htlcMinimumMsat <= r.amountToForward) val expiryDeltaOk = update.cltvExpiryDelta <= r.expiryDelta || prevUpdate_opt.exists(_.cltvExpiryDelta <= r.expiryDelta) - val feesOk = nodeFee(update.relayFees, r.amountToForward) <= r.relayFeeMsat || prevUpdate_opt.exists(u => nodeFee(u.relayFees, r.amountToForward) <= r.relayFeeMsat) + val feesOk = totalFee(r.amountToForward, update.relayFees, inboundChannelUpdate_opt.flatMap(_.blip18InboundFees_opt)) <= r.relayFeeMsat || + prevUpdate_opt.exists(u => totalFee(r.amountToForward, u.relayFees, inboundChannelUpdate_opt.flatMap(_.blip18InboundFees_opt)) <= r.relayFeeMsat) if (!htlcMinimumOk) { Some(makeCmdFailHtlc(r.add.id, AmountBelowMinimum(r.amountToForward, Some(update)))) } else if (!expiryDeltaOk) { @@ -435,7 +462,7 @@ class ChannelRelay private(nodeParams: NodeParams, } /** If we fail to relay a payment, we may want to attempt on-the-fly funding. */ - private def shouldAttemptOnTheFlyFunding(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried]): Boolean = { + private def shouldAttemptOnTheFlyFunding(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried], inboundChannelUpdate_opt: Option[ChannelUpdate]): Boolean = { val featureOk = Features.canUseFeature(nodeParams.features.initFeatures(), remoteFeatures_opt.getOrElse(Features.empty), Features.OnTheFlyFunding) // If we have a channel with the next node, we only want to perform on-the-fly funding for liquidity issues. val liquidityIssue = previousFailures.forall { @@ -444,7 +471,7 @@ class ChannelRelay private(nodeParams: NodeParams, } // If we have a channel with the next peer, but we skipped it because the sender is using invalid relay parameters, // we don't want to perform on-the-fly funding: the sender should send a valid payment first. - val relayParamsOk = channels.values.forall(c => validateRelayParams(c).isEmpty) + val relayParamsOk = channels.values.forall(c => validateRelayParams(c, inboundChannelUpdate_opt).isEmpty) featureOk && liquidityIssue && relayParamsOk } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala index 920274be82..9bcdc61bd3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala @@ -30,7 +30,8 @@ import fr.acinq.eclair.db.PendingCommandsDb import fr.acinq.eclair.payment._ import fr.acinq.eclair.reputation.{Reputation, ReputationRecorder} import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{CltvExpiryDelta, Logs, MilliSatoshi, NodeParams, RealShortChannelId, TimestampMilli} +import fr.acinq.eclair._ +import fr.acinq.eclair.{CltvExpiryDelta, Logs, MilliSatoshi, NodeParams, RealShortChannelId} import grizzled.slf4j.Logging import scala.concurrent.Promise @@ -139,6 +140,22 @@ object Relayer extends Logging { val zero: RelayFees = RelayFees(MilliSatoshi(0), 0) } + case class InboundFees(feeBase: MilliSatoshi, feeProportionalMillionths: Long) + + object InboundFees { + def apply(feeBaseInt32: Int, feeProportionalMillionthsInt32: Int): InboundFees = { + InboundFees(MilliSatoshi(feeBaseInt32), feeProportionalMillionthsInt32) + } + + def fromOptions(inboundFeeBase_opt: Option[MilliSatoshi], inboundFeeProportionalMillionths_opt: Option[Long]): Option[InboundFees] = { + if (inboundFeeBase_opt.isEmpty && inboundFeeProportionalMillionths_opt.isEmpty) { + None + } else { + Some(InboundFees(inboundFeeBase_opt.getOrElse(0.msat), inboundFeeProportionalMillionths_opt.getOrElse(0L))) + } + } + } + case class AsyncPaymentsParams(holdTimeoutBlocks: Int, cancelSafetyBeforeTimeout: CltvExpiryDelta) case class RelayParams(publicChannelFees: RelayFees, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/BlindedPathsResolver.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/BlindedPathsResolver.scala index df284bf46f..14b6add3c0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/BlindedPathsResolver.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/BlindedPathsResolver.scala @@ -161,7 +161,7 @@ private class BlindedPathsResolver(nodeParams: NodeParams, resolved: Seq[ResolvedPath]): Behavior[Command] = { // Note that we default to private fees if we don't have a channel yet with that node. // The announceChannel parameter is ignored if we already have a channel. - val relayFees = getRelayFees(nodeParams, nextNodeId.publicKey, announceChannel = false) + val (relayFees, inboundFees_opt) = getRelayFees(nodeParams, nextNodeId.publicKey, announceChannel = false) val shouldRelay = paymentRelayData.paymentRelay.feeBase >= relayFees.feeBase && paymentRelayData.paymentRelay.feeProportionalMillionths >= relayFees.feeProportionalMillionths && paymentRelayData.paymentRelay.cltvExpiryDelta >= nodeParams.channelConf.expiryDelta diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala index b486e22f2c..1c80b134b3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala @@ -372,7 +372,7 @@ object MultiPartPaymentLifecycle { case class PaymentSucceeded(request: SendMultiPartPayment, preimage: ByteVector32, parts: Seq[PartialPayment], pending: Set[UUID], remainingAttribution_opt: Option[ByteVector]) extends Data private def createRouteRequest(replyTo: ActorRef, nodeParams: NodeParams, routeParams: RouteParams, d: PaymentProgress, cfg: SendPaymentConfig): RouteRequest = { - RouteRequest(replyTo.toTyped, nodeParams.nodeId, d.request.recipient, routeParams, d.ignore, allowMultiPart = true, d.pending.values.toSeq, Some(cfg.paymentContext)) + RouteRequest(replyTo.toTyped, nodeParams.nodeId, d.request.recipient, routeParams, d.ignore, allowMultiPart = true, d.pending.values.toSeq, Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) } private def createChildPayment(replyTo: ActorRef, route: Route, request: SendMultiPartPayment): SendPaymentToRoute = { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala index aa564e0798..d6509dad7e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala @@ -56,7 +56,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A case Event(request: SendPaymentToRoute, WaitingForRequest) => log.debug("sending {} to route {}", request.amount, request.printRoute()) request.route.fold( - hops => router ! FinalizeRoute(self, hops, request.recipient.extraEdges, paymentContext = Some(cfg.paymentContext)), + hops => router ! FinalizeRoute(self, hops, request.recipient.extraEdges, paymentContext = Some(cfg.paymentContext), nodeParams.routerConf.blip18InboundFees, nodeParams.routerConf.excludePositiveInboundFees), route => self ! RouteResponse(route :: Nil) ) if (cfg.storeInDb) { @@ -66,7 +66,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A case Event(request: SendPaymentToNode, WaitingForRequest) => log.debug("sending {} to {}", request.amount, request.recipient.nodeId) - router ! RouteRequest(self, nodeParams.nodeId, request.recipient, request.routeParams, paymentContext = Some(cfg.paymentContext)) + router ! RouteRequest(self, nodeParams.nodeId, request.recipient, request.routeParams, paymentContext = Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) if (cfg.storeInDb) { paymentsDb.addOutgoingPayment(OutgoingPayment(id, cfg.parentId, cfg.externalId, paymentHash, cfg.paymentType, request.amount, request.recipient.totalAmount, request.recipient.nodeId, TimestampMilli.now(), cfg.invoice, cfg.payerKey_opt, OutgoingPaymentStatus.Pending)) } @@ -167,7 +167,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A data.request match { case request: SendPaymentToNode => val ignore1 = PaymentFailure.updateIgnored(failure, data.ignore) - router ! RouteRequest(self, nodeParams.nodeId, data.recipient, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext)) + router ! RouteRequest(self, nodeParams.nodeId, data.recipient, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) goto(WAITING_FOR_ROUTE) using WaitingForRoute(data.request, data.failures :+ failure, ignore1) case _: SendPaymentToRoute => log.error("unexpected retry during SendPaymentToRoute") @@ -277,7 +277,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A log.error("unexpected retry during SendPaymentToRoute") stop(FSM.Normal) case request: SendPaymentToNode => - router ! RouteRequest(self, nodeParams.nodeId, recipient1, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext)) + router ! RouteRequest(self, nodeParams.nodeId, recipient1, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) goto(WAITING_FOR_ROUTE) using WaitingForRoute(request.copy(recipient = recipient1), failures :+ failure, ignore1) } } else { @@ -288,7 +288,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A log.error("unexpected retry during SendPaymentToRoute") stop(FSM.Normal) case request: SendPaymentToNode => - router ! RouteRequest(self, nodeParams.nodeId, recipient, request.routeParams, ignore + nodeId, paymentContext = Some(cfg.paymentContext)) + router ! RouteRequest(self, nodeParams.nodeId, recipient, request.routeParams, ignore + nodeId, paymentContext = Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) goto(WAITING_FOR_ROUTE) using WaitingForRoute(request, failures :+ failure, ignore + nodeId) } } @@ -302,7 +302,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A log.error("unexpected retry during SendPaymentToRoute") stop(FSM.Normal) case request: SendPaymentToNode => - router ! RouteRequest(self, nodeParams.nodeId, recipient, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext)) + router ! RouteRequest(self, nodeParams.nodeId, recipient, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) goto(WAITING_FOR_ROUTE) using WaitingForRoute(request, failures :+ failure, ignore1) } case Right(e@Sphinx.DecryptedFailurePacket(nodeId, failureMessage)) => diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala index 305b1410ee..b76cba8426 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala @@ -112,7 +112,9 @@ object EclairInternalsSerializer { ("syncConf" | syncConfCodec) :: ("pathFindingExperimentConf" | pathFindingExperimentConfCodec) :: ("messageRouteParams" | messageRouteParamsCodec) :: - ("balanceEstimateHalfLife" | finiteDurationCodec)).as[RouterConf] + ("balanceEstimateHalfLife" | finiteDurationCodec) :: + ("blip18InboundFees" | bool(8)) :: + ("excludePositiveInboundFees" | bool(8))).as[RouterConf] val overrideFeaturesListCodec: Codec[List[(PublicKey, Features[Feature])]] = listOfN(uint16, publicKey ~ lengthPrefixedFeaturesCodec) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala index 1809e7263b..56e02ed362 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala @@ -18,6 +18,9 @@ package fr.acinq.eclair.router import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey, sha256, verifySignature} import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector64, Crypto, LexicographicalOrdering} +import fr.acinq.eclair.channel.ChannelParams +import fr.acinq.eclair.payment.relay.Relayer.{InboundFees, RelayFees} +import fr.acinq.eclair.wire.protocol.ChannelUpdateTlv.Blip18InboundFee import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{CltvExpiryDelta, Feature, Features, MilliSatoshi, NodeFeature, RealShortChannelId, ShortChannelId, TimestampSecond, TimestampSecondLong, serializationResult} import scodec.bits.ByteVector @@ -120,10 +123,11 @@ object Announcements { u1.htlcMinimumMsat == u2.htlcMinimumMsat && u1.htlcMaximumMsat == u2.htlcMaximumMsat - def makeChannelUpdate(chainHash: BlockHash, nodeSecret: PrivateKey, remoteNodeId: PublicKey, shortChannelId: ShortChannelId, cltvExpiryDelta: CltvExpiryDelta, htlcMinimumMsat: MilliSatoshi, feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long, htlcMaximumMsat: MilliSatoshi, isPrivate: Boolean = false, enable: Boolean = true, timestamp: TimestampSecond = TimestampSecond.now()): ChannelUpdate = { + def makeChannelUpdate(chainHash: BlockHash, nodeSecret: PrivateKey, remoteNodeId: PublicKey, shortChannelId: ShortChannelId, cltvExpiryDelta: CltvExpiryDelta, htlcMinimumMsat: MilliSatoshi, feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long, htlcMaximumMsat: MilliSatoshi, isPrivate: Boolean = false, enable: Boolean = true, timestamp: TimestampSecond = TimestampSecond.now(), inboundFees_opt: Option[InboundFees] = None): ChannelUpdate = { val messageFlags = ChannelUpdate.MessageFlags(isPrivate) val channelFlags = ChannelUpdate.ChannelFlags(isNode1 = isNode1(nodeSecret.publicKey, remoteNodeId), isEnabled = enable) - val witness = channelUpdateWitnessEncode(chainHash, shortChannelId, timestamp, messageFlags, channelFlags, cltvExpiryDelta, htlcMinimumMsat, feeBaseMsat, feeProportionalMillionths, htlcMaximumMsat, TlvStream.empty) + val tlvStream = inboundFees_opt.map(fees => TlvStream[ChannelUpdateTlv](Blip18InboundFee(fees))).getOrElse(TlvStream.empty) + val witness = channelUpdateWitnessEncode(chainHash, shortChannelId, timestamp, messageFlags, channelFlags, cltvExpiryDelta, htlcMinimumMsat, feeBaseMsat, feeProportionalMillionths, htlcMaximumMsat, tlvStream) val sig = Crypto.sign(witness, nodeSecret) ChannelUpdate( signature = sig, @@ -136,7 +140,8 @@ object Announcements { htlcMinimumMsat = htlcMinimumMsat, feeBaseMsat = feeBaseMsat, feeProportionalMillionths = feeProportionalMillionths, - htlcMaximumMsat = htlcMaximumMsat + htlcMaximumMsat = htlcMaximumMsat, + tlvStream = tlvStream ) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala index 13d98e2c8a..93956c4db7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala @@ -21,6 +21,7 @@ import fr.acinq.bitcoin.scalacompat.{Btc, MilliBtc, Satoshi} import fr.acinq.eclair._ import fr.acinq.eclair.payment.Invoice import fr.acinq.eclair.payment.relay.Relayer.RelayFees +import fr.acinq.eclair.router.Router.HopRelayParams import fr.acinq.eclair.router.Graph.GraphStructure.GraphEdge import fr.acinq.eclair.router.Router._ import fr.acinq.eclair.wire.protocol.{ChannelUpdate, NodeAnnouncement} @@ -212,10 +213,11 @@ object Graph { wr: WeightRatios[PaymentPathWeight], currentBlockHeight: BlockHeight, boundaries: PaymentPathWeight => Boolean, - includeLocalChannelCost: Boolean): Seq[WeightedPath[PaymentPathWeight]] = { + includeLocalChannelCost: Boolean, + excludePositiveInboundFees: Boolean = false): Seq[WeightedPath[PaymentPathWeight]] = { // find the shortest path (k = 0) val targetWeight = PaymentPathWeight(amount) - dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost) match { + dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost, excludePositiveInboundFees) match { case None => Seq.empty // if we can't even find a single path, avoid returning a Seq(Seq.empty) case Some(shortestPath) => @@ -253,7 +255,7 @@ object Graph { val alreadyExploredVertices = rootPathEdges.map(_.desc.b).toSet val rootPathWeight = pathWeight(g.balances, sourceNode, rootPathEdges, amount, currentBlockHeight, wr, includeLocalChannelCost) // find the "spur" path, a sub-path going from the spur node to the target avoiding previously found sub-paths - dijkstraShortestPath(g, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost) match { + dijkstraShortestPath(g, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost, excludePositiveInboundFees) match { case Some(spurPath) => val completePath = spurPath ++ rootPathEdges val candidatePath = WeightedPath(completePath, pathWeight(g.balances, sourceNode, completePath, amount, currentBlockHeight, wr, includeLocalChannelCost)) @@ -305,7 +307,8 @@ object Graph { nodeFeatures: Features[NodeFeature], currentBlockHeight: BlockHeight, wr: WeightRatios[RichWeight], - includeLocalChannelCost: Boolean): Option[Seq[GraphEdge]] = { + includeLocalChannelCost: Boolean, + excludePositiveInboundFees: Boolean): Option[Seq[GraphEdge]] = { // the graph does not contain source/destination nodes val sourceNotInGraph = !g.graph.containsVertex(sourceNode) && !extraEdges.exists(_.desc.a == sourceNode) val targetNotInGraph = !g.graph.containsVertex(targetNode) && !extraEdges.exists(_.desc.b == targetNode) @@ -344,6 +347,7 @@ object Graph { if (current.weight.canUseEdge(edge) && !ignoredEdges.contains(edge.desc) && !ignoredVertices.contains(neighbor) && + (!excludePositiveInboundFees || g.graph.getBackEdge(edge).flatMap(_.getChannelUpdate).flatMap(_.blip18InboundFees_opt).forall(i => i.feeBase.toLong <= 0 && i.feeProportionalMillionths <= 0)) && (neighbor == sourceNode || g.graph.getVertexFeatures(neighbor).areSupported(nodeFeatures))) { // NB: this contains the amount (including fees) that will need to be sent to `neighbor`, but the amount that // will be relayed through that edge is the one in `currentWeight`. @@ -388,7 +392,7 @@ object Graph { boundaries: MessagePathWeight => Boolean, currentBlockHeight: BlockHeight, wr: MessageWeightRatios): Option[Seq[GraphEdge]] = - dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges = Set.empty, ignoredVertices, extraEdges = Set.empty, MessagePathWeight.zero, boundaries, Features(Features.OnionMessages -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true) + dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges = Set.empty, ignoredVertices, extraEdges = Set.empty, MessagePathWeight.zero, boundaries, Features(Features.OnionMessages -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true, excludePositiveInboundFees = false) /** * Find non-overlapping (no vertices shared) payment paths that support route blinding @@ -405,12 +409,13 @@ object Graph { pathsToFind: Int, wr: WeightRatios[PaymentPathWeight], currentBlockHeight: BlockHeight, - boundaries: PaymentPathWeight => Boolean): Seq[WeightedPath[PaymentPathWeight]] = { + boundaries: PaymentPathWeight => Boolean, + excludePositiveInboundFees: Boolean): Seq[WeightedPath[PaymentPathWeight]] = { val paths = new mutable.ArrayBuffer[WeightedPath[PaymentPathWeight]](pathsToFind) val verticesToIgnore = new mutable.HashSet[PublicKey]() verticesToIgnore.addAll(ignoredVertices) for (_ <- 1 to pathsToFind) { - dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, verticesToIgnore.toSet, extraEdges = Set.empty, PaymentPathWeight(amount), boundaries, Features(Features.RouteBlinding -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true) match { + dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, verticesToIgnore.toSet, extraEdges = Set.empty, PaymentPathWeight(amount), boundaries, Features(Features.RouteBlinding -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true, excludePositiveInboundFees) match { case Some(path) => val weight = pathWeight(g.balances, sourceNode, path, amount, currentBlockHeight, wr, includeLocalChannelCost = true) paths += WeightedPath(path, weight) @@ -502,6 +507,11 @@ object Graph { */ case class GraphEdge private(desc: ChannelDesc, params: HopRelayParams, capacity: Satoshi, balance_opt: Option[MilliSatoshi]) { def fee(amount: MilliSatoshi): MilliSatoshi = params.fee(amount) + + def getChannelUpdate: Option[ChannelUpdate] = params match { + case HopRelayParams.FromAnnouncement(update, _) => Some(update) + case _ => None + } } object GraphEdge { @@ -616,6 +626,10 @@ object Graph { def getEdge(desc: ChannelDesc): Option[GraphEdge] = vertices.get(desc.b).flatMap(_.incomingEdges.get(desc)) + def getBackEdge(desc: ChannelDesc): Option[GraphEdge] = getEdge(desc.copy(a = desc.b, b = desc.a)) + + def getBackEdge(edge: GraphEdge): Option[GraphEdge] = getBackEdge(edge.desc) + /** * @param keyA the key associated with the starting vertex * @param keyB the key associated with the ending vertex diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala index cba5f42684..d5250313d4 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala @@ -57,6 +57,14 @@ object RouteCalculation { } } + def validatePositiveInboundFees(route: Route, excludePositiveInboundFees: Boolean): Try[Route] = { + if (!excludePositiveInboundFees || route.hops.forall(hop => hop.params.inboundFees_opt.forall(i => i.feeBase <= 0.msat && i.feeProportionalMillionths <= 0))) { + Success(route) + } else { + Failure(new IllegalArgumentException("Route contains hops with positive inbound fees")) + } + } + Logs.withMdc(log)(Logs.mdc( category_opt = Some(LogCategory.PAYMENT), parentPaymentId_opt = fr.paymentContext.map(_.parentId), @@ -75,8 +83,17 @@ object RouteCalculation { // select the largest edge (using balance when available, otherwise capacity). val selectedEdges = edges.map(es => es.maxBy(e => e.balance_opt.getOrElse(e.capacity.toMilliSatoshi))) val hops = selectedEdges.map(e => ChannelHop(getEdgeRelayScid(d, localNodeId, e), e.desc.a, e.desc.b, e.params)) - validateMaxRouteFee(Route(amount, hops, None), maxFee_opt) match { - case Success(route) => fr.replyTo ! RouteResponse(route :: Nil) + val route = if (fr.blip18InboundFees) { + validatePositiveInboundFees(routeWithInboundFees(amount, hops, g), fr.excludePositiveInboundFees) + } else { + Success(Route(amount, hops, None)) + } + route match { + case Success(r) => + validateMaxRouteFee(r, maxFee_opt) match { + case Success(validatedRoute) => fr.replyTo ! RouteResponse(validatedRoute :: Nil) + case Failure(f) => fr.replyTo ! PaymentRouteNotFound(f) + } case Failure(f) => fr.replyTo ! PaymentRouteNotFound(f) } case _ => @@ -107,8 +124,17 @@ object RouteCalculation { if (end != targetNodeId || hops.length != shortChannelIds.length) { fr.replyTo ! PaymentRouteNotFound(new IllegalArgumentException("The sequence of channels provided cannot be used to build a route to the target node")) } else { - validateMaxRouteFee(Route(amount, hops, None), maxFee_opt) match { - case Success(route) => fr.replyTo ! RouteResponse(route :: Nil) + val route = if (fr.blip18InboundFees) { + validatePositiveInboundFees(routeWithInboundFees(amount, hops, g), fr.excludePositiveInboundFees) + } else { + Success(Route(amount, hops, None)) + } + route match { + case Success(r) => + validateMaxRouteFee(r, maxFee_opt) match { + case Success(validatedRoute) => fr.replyTo ! RouteResponse(validatedRoute :: Nil) + case Failure(f) => fr.replyTo ! PaymentRouteNotFound(f) + } case Failure(f) => fr.replyTo ! PaymentRouteNotFound(f) } } @@ -198,9 +224,9 @@ object RouteCalculation { val tags = TagSet.Empty.withTag(Tags.MultiPart, r.allowMultiPart).withTag(Tags.Amount, Tags.amountBucket(amountToSend)) KamonExt.time(Metrics.FindRouteDuration.withTags(tags.withTag(Tags.NumberOfRoutes, routesToFind.toLong))) { val result = if (r.allowMultiPart) { - findMultiPartRoute(d.graphWithBalances, r.source, targetNodeId, amountToSend, maxFee, extraEdges, ignoredEdges, r.ignore.nodes, r.pendingPayments, r.routeParams, currentBlockHeight) + findMultiPartRoute(d.graphWithBalances, r.source, targetNodeId, amountToSend, maxFee, extraEdges, ignoredEdges, r.ignore.nodes, r.pendingPayments, r.routeParams, currentBlockHeight, r.blip18InboundFees, r.excludePositiveInboundFees) } else { - findRoute(d.graphWithBalances, r.source, targetNodeId, amountToSend, maxFee, routesToFind, extraEdges, ignoredEdges, r.ignore.nodes, r.routeParams, currentBlockHeight) + findRoute(d.graphWithBalances, r.source, targetNodeId, amountToSend, maxFee, routesToFind, extraEdges, ignoredEdges, r.ignore.nodes, r.routeParams, currentBlockHeight, r.blip18InboundFees, r.excludePositiveInboundFees) } result.map(routes => addFinalHop(r.target, routes)) match { case Success(routes) => @@ -236,7 +262,7 @@ object RouteCalculation { weight.length <= ROUTE_MAX_LENGTH && weight.cltv <= r.routeParams.boundaries.maxCltv } - val routes = Graph.routeBlindingPaths(d.graphWithBalances, r.source, r.target, r.amount, r.ignore.channels, r.ignore.nodes, r.pathsToFind, r.routeParams.heuristics, currentBlockHeight, boundaries) + val routes = Graph.routeBlindingPaths(d.graphWithBalances, r.source, r.target, r.amount, r.ignore.channels, r.ignore.nodes, r.pathsToFind, r.routeParams.heuristics, currentBlockHeight, boundaries, r.excludePositiveInboundFees) if (routes.isEmpty) { r.replyTo ! PaymentRouteNotFound(RouteNotFound) } else { @@ -310,13 +336,44 @@ object RouteCalculation { ignoredEdges: Set[ChannelDesc] = Set.empty, ignoredVertices: Set[PublicKey] = Set.empty, routeParams: RouteParams, - currentBlockHeight: BlockHeight): Try[Seq[Route]] = Try { - findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, routeParams, currentBlockHeight) match { - case Right(routes) => routes.map(route => Route(amount, route.path.map(graphEdgeToHop), None)) + currentBlockHeight: BlockHeight, + blip18InboundFees: Boolean = false, + excludePositiveInboundFees: Boolean = false, + ): Try[Seq[Route]] = Try { + findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, routeParams, currentBlockHeight, excludePositiveInboundFees) match { + case Right(routes) => routes.map { route => + if (blip18InboundFees) + routeWithInboundFees(amount, route.path.map(graphEdgeToHop), g.graph) + else + Route(amount, route.path.map(graphEdgeToHop), None) + } case Left(ex) => return Failure(ex) } } + private def routeWithInboundFees(amount: MilliSatoshi, routeHops: Seq[ChannelHop], g: DirectedGraph): Route = { + if (routeHops.tail.isEmpty) { + Route(amount, routeHops, None) + } else { + val hops = routeHops.reverse + val updatedHops = routeHops.head :: hops.zip(hops.tail).foldLeft(List.empty[ChannelHop]) { (hops, x) => + val (curr, prev) = x + val backEdge_opt = g.getBackEdge(ChannelDesc(prev.shortChannelId, prev.nodeId, prev.nextNodeId)) + val hop = curr.copy(params = curr.params match { + case hopParams: HopRelayParams.FromAnnouncement => + backEdge_opt + .flatMap(_.getChannelUpdate) + .map(u => hopParams.copy(inboundFees_opt = u.blip18InboundFees_opt)) + .getOrElse(hopParams) + case hopParams => hopParams + }) + + hop :: hops + } + Route(amount, updatedHops, None) + } + } + @tailrec private def findRouteInternal(g: GraphWithBalanceEstimates, localNodeId: PublicKey, @@ -328,7 +385,9 @@ object RouteCalculation { ignoredEdges: Set[ChannelDesc] = Set.empty, ignoredVertices: Set[PublicKey] = Set.empty, routeParams: RouteParams, - currentBlockHeight: BlockHeight): Either[RouterException, Seq[WeightedPath[PaymentPathWeight]]] = { + currentBlockHeight: BlockHeight, + excludePositiveInboundFees: Boolean): Either[RouterException, Seq[WeightedPath[PaymentPathWeight]]] = { + require(amount > 0.msat, "route amount must be strictly positive") if (localNodeId == targetNodeId) return Left(CannotRouteToSelf) @@ -341,7 +400,7 @@ object RouteCalculation { val boundaries: PaymentPathWeight => Boolean = { weight => feeOk(weight.amount - amount) && lengthOk(weight.length) && cltvOk(weight.cltv) } - val foundRoutes: Seq[WeightedPath[PaymentPathWeight]] = Graph.yenKshortestPaths(g, localNodeId, targetNodeId, amount, ignoredEdges, ignoredVertices, extraEdges, numRoutes, routeParams.heuristics, currentBlockHeight, boundaries, routeParams.includeLocalChannelCost) + val foundRoutes: Seq[WeightedPath[PaymentPathWeight]] = Graph.yenKshortestPaths(g, localNodeId, targetNodeId, amount, ignoredEdges, ignoredVertices, extraEdges, numRoutes, routeParams.heuristics, currentBlockHeight, boundaries, routeParams.includeLocalChannelCost, excludePositiveInboundFees) if (foundRoutes.nonEmpty) { val (directRoutes, indirectRoutes) = foundRoutes.partition(_.path.length == 1) val routes = if (routeParams.randomize) { @@ -358,7 +417,7 @@ object RouteCalculation { maxCltv = DEFAULT_ROUTE_MAX_CLTV, ) ) - findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, relaxedRouteParams, currentBlockHeight) + findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, relaxedRouteParams, currentBlockHeight, excludePositiveInboundFees) } else { Left(RouteNotFound) } @@ -389,8 +448,10 @@ object RouteCalculation { ignoredVertices: Set[PublicKey] = Set.empty, pendingHtlcs: Seq[Route] = Nil, routeParams: RouteParams, - currentBlockHeight: BlockHeight): Try[Seq[Route]] = Try { - findMultiPartRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, extraEdges, ignoredEdges, ignoredVertices, pendingHtlcs, routeParams, currentBlockHeight) match { + currentBlockHeight: BlockHeight, + blip18InboundFees: Boolean = false, + excludePositiveInboundFees: Boolean = false): Try[Seq[Route]] = Try { + findMultiPartRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, extraEdges, ignoredEdges, ignoredVertices, pendingHtlcs, routeParams, currentBlockHeight, blip18InboundFees, excludePositiveInboundFees) match { case Right(routes) => routes case Left(ex) => return Failure(ex) } @@ -407,6 +468,8 @@ object RouteCalculation { pendingHtlcs: Seq[Route] = Nil, routeParams: RouteParams, currentBlockHeight: BlockHeight, + blip18InboundFees: Boolean, + excludePositiveInboundFees: Boolean, now: TimestampSecond = TimestampSecond.now()): Either[RouterException, Seq[Route]] = { // We use Yen's k-shortest paths to find many paths for chunks of the total amount. // When the recipient is a direct peer, we have complete visibility on our local channels so we can use more accurate MPP parameters. @@ -425,16 +488,24 @@ object RouteCalculation { val minPartAmount = routeParams.mpp.minPartAmount.max(amount / numRoutes).min(amount) routeParams.copy(mpp = MultiPartParams(minPartAmount, numRoutes, routeParams.mpp.splittingStrategy)) } - findRouteInternal(g, localNodeId, targetNodeId, routeParams1.mpp.minPartAmount, maxFee, routeParams1.mpp.maxParts, extraEdges, ignoredEdges, ignoredVertices, routeParams1, currentBlockHeight) match { + findRouteInternal(g, localNodeId, targetNodeId, routeParams1.mpp.minPartAmount, maxFee, routeParams1.mpp.maxParts, extraEdges, ignoredEdges, ignoredVertices, routeParams1, currentBlockHeight, excludePositiveInboundFees) match { case Right(paths) => // We use these shortest paths to find a set of non-conflicting HTLCs that send the total amount. split(amount, mutable.Queue(paths: _*), initializeUsedCapacity(pendingHtlcs), routeParams1, g.balances, now) match { - case Right(routes) if validateMultiPartRoute(amount, maxFee, routes, routeParams.includeLocalChannelCost) => Right(routes) + case Right(routes) if validateMultiPartRoute(amount, maxFee, routes, routeParams.includeLocalChannelCost) => + if (blip18InboundFees) + Right(routes.map(r => routeWithInboundFees(r.amount, r.hops, g.graph))) + else + Right(routes) case Right(_) if routeParams.randomize => // We've found a multipart route, but it's too expensive. We try again without randomization to prioritize cheaper paths. val sortedPaths = paths.sortBy(_.weight.weight) split(amount, mutable.Queue(sortedPaths: _*), initializeUsedCapacity(pendingHtlcs), routeParams1, g.balances, now) match { - case Right(routes) if validateMultiPartRoute(amount, maxFee, routes, routeParams.includeLocalChannelCost) => Right(routes) + case Right(routes) if validateMultiPartRoute(amount, maxFee, routes, routeParams.includeLocalChannelCost) => + if (blip18InboundFees) + Right(routes.map(r => routeWithInboundFees(r.amount, r.hops, g.graph))) + else + Right(routes) case _ => Left(RouteNotFound) } case _ => Left(RouteNotFound) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala index 28b8e95b43..504813d2a7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala @@ -385,7 +385,9 @@ object Router { syncConf: SyncConf, pathFindingExperimentConf: PathFindingExperimentConf, messageRouteParams: MessageRouteParams, - balanceEstimateHalfLife: FiniteDuration) + balanceEstimateHalfLife: FiniteDuration, + blip18InboundFees: Boolean, + excludePositiveInboundFees: Boolean) // @formatter:off case class ChannelDesc private(shortChannelId: ShortChannelId, a: PublicKey, b: PublicKey){ @@ -490,7 +492,8 @@ object Router { // @formatter:off def cltvExpiryDelta: CltvExpiryDelta def relayFees: Relayer.RelayFees - final def fee(amount: MilliSatoshi): MilliSatoshi = nodeFee(relayFees, amount) + def inboundFees_opt: Option[Relayer.InboundFees] + final def fee(amount: MilliSatoshi): MilliSatoshi = totalFee(amount, relayFees, inboundFees_opt) def htlcMinimum: MilliSatoshi def htlcMaximum_opt: Option[MilliSatoshi] // @formatter:on @@ -498,7 +501,7 @@ object Router { object HopRelayParams { /** We learnt about this channel from a channel_update. */ - case class FromAnnouncement(channelUpdate: ChannelUpdate) extends HopRelayParams { + case class FromAnnouncement(channelUpdate: ChannelUpdate, inboundFees_opt: Option[Relayer.InboundFees] = None) extends HopRelayParams { override val cltvExpiryDelta = channelUpdate.cltvExpiryDelta override val relayFees = channelUpdate.relayFees override val htlcMinimum = channelUpdate.htlcMinimumMsat @@ -509,6 +512,7 @@ object Router { case class FromHint(extraHop: Invoice.ExtraEdge) extends HopRelayParams { override val cltvExpiryDelta = extraHop.cltvExpiryDelta override val relayFees = extraHop.relayFees + override val inboundFees_opt = None override val htlcMinimum = extraHop.htlcMinimum override val htlcMaximum_opt = extraHop.htlcMaximum_opt } @@ -516,6 +520,7 @@ object Router { def areSame(a: HopRelayParams, b: HopRelayParams, ignoreHtlcSize: Boolean = false): Boolean = a.cltvExpiryDelta == b.cltvExpiryDelta && a.relayFees == b.relayFees && + a.inboundFees_opt == b.inboundFees_opt && (ignoreHtlcSize || (a.htlcMinimum == b.htlcMinimum && a.htlcMaximum_opt == b.htlcMaximum_opt)) } @@ -626,7 +631,9 @@ object Router { ignore: Ignore = Ignore.empty, allowMultiPart: Boolean = false, pendingPayments: Seq[Route] = Nil, - paymentContext: Option[PaymentContext] = None) + paymentContext: Option[PaymentContext] = None, + blip18InboundFees: Boolean = false, + excludePositiveInboundFees: Boolean = false) case class BlindedRouteRequest(replyTo: typed.ActorRef[PaymentRouteResponse], source: PublicKey, @@ -634,12 +641,16 @@ object Router { amount: MilliSatoshi, routeParams: RouteParams, pathsToFind: Int, - ignore: Ignore = Ignore.empty) + ignore: Ignore = Ignore.empty, + blip18InboundFees: Boolean, + excludePositiveInboundFees: Boolean) case class FinalizeRoute(replyTo: typed.ActorRef[PaymentRouteResponse], route: PredefinedRoute, extraEdges: Seq[ExtraEdge] = Nil, - paymentContext: Option[PaymentContext] = None) + paymentContext: Option[PaymentContext] = None, + blip18InboundFees: Boolean = false, + excludePositiveInboundFees: Boolean = false) sealed trait PostmanRequest @@ -713,7 +724,7 @@ object Router { def amount: MilliSatoshi def targetNodeId: PublicKey def maxFee_opt: Option[MilliSatoshi] - } + } case class PredefinedNodeRoute(amount: MilliSatoshi, nodes: Seq[PublicKey], maxFee_opt: Option[MilliSatoshi] = None) extends PredefinedRoute { override def isEmpty = nodes.isEmpty override def targetNodeId: PublicKey = nodes.last 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 ba4d08fe57..219b8eaa1b 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 @@ -709,6 +709,9 @@ case class ChannelUpdate(signature: ByteVector64, def toStringShort: String = s"cltvExpiryDelta=$cltvExpiryDelta,feeBase=$feeBaseMsat,feeProportionalMillionths=$feeProportionalMillionths" def relayFees: Relayer.RelayFees = Relayer.RelayFees(feeBase = feeBaseMsat, feeProportionalMillionths = feeProportionalMillionths) + + def blip18InboundFees_opt: Option[Relayer.InboundFees] = + tlvStream.get[ChannelUpdateTlv.Blip18InboundFee].map(blip18 => Relayer.InboundFees(blip18.feeBase, blip18.feeProportionalMillionths)) } object ChannelUpdate { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RoutingTlv.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RoutingTlv.scala index d157a388b9..326dbd0084 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RoutingTlv.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RoutingTlv.scala @@ -16,6 +16,7 @@ package fr.acinq.eclair.wire.protocol +import fr.acinq.eclair.payment.relay.Relayer.InboundFees import fr.acinq.eclair.wire.protocol.CommonCodecs.{timestampSecond, varint, varintoverflow} import fr.acinq.eclair.wire.protocol.TlvCodecs.{tlvField, tlvStream} import fr.acinq.eclair.{TimestampSecond, UInt64} @@ -53,7 +54,20 @@ object ChannelAnnouncementTlv { sealed trait ChannelUpdateTlv extends Tlv object ChannelUpdateTlv { - val channelUpdateTlvCodec: Codec[TlvStream[ChannelUpdateTlv]] = tlvStream(discriminated[ChannelUpdateTlv].by(varint)) + case class Blip18InboundFee(feeBase: Int, feeProportionalMillionths: Int) extends ChannelUpdateTlv + + object Blip18InboundFee { + def apply(fees: InboundFees): Blip18InboundFee = Blip18InboundFee(fees.feeBase.toLong.toInt, fees.feeProportionalMillionths.toInt) + } + + private val blip18InboundFeeCodec: Codec[Blip18InboundFee] = tlvField(Codec( + ("feeBase" | int32) :: + ("feeProportionalMillionths" | int32) + ).as[Blip18InboundFee]) + + val channelUpdateTlvCodec: Codec[TlvStream[ChannelUpdateTlv]] = TlvCodecs.tlvStream(discriminated.by(varint) + .typecase(UInt64(55555), blip18InboundFeeCodec) + ) } sealed trait GossipTimestampFilterTlv extends Tlv diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala index 6623043044..05fe1318b5 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -33,7 +33,7 @@ import fr.acinq.eclair.io.Peer import fr.acinq.eclair.io.Peer.OpenChannel import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceiveStandardPayment import fr.acinq.eclair.payment.receive.PaymentHandler -import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, RelayFees} +import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, InboundFees, RelayFees} import fr.acinq.eclair.payment.send.PaymentIdentifier import fr.acinq.eclair.payment.send.PaymentInitiator._ import fr.acinq.eclair.payment.{Bolt11Invoice, Invoice, PaymentFailed} @@ -75,7 +75,7 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I val offerManager = TestProbe() val defaultOfferHandler = TestProbe() val kit = Kit( - TestConstants.Alice.nodeParams, + TestConstants.Alice.nodeParams.copy(routerConf = TestConstants.Alice.nodeParams.routerConf.copy(blip18InboundFees = true)), system, watcher.ref, paymentHandler.ref, @@ -658,9 +658,11 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I import f._ val peersDb = mock[PeersDb] + val inboundFeesDb = mock[InboundFeesDb] val databases = mock[Databases] databases.peers returns peersDb + databases.inboundFees returns inboundFeesDb val kitWithMockDb = kit.copy(nodeParams = kit.nodeParams.copy(db = databases)) val eclair = new EclairImpl(kitWithMockDb) @@ -672,7 +674,7 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I val b1 = randomBytes32() val map = Map(a1 -> a, a2 -> a, b1 -> b) - eclair.updateRelayFee(List(a, b), 999 msat, 1234).pipeTo(sender.ref) + eclair.updateRelayFee(List(a, b), 999 msat, 1234, Some(1 msat), Some(2)).pipeTo(sender.ref) register.expectMsg(Register.GetChannelsTo) register.reply(map) @@ -686,13 +688,16 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I register.expectNoMessage() assert(sender.expectMsgType[Map[ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] == Map( - Left(a1) -> Right(RES_SUCCESS(CMD_UPDATE_RELAY_FEE(ActorRef.noSender, 999 msat, 1234), a1)), - Left(a2) -> Right(RES_FAILURE(CMD_UPDATE_RELAY_FEE(ActorRef.noSender, 999 msat, 1234), CommandUnavailableInThisState(a2, "CMD_UPDATE_RELAY_FEE", channel.CLOSING))), + Left(a1) -> Right(RES_SUCCESS(CMD_UPDATE_RELAY_FEE(ActorRef.noSender, 999 msat, 1234, Some(1 msat), Some(2)), a1)), + Left(a2) -> Right(RES_FAILURE(CMD_UPDATE_RELAY_FEE(ActorRef.noSender, 999 msat, 1234, Some(1 msat), Some(2)), CommandUnavailableInThisState(a2, "CMD_UPDATE_RELAY_FEE", channel.CLOSING))), Left(b1) -> Left(ChannelNotFound(Left(b1))) )) peersDb.addOrUpdateRelayFees(a, RelayFees(999 msat, 1234)).wasCalled(once) peersDb.addOrUpdateRelayFees(b, RelayFees(999 msat, 1234)).wasCalled(once) + + inboundFeesDb.addOrUpdateInboundFees(a, InboundFees(1 msat, 2)).wasCalled(once) + inboundFeesDb.addOrUpdateInboundFees(b, InboundFees(1 msat, 2)).wasCalled(once) } test("channelBalances asks for all channels, usableBalances only for enabled ones") { f => diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 433d97a3ff..2818cad370 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -212,7 +212,7 @@ object TestConstants { channelRangeChunkSize = 20, channelQueryChunkSize = 5, peerLimit = 10, - whitelist = Set.empty + whitelist = Set.empty, ), pathFindingExperimentConf = PathFindingExperimentConf(Map("alice-test-experiment" -> PathFindingConf( randomize = false, @@ -237,6 +237,8 @@ object TestConstants { experimentPercentage = 100))), messageRouteParams = MessageRouteParams(8, MessageWeightRatios(0.7, 0.1, 0.2)), balanceEstimateHalfLife = 1 day, + blip18InboundFees = false, + excludePositiveInboundFees = false, ), socksProxy_opt = None, maxPaymentAttempts = 5, @@ -429,6 +431,8 @@ object TestConstants { experimentPercentage = 100))), messageRouteParams = MessageRouteParams(9, MessageWeightRatios(0.5, 0.2, 0.3)), balanceEstimateHalfLife = 1 day, + blip18InboundFees = false, + excludePositiveInboundFees = false, ), socksProxy_opt = None, maxPaymentAttempts = 5, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala index 99b1c7def8..44686d4aaa 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala @@ -36,6 +36,7 @@ sealed trait TestDatabases extends Databases { override def offers: OffersDb = db.offers override def pendingCommands: PendingCommandsDb = db.pendingCommands override def liquidity: LiquidityDb = db.liquidity + override def inboundFees: InboundFeesDb = db.inboundFees def close(): Unit // @formatter:on } @@ -46,7 +47,7 @@ object TestDatabases { def inMemoryDb(): Databases = { val connection = sqliteInMemory() - val dbs = Databases.SqliteDatabases(connection, connection, connection, jdbcUrlFile_opt = None) + val dbs = Databases.SqliteDatabases(connection, connection, connection, connection, jdbcUrlFile_opt = None) dbs.copy(channels = new SqliteChannelsDbWithValidation(dbs.channels)) } @@ -102,7 +103,7 @@ object TestDatabases { override lazy val db: Databases = { val jdbcUrlFile: File = new File(TestUtils.BUILD_DIRECTORY, s"jdbcUrlFile_${UUID.randomUUID()}.tmp") jdbcUrlFile.deleteOnExit() - val dbs = Databases.SqliteDatabases(connection, connection, connection, jdbcUrlFile_opt = Some(jdbcUrlFile)) + val dbs = Databases.SqliteDatabases(connection, connection, connection, connection, jdbcUrlFile_opt = Some(jdbcUrlFile)) dbs.copy(channels = new SqliteChannelsDbWithValidation(dbs.channels)) } override def close(): Unit = connection.close() diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/InboundFeesDbSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/InboundFeesDbSpec.scala new file mode 100644 index 0000000000..a896ed0d47 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/InboundFeesDbSpec.scala @@ -0,0 +1,45 @@ +package fr.acinq.eclair.db + +import fr.acinq.eclair.TestDatabases.{TestPgDatabases, TestSqliteDatabases} +import fr.acinq.eclair._ +import fr.acinq.eclair.db.pg.PgInboundFeesDb +import fr.acinq.eclair.db.sqlite.SqliteInboundFeesDb +import fr.acinq.eclair.payment.relay.Relayer.InboundFees +import org.scalatest.funsuite.AnyFunSuite + +class InboundFeesDbSpec extends AnyFunSuite { + + import fr.acinq.eclair.TestDatabases.forAllDbs + + test("init database two times in a row") { + forAllDbs { + case sqlite: TestSqliteDatabases => + new SqliteInboundFeesDb(sqlite.connection) + new SqliteInboundFeesDb(sqlite.connection) + case pg: TestPgDatabases => + new PgInboundFeesDb()(pg.datasource, pg.lock) + new PgInboundFeesDb()(pg.datasource, pg.lock) + } + } + + test("add and update inbound fees") { + forAllDbs { dbs => + val db = dbs.inboundFees + + val a = randomKey().publicKey + val b = randomKey().publicKey + + assert(db.getInboundFees(a).isEmpty) + assert(db.getInboundFees(b).isEmpty) + db.addOrUpdateInboundFees(a, InboundFees(1 msat, 123)) + assert(db.getInboundFees(a).contains(InboundFees(1 msat, 123))) + assert(db.getInboundFees(b).isEmpty) + db.addOrUpdateInboundFees(a, InboundFees(2 msat, 456)) + assert(db.getInboundFees(a).contains(InboundFees(2 msat, 456))) + assert(db.getInboundFees(b).isEmpty) + db.addOrUpdateInboundFees(b, InboundFees(3 msat, 789)) + assert(db.getInboundFees(a).contains(InboundFees(2 msat, 456))) + assert(db.getInboundFees(b).contains(InboundFees(3 msat, 789))) + } + } +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala index 525eaff283..581f2f3ba4 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala @@ -34,10 +34,12 @@ import fr.acinq.eclair.crypto.Sphinx import fr.acinq.eclair.io.{Peer, PeerReadyManager, Switchboard} import fr.acinq.eclair.payment.IncomingPaymentPacket.ChannelRelayPacket import fr.acinq.eclair.payment.relay.ChannelRelayer._ +import fr.acinq.eclair.payment.relay.Relayer.InboundFees import fr.acinq.eclair.payment.{ChannelPaymentRelayed, IncomingPaymentPacket, PaymentPacketSpec} import fr.acinq.eclair.reputation.{Reputation, ReputationRecorder} import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.wire.protocol.BlindedRouteData.PaymentRelayData +import fr.acinq.eclair.wire.protocol.ChannelUpdateTlv.Blip18InboundFee import fr.acinq.eclair.wire.protocol.PaymentOnion.IntermediatePayload import fr.acinq.eclair.wire.protocol.PaymentOnion.IntermediatePayload.ChannelRelay import fr.acinq.eclair.wire.protocol._ @@ -542,6 +544,20 @@ class ChannelRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("a expectFwdFail(register, r.add.channelId, CMD_FAIL_HTLC(r.add.id, FailureReason.LocalFailure(FeeInsufficient(r.add.amountMsat, Some(u3.channelUpdate))), None, commit = true)) } + ignore("relay that would fail (fee insufficient) when inbound fees are set") { f => + import f._ + + val payload = ChannelRelay.Standard(realScid1, outgoingAmount, outgoingExpiry) + val r = createValidIncomingPacket(payload) + val u = createLocalUpdate(channelId1, inboundFees_opt = Some(InboundFees(10000 msat, 100000))) + + channelRelayer ! WrappedLocalChannelUpdate(u) + channelRelayer ! Relay(r, TestConstants.Alice.nodeParams.nodeId, 1.0) + + expectFwdFail(register, r.add.channelId, CMD_FAIL_HTLC(r.add.id, FailureReason.LocalFailure(FeeInsufficient(r.add.amountMsat, Some(u.channelUpdate))), None, commit = true)) + } + + test("fail to relay when there is a local error") { f => import f._ @@ -907,11 +923,12 @@ object ChannelRelayerSpec { ShortIdAliases(localAlias, remoteAlias_opt = None) } - def createLocalUpdate(channelId: ByteVector32, channelUpdateScid_opt: Option[ShortChannelId] = None, balance: MilliSatoshi = 100_000_000 msat, capacity: Satoshi = 5_000_000 sat, enabled: Boolean = true, htlcMinimum: MilliSatoshi = 0 msat, timestamp: TimestampSecond = 0 unixsec, feeBaseMsat: MilliSatoshi = 1000 msat, feeProportionalMillionths: Long = 100, optionScidAlias: Boolean = false): LocalChannelUpdate = { + def createLocalUpdate(channelId: ByteVector32, channelUpdateScid_opt: Option[ShortChannelId] = None, balance: MilliSatoshi = 100_000_000 msat, capacity: Satoshi = 5_000_000 sat, enabled: Boolean = true, htlcMinimum: MilliSatoshi = 0 msat, timestamp: TimestampSecond = 0 unixsec, feeBaseMsat: MilliSatoshi = 1000 msat, feeProportionalMillionths: Long = 100, optionScidAlias: Boolean = false, inboundFees_opt: Option[InboundFees] = None): LocalChannelUpdate = { val aliases = createAliases(channelId) val realScid = channelIds.collectFirst { case (realScid: RealShortChannelId, cid) if cid == channelId => realScid }.get val channelUpdateScid = channelUpdateScid_opt.getOrElse(realScid) - val update = ChannelUpdate(ByteVector64(randomBytes(64)), Block.RegtestGenesisBlock.hash, channelUpdateScid, timestamp, ChannelUpdate.MessageFlags(dontForward = false), ChannelUpdate.ChannelFlags(isNode1 = true, isEnabled = enabled), CltvExpiryDelta(100), htlcMinimum, feeBaseMsat, feeProportionalMillionths, capacity.toMilliSatoshi) + val tlvStream = inboundFees_opt.map(fees => TlvStream[ChannelUpdateTlv](Blip18InboundFee(fees))).getOrElse(TlvStream.empty) + val update = ChannelUpdate(ByteVector64(randomBytes(64)), Block.RegtestGenesisBlock.hash, channelUpdateScid, timestamp, ChannelUpdate.MessageFlags(dontForward = false), ChannelUpdate.ChannelFlags(isNode1 = true, isEnabled = enabled), CltvExpiryDelta(100), htlcMinimum, feeBaseMsat, feeProportionalMillionths, capacity.toMilliSatoshi, tlvStream) val features: Set[PermanentChannelFeature] = Set( if (optionScidAlias) Some(ScidAlias) else None, ).flatten diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/AnnouncementsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/AnnouncementsSpec.scala index d336ed4993..f68f4ee09e 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/AnnouncementsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/AnnouncementsSpec.scala @@ -21,6 +21,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} import fr.acinq.eclair.TestConstants.Alice import fr.acinq.eclair.RealShortChannelId import fr.acinq.eclair._ +import fr.acinq.eclair.payment.relay.Relayer.InboundFees import fr.acinq.eclair.router.Announcements._ import fr.acinq.eclair.wire.protocol.ChannelUpdate.ChannelFlags import fr.acinq.eclair.wire.protocol.LightningMessageCodecs.nodeAnnouncementCodec @@ -135,6 +136,9 @@ class AnnouncementsSpec extends AnyFunSuite { val ann = makeChannelUpdate(Block.RegtestGenesisBlock.hash, Alice.nodeParams.privateKey, randomKey().publicKey, ShortChannelId(45561L), Alice.nodeParams.channelConf.expiryDelta, Alice.nodeParams.channelConf.htlcMinimum, Alice.nodeParams.relayParams.publicChannelFees.feeBase, Alice.nodeParams.relayParams.publicChannelFees.feeProportionalMillionths, 500000000 msat) assert(checkSig(ann, Alice.nodeParams.nodeId)) assert(!checkSig(ann, randomKey().publicKey)) + val annInboundFees = makeChannelUpdate(Block.RegtestGenesisBlock.hash, Alice.nodeParams.privateKey, randomKey().publicKey, ShortChannelId(45561L), Alice.nodeParams.channelConf.expiryDelta, Alice.nodeParams.channelConf.htlcMinimum, Alice.nodeParams.relayParams.publicChannelFees.feeBase, Alice.nodeParams.relayParams.publicChannelFees.feeProportionalMillionths, 500000000 msat, inboundFees_opt = Some(InboundFees(1 msat, 1))) + assert(checkSig(annInboundFees, Alice.nodeParams.nodeId)) + assert(!checkSig(annInboundFees.copy(tlvStream = TlvStream.empty), Alice.nodeParams.nodeId)) } test("check flags") { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18RouteCalculationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18RouteCalculationSpec.scala new file mode 100644 index 0000000000..51b49259a7 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18RouteCalculationSpec.scala @@ -0,0 +1,382 @@ +package fr.acinq.eclair.router + +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat.{Block, Satoshi, SatoshiLong} +import fr.acinq.eclair.payment.IncomingPaymentPacket.{ChannelRelayPacket, FinalPacket, decrypt} +import fr.acinq.eclair.payment.OutgoingPaymentPacket.buildOutgoingPayment +import fr.acinq.eclair.payment.PaymentPacketSpec.{paymentHash, paymentSecret} +import fr.acinq.eclair.payment.relay.Relayer.RelayFees +import fr.acinq.eclair.payment.send.ClearRecipient +import fr.acinq.eclair.reputation.Reputation +import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge} +import fr.acinq.eclair.router.Graph.HeuristicsConstants +import fr.acinq.eclair.router.RouteCalculation.{findMultiPartRoute, findRoute} +import fr.acinq.eclair.router.Router.MultiPartParams.FullCapacity +import fr.acinq.eclair.router.Router._ +import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.wire.protocol.PaymentOnion.FinalPayload +import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, MilliSatoshiLong, RealShortChannelId, ShortChannelId, TestConstants, TimestampSecond, TimestampSecondLong, randomBytes32, randomKey} +import org.scalatest.ParallelTestExecution +import org.scalatest.funsuite.AnyFunSuite + +import scala.concurrent.duration.DurationInt +import scala.util.{Failure, Success} + +class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { + + import Blip18RouteCalculationSpec._ + + val (priv_a, priv_b, priv_c, priv_d, priv_e, priv_f) = (randomKey(), randomKey(), randomKey(), randomKey(), randomKey(), randomKey()) + val (a, b, c, d, e, f) = (priv_a.publicKey, priv_b.publicKey, priv_c.publicKey, priv_d.publicKey, priv_e.publicKey, priv_f.publicKey) + + test("find a direct route") { + val g = GraphWithBalanceEstimates(DirectedGraph(Seq( + makeEdge(10L, a, b, minHtlc = 2 msat, feeBase = 0 msat, feeProportionalMillionth = 120, inboundFeeBase_opt = Some(0.msat), inboundFeeProportionalMillionth_opt = Some(-71)), + )), 1 day) + + val Success(route :: Nil) = findRoute(g, a, b, 10_000_000 msat, 10_000_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + + assert(route.channelFee(true) == 1200.msat) + } + + test("test findRoute with Blip18 enabled") { + // extracted from the LND code base + val g = GraphWithBalanceEstimates(DirectedGraph(Seq( + makeEdge(10L, a, b, minHtlc = 2 msat), + makeEdge(10L, b, a, minHtlc = 2 msat, inboundFeeBase_opt = Some(-5000 msat), inboundFeeProportionalMillionth_opt = Some(-60_000)), + + makeEdge(11L, b, c, 1000 msat, 50_000, minHtlc = 2 msat), + makeEdge(11L, c, b, minHtlc = 2 msat, inboundFeeBase_opt = Some(5000 msat), inboundFeeProportionalMillionth_opt = Some(0)), + + makeEdge(12L, c, d, 0 msat, 50_000, minHtlc = 2 msat), + makeEdge(12L, d, c, minHtlc = 2 msat, inboundFeeBase_opt = Some(-8000 msat), inboundFeeProportionalMillionth_opt = Some(-50_000)), + + makeEdge(13L, d, e, 9000 msat, 100_000, minHtlc = 2 msat), + makeEdge(13L, e, d, minHtlc = 2 msat, inboundFeeBase_opt = Some(2000 msat), inboundFeeProportionalMillionth_opt = Some(80_000)), + )), 1 day) + + val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = false) + + assert(route.channelFee(false) == 15_302.msat) + + val recipient = ClearRecipient(e, Features.empty, 100_000 msat, CltvExpiry(400018), paymentSecret) + val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, Reputation.Score.max) + + assert(payment.outgoingChannel == ShortChannelId(10L)) + assert(payment.cmd.amount == 115_302.msat) + + val packet_b = payment.cmd.onion + + val add_b = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_b, None, 1, None) + val Right(relay_b@ChannelRelayPacket(_, payload_b, packet_c, _)) = decrypt(add_b, priv_b, Features.empty) + assert(payload_b.outgoing.contains(ShortChannelId(11L))) + assert(relay_b.amountToForward == 115_302.msat) + assert(relay_b.relayFeeMsat == -15_302.msat) + + val add_c = UpdateAddHtlc(randomBytes32(), 1, 100_000 msat, paymentHash, CltvExpiry(400018), packet_c, None, 1, None) + val Right(relay_c@ChannelRelayPacket(_, payload_c, packet_d, _)) = decrypt(add_c, priv_c, Features.empty) + assert(payload_c.outgoing.contains(ShortChannelId(12L))) + assert(relay_c.amountToForward == 105_050.msat) + assert(relay_c.relayFeeMsat == -5050.msat) + + val add_d = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_d, None, 1, None) + val Right(relay_d@ChannelRelayPacket(_, payload_d, packet_e, _)) = decrypt(add_d, priv_d, Features.empty) + assert(payload_d.outgoing.contains(ShortChannelId(13L))) + assert(relay_d.amountToForward == 100_000.msat) + assert(relay_d.relayFeeMsat == 0.msat) + + val add_e = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_e, None, 1, None) + val Right(FinalPacket(_, payload_e, _)) = decrypt(add_e, priv_e, Features.empty) + assert(payload_e.isInstanceOf[FinalPayload.Standard]) + assert(payload_e.amount == 100_000.msat) + assert(payload_e.totalAmount == 100_000.msat) + } + + test("test findRoute with Blip18 disabled") { + // extracted from the LND code base + val g = GraphWithBalanceEstimates(DirectedGraph(Seq( + makeEdge(10L, a, b, minHtlc = 2 msat), + makeEdge(10L, b, a, minHtlc = 2 msat, inboundFeeBase_opt = Some(-5000 msat), inboundFeeProportionalMillionth_opt = Some(-60_000)), + + makeEdge(11L, b, c, 1000 msat, 50_000, minHtlc = 2 msat), + makeEdge(11L, c, b, minHtlc = 2 msat, inboundFeeBase_opt = Some(5000 msat), inboundFeeProportionalMillionth_opt = Some(0)), + + makeEdge(12L, c, d, 0 msat, 50_000, minHtlc = 2 msat), + makeEdge(12L, d, c, minHtlc = 2 msat, inboundFeeBase_opt = Some(-8000 msat), inboundFeeProportionalMillionth_opt = Some(-50_000)), + + makeEdge(13L, d, e, 9000 msat, 100_000, minHtlc = 2 msat), + makeEdge(13L, e, d, minHtlc = 2 msat, inboundFeeBase_opt = Some(2000 msat), inboundFeeProportionalMillionth_opt = Some(80_000)), + )), 1 day) + + val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = false, excludePositiveInboundFees = false) + + assert(route.channelFee(false) == 32_197.msat) + + val recipient = ClearRecipient(e, Features.empty, 100_000 msat, CltvExpiry(400018), paymentSecret) + val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, Reputation.Score.max) + + assert(payment.outgoingChannel == ShortChannelId(10L)) + assert(payment.cmd.amount == 132_197.msat) + + val packet_b = payment.cmd.onion + + val add_b = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_b, None, 1, None) + val Right(relay_b@ChannelRelayPacket(_, payload_b, packet_c, _)) = decrypt(add_b, priv_b, Features.empty) + assert(payload_b.outgoing.contains(ShortChannelId(11L))) + assert(relay_b.amountToForward == 124_950.msat) + assert(relay_b.relayFeeMsat == -24_950.msat) + + val add_c = UpdateAddHtlc(randomBytes32(), 1, 100_000 msat, paymentHash, CltvExpiry(400018), packet_c, None, 1, None) + val Right(relay_c@ChannelRelayPacket(_, payload_c, packet_d, _)) = decrypt(add_c, priv_c, Features.empty) + assert(payload_c.outgoing.contains(ShortChannelId(12L))) + assert(relay_c.amountToForward == 119_000.msat) + assert(relay_c.relayFeeMsat == -19000.msat) + + val add_d = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_d, None, 1, None) + val Right(relay_d@ChannelRelayPacket(_, payload_d, packet_e, _)) = decrypt(add_d, priv_d, Features.empty) + assert(payload_d.outgoing.contains(ShortChannelId(13L))) + assert(relay_d.amountToForward == 100_000.msat) + assert(relay_d.relayFeeMsat == 0.msat) + + val add_e = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_e, None, 1, None) + val Right(FinalPacket(_, payload_e, _)) = decrypt(add_e, priv_e, Features.empty) + assert(payload_e.isInstanceOf[FinalPayload.Standard]) + assert(payload_e.amount == 100_000.msat) + assert(payload_e.totalAmount == 100_000.msat) + } + + test("test findMultiPartRoute with Blip18 enabled") { + // extracted from the LND code base + val g = GraphWithBalanceEstimates(DirectedGraph(Seq( + makeEdge(10L, a, b, minHtlc = 2 msat), + makeEdge(10L, b, a, minHtlc = 2 msat, inboundFeeBase_opt = Some(-5000 msat), inboundFeeProportionalMillionth_opt = Some(-60_000)), + + makeEdge(11L, b, c, 1000 msat, 50_000, minHtlc = 2 msat), + makeEdge(11L, c, b, minHtlc = 2 msat, inboundFeeBase_opt = Some(5000 msat), inboundFeeProportionalMillionth_opt = Some(0)), + + makeEdge(12L, c, d, 0 msat, 50_000, minHtlc = 2 msat), + makeEdge(12L, d, c, minHtlc = 2 msat, inboundFeeBase_opt = Some(-8000 msat), inboundFeeProportionalMillionth_opt = Some(-50_000)), + + makeEdge(13L, d, e, 9000 msat, 100_000, minHtlc = 2 msat), + makeEdge(13L, e, d, minHtlc = 2 msat, inboundFeeBase_opt = Some(2000 msat), inboundFeeProportionalMillionth_opt = Some(80_000)), + )), 1 day) + + val Success(route :: Nil) = findMultiPartRoute(g, a, e, 100_000 msat, 100_000 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = false) + + assert(route.channelFee(false) == 15_302.msat) + + val recipient = ClearRecipient(e, Features.empty, 100_000 msat, CltvExpiry(400018), paymentSecret) + val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, Reputation.Score.max) + + assert(payment.outgoingChannel == ShortChannelId(10L)) + assert(payment.cmd.amount == 115_302.msat) + + val packet_b = payment.cmd.onion + + val add_b = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_b, None, 1, None) + val Right(relay_b@ChannelRelayPacket(_, payload_b, packet_c, _)) = decrypt(add_b, priv_b, Features.empty) + assert(payload_b.outgoing.contains(ShortChannelId(11L))) + assert(relay_b.amountToForward == 115_302.msat) + assert(relay_b.relayFeeMsat == -15_302.msat) + + val add_c = UpdateAddHtlc(randomBytes32(), 1, 100_000 msat, paymentHash, CltvExpiry(400018), packet_c, None, 1, None) + val Right(relay_c@ChannelRelayPacket(_, payload_c, packet_d, _)) = decrypt(add_c, priv_c, Features.empty) + assert(payload_c.outgoing.contains(ShortChannelId(12L))) + assert(relay_c.amountToForward == 105_050.msat) + assert(relay_c.relayFeeMsat == -5050.msat) + + val add_d = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_d, None, 1, None) + val Right(relay_d@ChannelRelayPacket(_, payload_d, packet_e, _)) = decrypt(add_d, priv_d, Features.empty) + assert(payload_d.outgoing.contains(ShortChannelId(13L))) + assert(relay_d.amountToForward == 100_000.msat) + assert(relay_d.relayFeeMsat == 0.msat) + + val add_e = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_e, None, 1, None) + val Right(FinalPacket(_, payload_e, _)) = decrypt(add_e, priv_e, Features.empty) + assert(payload_e.isInstanceOf[FinalPayload.Standard]) + assert(payload_e.amount == 100_000.msat) + assert(payload_e.totalAmount == 100_000.msat) + } + + test("test findMultiPartRoute with Blip18 disabled") { + // extracted from the LND code base + val g = GraphWithBalanceEstimates(DirectedGraph(Seq( + makeEdge(10L, a, b, minHtlc = 2 msat), + makeEdge(10L, b, a, minHtlc = 2 msat, inboundFeeBase_opt = Some(-5000 msat), inboundFeeProportionalMillionth_opt = Some(-60_000)), + + makeEdge(11L, b, c, 1000 msat, 50_000, minHtlc = 2 msat), + makeEdge(11L, c, b, minHtlc = 2 msat, inboundFeeBase_opt = Some(5000 msat), inboundFeeProportionalMillionth_opt = Some(0)), + + makeEdge(12L, c, d, 0 msat, 50_000, minHtlc = 2 msat), + makeEdge(12L, d, c, minHtlc = 2 msat, inboundFeeBase_opt = Some(-8000 msat), inboundFeeProportionalMillionth_opt = Some(-50_000)), + + makeEdge(13L, d, e, 9000 msat, 100_000, minHtlc = 2 msat), + makeEdge(13L, e, d, minHtlc = 2 msat, inboundFeeBase_opt = Some(2000 msat), inboundFeeProportionalMillionth_opt = Some(80_000)), + )), 1 day) + + val Success(route :: Nil) = findMultiPartRoute(g, a, e, 100_000 msat, 100_000 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = false, excludePositiveInboundFees = false) + + assert(route.channelFee(false) == 32_197.msat) + + val recipient = ClearRecipient(e, Features.empty, 100_000 msat, CltvExpiry(400018), paymentSecret) + val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, Reputation.Score.max) + + assert(payment.outgoingChannel == ShortChannelId(10L)) + assert(payment.cmd.amount == 132_197.msat) + + val packet_b = payment.cmd.onion + + val add_b = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_b, None, 1, None) + val Right(relay_b@ChannelRelayPacket(_, payload_b, packet_c, _)) = decrypt(add_b, priv_b, Features.empty) + assert(payload_b.outgoing.contains(ShortChannelId(11L))) + assert(relay_b.amountToForward == 124_950.msat) + assert(relay_b.relayFeeMsat == -24_950.msat) + + val add_c = UpdateAddHtlc(randomBytes32(), 1, 100_000 msat, paymentHash, CltvExpiry(400018), packet_c, None, 1, None) + val Right(relay_c@ChannelRelayPacket(_, payload_c, packet_d, _)) = decrypt(add_c, priv_c, Features.empty) + assert(payload_c.outgoing.contains(ShortChannelId(12L))) + assert(relay_c.amountToForward == 119_000.msat) + assert(relay_c.relayFeeMsat == -19000.msat) + + val add_d = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_d, None, 1, None) + val Right(relay_d@ChannelRelayPacket(_, payload_d, packet_e, _)) = decrypt(add_d, priv_d, Features.empty) + assert(payload_d.outgoing.contains(ShortChannelId(13L))) + assert(relay_d.amountToForward == 100_000.msat) + assert(relay_d.relayFeeMsat == 0.msat) + + val add_e = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_e, None, 1, None) + val Right(FinalPacket(_, payload_e, _)) = decrypt(add_e, priv_e, Features.empty) + assert(payload_e.isInstanceOf[FinalPayload.Standard]) + assert(payload_e.amount == 100_000.msat) + assert(payload_e.totalAmount == 100_000.msat) + } + + test("calculate Blip18 simple route with a positive inbound fees channel") { + // channels with positive (greater than 0) inbound fees should be automatically excluded from path finding + + { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 10, cltvDelta = CltvExpiryDelta(1), balance_opt = Some(DEFAULT_AMOUNT_MSAT * 2)), + makeEdge(2L, b, c, 2 msat, 20, cltvDelta = CltvExpiryDelta(1)), + makeEdge(2L, c, b, 2 msat, 20, cltvDelta = CltvExpiryDelta(1), inboundFeeBase_opt = Some(1 msat), inboundFeeProportionalMillionth_opt = Some(10)), + makeEdge(3L, c, d, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), + makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) + )), 1 day) + + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + assert(res == Failure(RouteNotFound)) + } + { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 10, cltvDelta = CltvExpiryDelta(1), balance_opt = Some(DEFAULT_AMOUNT_MSAT * 2)), + makeEdge(2L, b, c, 2 msat, 20, cltvDelta = CltvExpiryDelta(1)), + makeEdge(2L, c, b, 2 msat, 20, cltvDelta = CltvExpiryDelta(1), inboundFeeBase_opt = Some(1 msat), inboundFeeProportionalMillionth_opt = Some(0)), + makeEdge(3L, c, d, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), + makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) + )), 1 day) + + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + assert(res == Failure(RouteNotFound)) + } + { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 10, cltvDelta = CltvExpiryDelta(1), balance_opt = Some(DEFAULT_AMOUNT_MSAT * 2)), + makeEdge(2L, b, c, 2 msat, 20, cltvDelta = CltvExpiryDelta(1)), + makeEdge(2L, c, b, 2 msat, 20, cltvDelta = CltvExpiryDelta(1), inboundFeeBase_opt = Some(0 msat), inboundFeeProportionalMillionth_opt = Some(10)), + makeEdge(3L, c, d, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), + makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) + )), 1 day) + + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + assert(res == Failure(RouteNotFound)) + } + { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 10, cltvDelta = CltvExpiryDelta(1), balance_opt = Some(DEFAULT_AMOUNT_MSAT * 2)), + makeEdge(2L, b, c, 2 msat, 20, cltvDelta = CltvExpiryDelta(1)), + makeEdge(2L, c, b, 2 msat, 20, cltvDelta = CltvExpiryDelta(1), inboundFeeBase_opt = Some(1 msat), inboundFeeProportionalMillionth_opt = Some(-10)), + makeEdge(3L, c, d, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), + makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) + )), 1 day) + + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + assert(res == Failure(RouteNotFound)) + } + { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 10, cltvDelta = CltvExpiryDelta(1), balance_opt = Some(DEFAULT_AMOUNT_MSAT * 2)), + makeEdge(2L, b, c, 2 msat, 20, cltvDelta = CltvExpiryDelta(1)), + makeEdge(2L, c, b, 2 msat, 20, cltvDelta = CltvExpiryDelta(1), inboundFeeBase_opt = Some(-1 msat), inboundFeeProportionalMillionth_opt = Some(10)), + makeEdge(3L, c, d, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), + makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) + )), 1 day) + + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + assert(res == Failure(RouteNotFound)) + } + } + +} + +object Blip18RouteCalculationSpec { + + val DEFAULT_AMOUNT_MSAT = 10_000_000 msat + val DEFAULT_MAX_FEE = 100_000 msat + val DEFAULT_EXPIRY = CltvExpiry(TestConstants.defaultBlockHeight) + val DEFAULT_CAPACITY = 100_000 sat + + val NO_WEIGHT_RATIOS: HeuristicsConstants = HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false) + val DEFAULT_ROUTE_PARAMS = PathFindingConf( + randomize = false, + boundaries = SearchBoundaries(21000 msat, 0.03, 6, CltvExpiryDelta(2016)), + NO_WEIGHT_RATIOS, + MultiPartParams(1000 msat, 10, FullCapacity), + experimentName = "my-test-experiment", + experimentPercentage = 100).getDefaultRouteParams + + val DUMMY_SIG = Transactions.PlaceHolderSig + + def makeChannel(shortChannelId: Long, nodeIdA: PublicKey, nodeIdB: PublicKey): ChannelAnnouncement = { + val (nodeId1, nodeId2) = if (Announcements.isNode1(nodeIdA, nodeIdB)) (nodeIdA, nodeIdB) else (nodeIdB, nodeIdA) + ChannelAnnouncement(DUMMY_SIG, DUMMY_SIG, DUMMY_SIG, DUMMY_SIG, Features.empty, Block.RegtestGenesisBlock.hash, RealShortChannelId(shortChannelId), nodeId1, nodeId2, randomKey().publicKey, randomKey().publicKey) + } + + def makeEdge(shortChannelId: Long, + nodeId1: PublicKey, + nodeId2: PublicKey, + feeBase: MilliSatoshi = 0 msat, + feeProportionalMillionth: Int = 0, + minHtlc: MilliSatoshi = DEFAULT_AMOUNT_MSAT, + maxHtlc: Option[MilliSatoshi] = None, + cltvDelta: CltvExpiryDelta = CltvExpiryDelta(0), + capacity: Satoshi = DEFAULT_CAPACITY, + balance_opt: Option[MilliSatoshi] = None, + inboundFeeBase_opt: Option[MilliSatoshi] = None, + inboundFeeProportionalMillionth_opt: Option[Int] = None): GraphEdge = { + val update = makeUpdateShort(ShortChannelId(shortChannelId), nodeId1, nodeId2, feeBase, feeProportionalMillionth, minHtlc, maxHtlc, cltvDelta, inboundFeeBase_opt = inboundFeeBase_opt, inboundFeeProportionalMillionth_opt = inboundFeeProportionalMillionth_opt) + GraphEdge(ChannelDesc(RealShortChannelId(shortChannelId), nodeId1, nodeId2), HopRelayParams.FromAnnouncement(update), capacity, balance_opt) + } + + def makeUpdateShort(shortChannelId: ShortChannelId, nodeId1: PublicKey, nodeId2: PublicKey, feeBase: MilliSatoshi, feeProportionalMillionth: Int, minHtlc: MilliSatoshi = DEFAULT_AMOUNT_MSAT, maxHtlc: Option[MilliSatoshi] = None, cltvDelta: CltvExpiryDelta = CltvExpiryDelta(0), timestamp: TimestampSecond = 0 unixsec, inboundFeeBase_opt: Option[MilliSatoshi] = None, inboundFeeProportionalMillionth_opt: Option[Int] = None): ChannelUpdate = { + val tlvStream: TlvStream[ChannelUpdateTlv] = if (inboundFeeBase_opt.isDefined && inboundFeeProportionalMillionth_opt.isDefined) { + TlvStream(ChannelUpdateTlv.Blip18InboundFee(inboundFeeBase_opt.get.toLong.toInt, inboundFeeProportionalMillionth_opt.get)) + } else { + TlvStream.empty + } + ChannelUpdate( + signature = DUMMY_SIG, + chainHash = Block.RegtestGenesisBlock.hash, + shortChannelId = shortChannelId, + timestamp = timestamp, + messageFlags = ChannelUpdate.MessageFlags(dontForward = false), + channelFlags = ChannelUpdate.ChannelFlags(isEnabled = true, isNode1 = Announcements.isNode1(nodeId1, nodeId2)), + cltvExpiryDelta = cltvDelta, + htlcMinimumMsat = minHtlc, + feeBaseMsat = feeBase, + feeProportionalMillionths = feeProportionalMillionth, + htlcMaximumMsat = maxHtlc.getOrElse(500_000_000 msat), + tlvStream = tlvStream + ) + } +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala index 5e15a56b39..1063c24289 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala @@ -524,13 +524,13 @@ class GraphSpec extends AnyFunSuite { .addOrUpdateVertex(makeNodeAnnouncement(priv_h, "H", Color(0, 0, 0), Nil, Features(Features.RouteBlinding -> FeatureSupport.Optional))) { - val paths = routeBlindingPaths(GraphWithBalanceEstimates(graph, 1 day), a, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false), BlockHeight(793397), _ => true) + val paths = routeBlindingPaths(GraphWithBalanceEstimates(graph, 1 day), a, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false), BlockHeight(793397), _ => true, false) assert(paths.length == 2) assert(paths(0).path.map(_.desc.a) == Seq(a, b)) assert(paths(1).path.map(_.desc.a) == Seq(a, e, f)) } { - val paths = routeBlindingPaths(GraphWithBalanceEstimates(graph, 1 day), c, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false), BlockHeight(793397), _ => true) + val paths = routeBlindingPaths(GraphWithBalanceEstimates(graph, 1 day), c, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false), BlockHeight(793397), _ => true, false) assert(paths.length == 1) assert(paths(0).path.map(_.desc.a) == Seq(c, a, b)) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala index 7e716dbf51..03708532af 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala @@ -555,7 +555,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { ) val publicChannels = channels.map { case (shortChannelId, announcement) => - val HopRelayParams.FromAnnouncement(update) = edges.find(_.desc.shortChannelId == shortChannelId).get.params + val HopRelayParams.FromAnnouncement(update, _) = edges.find(_.desc.shortChannelId == shortChannelId).get.params val (update_1_opt, update_2_opt) = if (update.channelFlags.isNode1) (Some(update), None) else (None, Some(update)) val pc = PublicChannel(announcement, TxId(ByteVector32.Zeroes), Satoshi(1000), update_1_opt, update_2_opt, None) (shortChannelId, pc) diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala index d83f8a6409..ba2144cd51 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala @@ -16,7 +16,7 @@ package fr.acinq.eclair.api.handlers -import akka.http.scaladsl.server.Route +import akka.http.scaladsl.server.{MalformedFormFieldRejection, Route} import fr.acinq.eclair.MilliSatoshi import fr.acinq.eclair.api.Service import fr.acinq.eclair.api.directives.EclairDirectives @@ -35,8 +35,18 @@ trait Fees { val updateRelayFee: Route = postRequest("updaterelayfee") { implicit t => withNodesIdentifier { nodes => - formFields("feeBaseMsat".as[MilliSatoshi], "feeProportionalMillionths".as[Long]) { (feeBase, feeProportional) => - complete(eclairApi.updateRelayFee(nodes, feeBase, feeProportional)) + formFields("feeBaseMsat".as[MilliSatoshi], "feeProportionalMillionths".as[Long], "inboundFeeBaseMsat".as[MilliSatoshi]?, "inboundFeeProportionalMillionths".as[Long]?) { (feeBase, feeProportional, inboundFeeBase_opt, inboundFeeProportional_opt) => + if (inboundFeeBase_opt.isEmpty && inboundFeeProportional_opt.isDefined) { + reject(MalformedFormFieldRejection("inboundFeeBaseMsat", "inbound fee base is required")) + } else if (inboundFeeBase_opt.isDefined && inboundFeeProportional_opt.isEmpty) { + reject(MalformedFormFieldRejection("inboundFeeProportionalMillionths", "inbound fee proportional millionths is required")) + } else if (!inboundFeeBase_opt.forall(value => value.toLong >= Int.MinValue && value.toLong <= 0)) { + reject(MalformedFormFieldRejection("inboundFeeBaseMsat", s"inbound fee base must be must be in the range from ${Int.MinValue} to 0")) + } else if (!inboundFeeProportional_opt.forall(value => value >= Int.MinValue && value <= 0)) { + reject(MalformedFormFieldRejection("inboundFeeProportionalMillionths", s"inbound fee proportional millionths must be in the range from ${Int.MinValue} to 0")) + } else { + complete(eclairApi.updateRelayFee(nodes, feeBase, feeProportional, inboundFeeBase_opt, inboundFeeProportional_opt)) + } } } }