diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 133362df1b..76cd6f3e96 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -9,6 +9,39 @@ With this release, eclair requires using Bitcoin Core 28.1. Newer versions of Bitcoin Core may be used, but have not been extensively tested. +### Offers + +You can now create an offer with + +``` +./eclair-cli createoffer --description=coffee --amountMsat=20000 --expireInSeconds=3600 --issuer=me@example.com --blindedPathsFirstNodeId=03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f +``` + +All parameters are optional and omitting all of them will create a minimal offer with your public node id. +You can also disable offers and list offers with + +``` +./eclair-cli disableoffer --offer=lnoxxx +./eclair-cli listoffers +``` + +If you specify `--blindedPathsFirstNodeId`, your public node id will not be in the offer, you will instead be hidden behind a blinded path starting at the node that you have chosen. +You can configure the number and length of blinded paths used in `eclair.conf`: + +``` +offers { + // Minimum length of an offer blinded path + message-path-min-length = 2 + + // Number of payment paths to put in Bolt12 invoices + payment-path-count = 2 + // Length of payment paths to put in Bolt12 invoices + payment-path-length = 4 + // Expiry delta of payment paths to put in Bolt12 invoices + payment-path-expiry-delta = 500 +} +``` + ### Simplified mutual close This release includes support for the latest [mutual close protocol](https://github.com/lightning/bolts/pull/1205). diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 403c8d5e3f..3b903dafd9 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -636,6 +636,18 @@ eclair { // Frequency at which we clean our DB to remove peer storage from nodes with whom we don't have channels anymore. cleanup-frequency = 1 day } + + offers { + // Minimum length of an offer blinded path when hiding our real node id + message-path-min-length = 2 + + // Number of payment paths to put in Bolt12 invoices when hiding our real node id + payment-path-count = 2 + // Length of payment paths to put in Bolt12 invoices when hiding our real node id + payment-path-length = 4 + // Expiry delta of payment paths to put in Bolt12 invoices when hiding our real node id + payment-path-expiry-delta = 500 + } } akka { 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 83f1e20669..fc7a6fb913 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -39,15 +39,17 @@ import fr.acinq.eclair.db.AuditDb.{NetworkFee, Stats} import fr.acinq.eclair.db.{IncomingPayment, OutgoingPayment, OutgoingPaymentStatus} import fr.acinq.eclair.io.Peer.{GetPeerInfo, OpenChannelResponse, PeerInfo} import fr.acinq.eclair.io._ +import fr.acinq.eclair.message.OnionMessages.{IntermediateNode, Recipient} 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.send.PaymentInitiator._ import fr.acinq.eclair.payment.send.{ClearRecipient, OfferPayment, PaymentIdentifier} import fr.acinq.eclair.router.Router import fr.acinq.eclair.router.Router._ -import fr.acinq.eclair.wire.protocol.OfferTypes.Offer +import fr.acinq.eclair.wire.protocol.OfferTypes.{Offer, OfferAbsoluteExpiry, OfferIssuer, OfferQuantityMax, OfferTlv} import fr.acinq.eclair.wire.protocol._ import grizzled.slf4j.Logging import scodec.bits.ByteVector @@ -126,6 +128,12 @@ trait Eclair { def receive(description: Either[String, ByteVector32], amount_opt: Option[MilliSatoshi], expire_opt: Option[Long], fallbackAddress_opt: Option[String], paymentPreimage_opt: Option[ByteVector32], privateChannelIds_opt: Option[List[ByteVector32]])(implicit timeout: Timeout): Future[Bolt11Invoice] + def createOffer(description_opt: Option[String], amount_opt: Option[MilliSatoshi], expire_opt: Option[Long], issuer_opt: Option[String], blindedPathsFirstNodeId_opt: Option[PublicKey])(implicit timeout: Timeout): Future[Offer] + + def disableOffer(offer: Offer)(implicit timeout: Timeout): Future[Unit] + + def listOffers(onlyActive: Boolean = true)(implicit timeout: Timeout): Future[Seq[Offer]] + def newAddress(): Future[String] def receivedInfo(paymentHash: ByteVector32)(implicit timeout: Timeout): Future[Option[IncomingPayment]] @@ -388,6 +396,24 @@ class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChan } } + override def createOffer(description_opt: Option[String], amount_opt: Option[MilliSatoshi], expireInSeconds_opt: Option[Long], issuer_opt: Option[String], blindedPathsFirstNodeId_opt: Option[PublicKey])(implicit timeout: Timeout): Future[Offer] = { + val offerCreator = appKit.system.spawnAnonymous(OfferCreator(appKit.nodeParams, appKit.router, appKit.offerManager, appKit.defaultOfferHandler)) + val expiry_opt = expireInSeconds_opt.map(TimestampSecond.now() + _) + offerCreator.ask[OfferCreator.CreateOfferResult](replyTo => OfferCreator.Create(replyTo, description_opt, amount_opt, expiry_opt, issuer_opt, blindedPathsFirstNodeId_opt)) + .flatMap { + case OfferCreator.CreateOfferError(reason) => Future.failed(new Exception(reason)) + case OfferCreator.CreatedOffer(offer) => Future.successful(offer) + } + } + + override def disableOffer(offer: Offer)(implicit timeout: Timeout): Future[Unit] = Future { + appKit.offerManager ! OfferManager.DisableOffer(offer) + } + + override def listOffers(onlyActive: Boolean = true)(implicit timeout: Timeout): Future[Seq[Offer]] = Future { + appKit.nodeParams.db.offers.listOffers(onlyActive).map(_.offer) + } + override def newAddress(): Future[String] = { appKit.wallet match { case w: BitcoinCoreClient => w.getReceiveAddress() diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Logs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Logs.scala index a79dd51f16..878b6ba41d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Logs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Logs.scala @@ -49,6 +49,7 @@ object Logs { parentPaymentId_opt: Option[UUID] = None, paymentId_opt: Option[UUID] = None, paymentHash_opt: Option[ByteVector32] = None, + offerId_opt: Option[ByteVector32] = None, txPublishId_opt: Option[UUID] = None, messageId_opt: Option[ByteVector32] = None, nodeAlias_opt: Option[String] = None): Map[String, String] = @@ -60,6 +61,7 @@ object Logs { parentPaymentId_opt.map(p => "parentPaymentId" -> s" p:$p"), paymentId_opt.map(i => "paymentId" -> s" i:$i"), paymentHash_opt.map(h => "paymentHash" -> s" h:$h"), + offerId_opt.map(o => "offerId" -> s" o:$o"), txPublishId_opt.map(t => "txPublishId" -> s" t:$t"), messageId_opt.map(m => "messageId" -> s" m:$m"), nodeAlias_opt.map(a => "nodeAlias" -> s" a:$a"), 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 48b502c0b8..59d0dfca97 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -30,6 +30,7 @@ import fr.acinq.eclair.db._ import fr.acinq.eclair.io.MessageRelay.{RelayAll, RelayChannelsOnly, RelayPolicy} import fr.acinq.eclair.io.{PeerConnection, PeerReadyNotifier} import fr.acinq.eclair.message.OnionMessages.OnionMessageConfig +import fr.acinq.eclair.payment.offer.OffersConfig import fr.acinq.eclair.payment.relay.OnTheFlyFunding import fr.acinq.eclair.payment.relay.Relayer.{AsyncPaymentsParams, RelayFees, RelayParams} import fr.acinq.eclair.router.Announcements.AddressException @@ -92,7 +93,8 @@ case class NodeParams(nodeKeyManager: NodeKeyManager, liquidityAdsConfig: LiquidityAds.Config, peerWakeUpConfig: PeerReadyNotifier.WakeUpConfig, onTheFlyFundingConfig: OnTheFlyFunding.Config, - peerStorageConfig: PeerStorageConfig) { + peerStorageConfig: PeerStorageConfig, + offersConfig: OffersConfig) { val privateKey: Crypto.PrivateKey = nodeKeyManager.nodeKey.privateKey val nodeId: PublicKey = nodeKeyManager.nodeId @@ -705,6 +707,12 @@ object NodeParams extends Logging { writeDelay = FiniteDuration(config.getDuration("peer-storage.write-delay").getSeconds, TimeUnit.SECONDS), removalDelay = FiniteDuration(config.getDuration("peer-storage.removal-delay").getSeconds, TimeUnit.SECONDS), cleanUpFrequency = FiniteDuration(config.getDuration("peer-storage.cleanup-frequency").getSeconds, TimeUnit.SECONDS), + ), + offersConfig = OffersConfig( + messagePathMinLength = config.getInt("offers.message-path-min-length"), + paymentPathCount = config.getInt("offers.payment-path-count"), + paymentPathLength = config.getInt("offers.payment-path-length"), + paymentPathCltvExpiryDelta = CltvExpiryDelta(config.getInt("offers.payment-path-expiry-delta")), ) ) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala index d2affaea41..1caeab4e99 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala @@ -40,7 +40,7 @@ import fr.acinq.eclair.db.FileBackupHandler.FileBackupParams import fr.acinq.eclair.db.{Databases, DbEventHandler, FileBackupHandler, PeerStorageCleaner} import fr.acinq.eclair.io._ import fr.acinq.eclair.message.Postman -import fr.acinq.eclair.payment.offer.OfferManager +import fr.acinq.eclair.payment.offer.{DefaultOfferHandler, OfferManager} import fr.acinq.eclair.payment.receive.PaymentHandler import fr.acinq.eclair.payment.relay.{AsyncPaymentTriggerer, PostRestartHtlcCleaner, Relayer} import fr.acinq.eclair.payment.send.{Autoprobe, PaymentInitiator} @@ -358,6 +358,8 @@ class Setup(val datadir: File, dbEventHandler = system.actorOf(SimpleSupervisor.props(DbEventHandler.props(nodeParams), "db-event-handler", SupervisorStrategy.Resume)) register = system.actorOf(SimpleSupervisor.props(Register.props(), "register", SupervisorStrategy.Resume)) offerManager = system.spawn(Behaviors.supervise(OfferManager(nodeParams, paymentTimeout = 1 minute)).onFailure(typed.SupervisorStrategy.resume), name = "offer-manager") + defaultOfferHandler = system.spawn(Behaviors.supervise(DefaultOfferHandler(nodeParams, router)).onFailure(typed.SupervisorStrategy.resume), name = "default-offer-handler") + _ = for (offer <- nodeParams.db.offers.listOffers(onlyActive = true)) offerManager ! OfferManager.RegisterOffer(offer.offer, if (offer.pathId_opt.isEmpty) Some(nodeParams.privateKey) else None, offer.pathId_opt, defaultOfferHandler) paymentHandler = system.actorOf(SimpleSupervisor.props(PaymentHandler.props(nodeParams, register, offerManager), "payment-handler", SupervisorStrategy.Resume)) triggerer = system.spawn(Behaviors.supervise(AsyncPaymentTriggerer()).onFailure(typed.SupervisorStrategy.resume), name = "async-payment-triggerer") peerReadyManager = system.spawn(Behaviors.supervise(PeerReadyManager()).onFailure(typed.SupervisorStrategy.restart), name = "peer-ready-manager") @@ -399,6 +401,7 @@ class Setup(val datadir: File, balanceActor = balanceActor, postman = postman, offerManager = offerManager, + defaultOfferHandler = defaultOfferHandler, wallet = bitcoinClient) zmqBlockTimeout = after(5 seconds, using = system.scheduler)(Future.failed(BitcoinZMQConnectionTimeoutException)) @@ -468,6 +471,7 @@ case class Kit(nodeParams: NodeParams, balanceActor: typed.ActorRef[BalanceActor.Command], postman: typed.ActorRef[Postman.Command], offerManager: typed.ActorRef[OfferManager.Command], + defaultOfferHandler: typed.ActorRef[OfferManager.HandlerCommand], wallet: OnChainWallet with OnchainPubkeyCache) object Kit { 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 bf0007a15f..c7f929f572 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 @@ -43,6 +43,7 @@ trait Databases { def channels: ChannelsDb def peers: PeersDb def payments: PaymentsDb + def offers: OffersDb def pendingCommands: PendingCommandsDb def liquidity: LiquidityDb //@formatter:on @@ -66,6 +67,7 @@ object Databases extends Logging { channels: SqliteChannelsDb, peers: SqlitePeersDb, payments: SqlitePaymentsDb, + offers: SqliteOffersDb, pendingCommands: SqlitePendingCommandsDb, private val backupConnection: Connection) extends Databases with FileBackup { override def backup(backupFile: File): Unit = SqliteUtils.using(backupConnection.createStatement()) { @@ -85,6 +87,7 @@ object Databases extends Logging { channels = new SqliteChannelsDb(eclairJdbc), peers = new SqlitePeersDb(eclairJdbc), payments = new SqlitePaymentsDb(eclairJdbc), + offers = new SqliteOffersDb(eclairJdbc), pendingCommands = new SqlitePendingCommandsDb(eclairJdbc), backupConnection = eclairJdbc ) @@ -97,6 +100,7 @@ object Databases extends Logging { channels: PgChannelsDb, peers: PgPeersDb, payments: PgPaymentsDb, + offers: PgOffersDb, pendingCommands: PgPendingCommandsDb, dataSource: HikariDataSource, lock: PgLock) extends Databases with ExclusiveLock { @@ -157,6 +161,7 @@ object Databases extends Logging { channels = new PgChannelsDb, peers = new PgPeersDb, payments = new PgPaymentsDb, + offers = new PgOffersDb, pendingCommands = new PgPendingCommandsDb, dataSource = ds, lock = lock) 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 index c06c199906..ee7ce61966 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala @@ -36,6 +36,7 @@ case class DualDatabases(primary: Databases, secondary: Databases) extends Datab 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) @@ -405,6 +406,26 @@ case class DualPaymentsDb(primary: PaymentsDb, secondary: PaymentsDb) extends Pa } } +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()): Unit = { + 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())) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/OffersDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/OffersDb.scala new file mode 100644 index 0000000000..8a21335d3f --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/OffersDb.scala @@ -0,0 +1,50 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.db + +import fr.acinq.bitcoin.scalacompat.ByteVector32 +import fr.acinq.eclair.TimestampMilli +import fr.acinq.eclair.wire.protocol.OfferTypes.Offer + +/** + * Database for offers fully managed by eclair, as opposed to offers managed by a plugin. + */ +trait OffersDb { + /** + * Add an offer managed by eclair. + * + * @param pathId_opt If the offer uses a blinded path, this is the corresponding pathId. + */ + def addOffer(offer: Offer, pathId_opt: Option[ByteVector32], createdAt: TimestampMilli = TimestampMilli.now()): Unit + + /** + * Disable an offer. The offer is still stored but new invoice requests and new payment attempts for already emitted + * invoices will be rejected. + */ + def disableOffer(offer: Offer, disabledAt: TimestampMilli = TimestampMilli.now()): Unit + + /** + * List offers managed by eclair. + * + * @param onlyActive Whether to return only active offers or also disabled ones. + */ + def listOffers(onlyActive: Boolean): Seq[OfferData] +} + +case class OfferData(offer: Offer, pathId_opt: Option[ByteVector32], createdAt: TimestampMilli, disabledAt_opt: Option[TimestampMilli]) { + val disabled: Boolean = disabledAt_opt.nonEmpty +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgOffersDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgOffersDb.scala new file mode 100644 index 0000000000..ca38e77860 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgOffersDb.scala @@ -0,0 +1,103 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.db.pg + +import fr.acinq.bitcoin.scalacompat.ByteVector32 +import fr.acinq.eclair.TimestampMilli +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.{OfferData, OffersDb} +import fr.acinq.eclair.wire.protocol.OfferTypes +import fr.acinq.eclair.wire.protocol.OfferTypes.Offer +import grizzled.slf4j.Logging + +import java.sql.ResultSet +import javax.sql.DataSource + +object PgOffersDb { + val DB_NAME = "offers" + val CURRENT_VERSION = 1 +} + +class PgOffersDb(implicit ds: DataSource, lock: PgLock) extends OffersDb with Logging { + + import PgOffersDb._ + import PgUtils.ExtendedResultSet._ + import PgUtils._ + import lock._ + + inTransaction { pg => + using(pg.createStatement()) { statement => + getVersion(statement, DB_NAME) match { + case None => + statement.executeUpdate("CREATE SCHEMA IF NOT EXISTS payments") + statement.executeUpdate("CREATE TABLE payments.offers (offer_id TEXT NOT NULL PRIMARY KEY, offer TEXT NOT NULL, path_id TEXT, created_at TIMESTAMP WITH TIME ZONE NOT NULL, is_active BOOLEAN NOT NULL, disabled_at TIMESTAMP WITH TIME ZONE)") + statement.executeUpdate("CREATE INDEX offer_created_at_idx ON payments.offers(created_at)") + statement.executeUpdate("CREATE INDEX offer_is_active_idx ON payments.offers(is_active)") + 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 addOffer(offer: OfferTypes.Offer, pathId_opt: Option[ByteVector32], createdAt: TimestampMilli = TimestampMilli.now()): Unit = withMetrics("offers/add", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("INSERT INTO payments.offers (offer_id, offer, path_id, created_at, is_active, disabled_at) VALUES (?, ?, ?, ?, TRUE, NULL)")) { statement => + statement.setString(1, offer.offerId.toHex) + statement.setString(2, offer.toString) + statement.setString(3, pathId_opt.map(_.toHex).orNull) + statement.setTimestamp(4, createdAt.toSqlTimestamp) + statement.executeUpdate() + } + } + } + + override def disableOffer(offer: OfferTypes.Offer, disabledAt: TimestampMilli = TimestampMilli.now()): Unit = withMetrics("offers/disable", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("UPDATE payments.offers SET disabled_at = ?, is_active = FALSE WHERE offer_id = ?")) { statement => + statement.setTimestamp(1, disabledAt.toSqlTimestamp) + statement.setString(2, offer.offerId.toHex) + statement.executeUpdate() + } + } + } + + private def parseOfferData(rs: ResultSet): OfferData = { + OfferData( + Offer.decode(rs.getString("offer")).get, + rs.getStringNullable("path_id").map(ByteVector32.fromValidHex), + TimestampMilli.fromSqlTimestamp(rs.getTimestamp("created_at")), + rs.getTimestampNullable("disabled_at").map(TimestampMilli.fromSqlTimestamp) + ) + } + + override def listOffers(onlyActive: Boolean): Seq[OfferData] = withMetrics("offers/list", DbBackends.Postgres) { + withLock { pg => + if (onlyActive) { + using(pg.prepareStatement("SELECT * FROM payments.offers WHERE is_active = TRUE ORDER BY created_at DESC")) { statement => + statement.executeQuery().map(parseOfferData).toSeq + } + } else { + using(pg.prepareStatement("SELECT * FROM payments.offers ORDER BY created_at DESC")) { statement => + statement.executeQuery().map(parseOfferData).toSeq + } + } + } + } +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgPaymentsDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgPaymentsDb.scala index d78883a296..27af66e55b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgPaymentsDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgPaymentsDb.scala @@ -50,7 +50,7 @@ class PgPaymentsDb(implicit ds: DataSource, lock: PgLock) extends PaymentsDb wit using(pg.createStatement()) { statement => def migration45(statement: Statement): Unit = { - statement.executeUpdate("CREATE SCHEMA payments") + statement.executeUpdate("CREATE SCHEMA IF NOT EXISTS payments") statement.executeUpdate("ALTER TABLE received_payments RENAME TO received") statement.executeUpdate("ALTER TABLE received SET SCHEMA payments") statement.executeUpdate("ALTER TABLE sent_payments RENAME TO sent") @@ -79,7 +79,7 @@ class PgPaymentsDb(implicit ds: DataSource, lock: PgLock) extends PaymentsDb wit getVersion(statement, DB_NAME) match { case None => - statement.executeUpdate("CREATE SCHEMA payments") + statement.executeUpdate("CREATE SCHEMA IF NOT EXISTS payments") statement.executeUpdate("CREATE TABLE payments.received (payment_hash TEXT NOT NULL PRIMARY KEY, payment_type TEXT NOT NULL, payment_preimage TEXT NOT NULL, path_ids BYTEA, payment_request TEXT NOT NULL, received_msat BIGINT, created_at TIMESTAMP WITH TIME ZONE NOT NULL, expire_at TIMESTAMP WITH TIME ZONE NOT NULL, received_at TIMESTAMP WITH TIME ZONE)") statement.executeUpdate("CREATE TABLE payments.sent (id TEXT NOT NULL PRIMARY KEY, parent_id TEXT NOT NULL, external_id TEXT, payment_hash TEXT NOT NULL, payment_preimage TEXT, payment_type TEXT NOT NULL, amount_msat BIGINT NOT NULL, fees_msat BIGINT, recipient_amount_msat BIGINT NOT NULL, recipient_node_id TEXT NOT NULL, payment_request TEXT, offer_id TEXT, payer_key TEXT, payment_route BYTEA, failures BYTEA, created_at TIMESTAMP WITH TIME ZONE NOT NULL, completed_at TIMESTAMP WITH TIME ZONE)") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteOffersDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteOffersDb.scala new file mode 100644 index 0000000000..886ffc5065 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteOffersDb.scala @@ -0,0 +1,92 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.db.sqlite + +import fr.acinq.bitcoin.scalacompat.ByteVector32 +import fr.acinq.eclair.TimestampMilli +import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics +import fr.acinq.eclair.db.Monitoring.Tags.DbBackends +import fr.acinq.eclair.db.sqlite.SqliteUtils.{getVersion, setVersion, using} +import fr.acinq.eclair.db.{OfferData, OffersDb} +import fr.acinq.eclair.wire.protocol.OfferTypes +import fr.acinq.eclair.wire.protocol.OfferTypes.Offer +import grizzled.slf4j.Logging + +import java.sql.{Connection, ResultSet} + +object SqliteOffersDb { + val DB_NAME = "offers" + val CURRENT_VERSION = 1 +} + +class SqliteOffersDb(val sqlite: Connection) extends OffersDb with Logging { + + import SqliteOffersDb._ + import SqliteUtils.ExtendedResultSet._ + + using(sqlite.createStatement(), inTransaction = true) { statement => + getVersion(statement, DB_NAME) match { + case None => + statement.executeUpdate("CREATE TABLE offers (offer_id BLOB NOT NULL PRIMARY KEY, offer TEXT NOT NULL, path_id BLOB, created_at INTEGER NOT NULL, is_active BOOLEAN NOT NULL, disabled_at INTEGER)") + statement.executeUpdate("CREATE INDEX offer_created_at_idx ON offers(created_at)") + statement.executeUpdate("CREATE INDEX offer_is_active_idx ON offers(is_active)") + 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 addOffer(offer: OfferTypes.Offer, pathId_opt: Option[ByteVector32], createdAt: TimestampMilli = TimestampMilli.now()): Unit = withMetrics("offers/add", DbBackends.Sqlite) { + using(sqlite.prepareStatement("INSERT INTO offers (offer_id, offer, path_id, created_at, is_active, disabled_at) VALUES (?, ?, ?, ?, TRUE, NULL)")) { statement => + statement.setBytes(1, offer.offerId.toArray) + statement.setString(2, offer.toString) + statement.setBytes(3, pathId_opt.map(_.toArray).orNull) + statement.setLong(4, createdAt.toLong) + statement.executeUpdate() + } + } + + override def disableOffer(offer: OfferTypes.Offer, disabledAt: TimestampMilli = TimestampMilli.now()): Unit = withMetrics("offers/disable", DbBackends.Sqlite) { + using(sqlite.prepareStatement("UPDATE offers SET disabled_at = ?, is_active = FALSE WHERE offer_id = ?")) { statement => + statement.setLong(1, disabledAt.toLong) + statement.setBytes(2, offer.offerId.toArray) + statement.executeUpdate() + } + } + + private def parseOfferData(rs: ResultSet): OfferData = { + OfferData( + Offer.decode(rs.getString("offer")).get, + rs.getByteVector32Nullable("path_id"), + TimestampMilli(rs.getLong("created_at")), + rs.getLongNullable("disabled_at").map(TimestampMilli(_)) + ) + } + + override def listOffers(onlyActive: Boolean): Seq[OfferData] = withMetrics("offers/list", DbBackends.Sqlite) { + if (onlyActive) { + using(sqlite.prepareStatement("SELECT * FROM offers WHERE is_active = TRUE ORDER BY created_at DESC")) { statement => + statement.executeQuery().map(parseOfferData).toSeq + } + } else { + using(sqlite.prepareStatement("SELECT * FROM offers ORDER BY created_at DESC")) { statement => + statement.executeQuery().map(parseOfferData).toSeq + } + } + } +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt12Invoice.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt12Invoice.scala index 538de2f626..82ad8d9c87 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt12Invoice.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/Bolt12Invoice.scala @@ -60,7 +60,7 @@ case class Bolt12Invoice(records: TlvStream[InvoiceTlv]) extends Invoice { Left("Wrong node id") } else if (isExpired()) { Left("Invoice expired") - } else if (!request.amount.forall(_ == amount)) { + } else if (request.amount != amount) { Left("Incompatible amount") } else if (!Features.areCompatible(request.features, features.bolt12Features())) { Left("Incompatible features") @@ -106,8 +106,7 @@ object Bolt12Invoice { paths: Seq[PaymentBlindedRoute], additionalTlvs: Set[InvoiceTlv] = Set.empty, customTlvs: Set[GenericTlv] = Set.empty): Bolt12Invoice = { - require(request.amount.nonEmpty || request.offer.amount.nonEmpty) - val amount = request.amount.orElse(request.offer.amount.map(_ * request.quantity)).get + val amount = request.amount val tlvs: Set[InvoiceTlv] = removeSignature(request.records).records ++ Set( Some(InvoicePaths(paths.map(_.route))), Some(InvoiceBlindedPay(paths.map(_.paymentInfo))), 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 new file mode 100644 index 0000000000..98ef2b1e90 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala @@ -0,0 +1,126 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.payment.offer + +import akka.actor.typed.Behavior +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.{ActorRef, typed} +import com.softwaremill.quicklens.ModifyPimp +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.eclair.Logs.LogCategory +import fr.acinq.eclair.payment.offer.OfferManager.InvoiceRequestActor +import fr.acinq.eclair.payment.relay.Relayer.RelayFees +import fr.acinq.eclair.router.Router +import fr.acinq.eclair.router.Router.{BlindedRouteRequest, ChannelHop} +import fr.acinq.eclair.wire.protocol.OfferTypes +import fr.acinq.eclair.wire.protocol.OfferTypes.InvoiceRequest +import fr.acinq.eclair.{CltvExpiryDelta, EncodedNodeId, Logs, MilliSatoshiLong, NodeParams} + +case class OffersConfig(messagePathMinLength: Int, paymentPathCount: Int, paymentPathLength: Int, paymentPathCltvExpiryDelta: CltvExpiryDelta) + +/** + * This actor creates Bolt 12 invoices for offers that are managed by eclair. + * It creates payment blinded paths whenever the corresponding offer is using a (message) blinded path. + */ +object DefaultOfferHandler { + def apply(nodeParams: NodeParams, router: ActorRef): Behavior[OfferManager.HandlerCommand] = { + Behaviors.setup(context => + Behaviors.receiveMessage { + case OfferManager.HandleInvoiceRequest(replyTo, invoiceRequest) => + invoiceRequest.offer.contactInfos.head match { + case OfferTypes.RecipientNodeId(_) => + val route = InvoiceRequestActor.Route(Nil, nodeParams.channelConf.maxExpiryDelta) + replyTo ! InvoiceRequestActor.ApproveRequest(invoiceRequest.amount, Seq(route)) + case OfferTypes.BlindedPath(path) => + path.firstNodeId match { + case firstNodeId: EncodedNodeId.WithPublicKey if firstNodeId.publicKey == nodeParams.nodeId => + // We're using a fake blinded path starting at ourselves: we only need to add dummy hops. + val paths = PaymentPathsBuilder.finalizeRoutes(nodeParams, Seq(Nil)) + replyTo ! InvoiceRequestActor.ApproveRequest(invoiceRequest.amount, paths) + case firstNodeId: EncodedNodeId.WithPublicKey => + val pathBuilder = context.spawnAnonymous(PaymentPathsBuilder(nodeParams, router, invoiceRequest)) + pathBuilder ! PaymentPathsBuilder.GetPaymentPaths(replyTo, firstNodeId.publicKey) + case _: EncodedNodeId.ShortChannelIdDir => + context.log.error("unexpected managed offer with compact first node id") + replyTo ! InvoiceRequestActor.RejectRequest("internal error") + } + } + Behaviors.same + case OfferManager.HandlePayment(replyTo, _, _) => + replyTo ! OfferManager.PaymentActor.AcceptPayment() + Behaviors.same + } + ) + } + + /** + * Short-lived actor that creates payment blinded paths with help from the router. + */ + private object PaymentPathsBuilder { + + // @formatter:off + sealed trait Command + case class GetPaymentPaths(replyTo: typed.ActorRef[InvoiceRequestActor.Command], blindedPathFirstNodeId: PublicKey) extends Command + private case class WrappedRouteResponse(response: Router.PaymentRouteResponse) extends Command + // @formatter:on + + def apply(nodeParams: NodeParams, router: ActorRef, invoiceRequest: InvoiceRequest): Behavior[Command] = { + Behaviors.setup { context => + Behaviors.withMdc(Logs.mdc(category_opt = Some(LogCategory.PAYMENT), offerId_opt = Some(invoiceRequest.offer.offerId))) { + Behaviors.receiveMessagePartial { + case GetPaymentPaths(replyTo, blindedPathFirstNodeId) => + val routeParams = nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams + .modify(_.boundaries.maxRouteLength).setTo(nodeParams.offersConfig.paymentPathLength) + .modify(_.boundaries.maxCltv).setTo(nodeParams.offersConfig.paymentPathCltvExpiryDelta) + router ! BlindedRouteRequest(context.messageAdapter(WrappedRouteResponse), blindedPathFirstNodeId, nodeParams.nodeId, invoiceRequest.amount, routeParams, nodeParams.offersConfig.paymentPathCount) + waitForRoute(nodeParams, replyTo, invoiceRequest, blindedPathFirstNodeId, context) + } + } + } + } + + private def waitForRoute(nodeParams: NodeParams, replyTo: typed.ActorRef[InvoiceRequestActor.Command], invoiceRequest: InvoiceRequest, blindedPathFirstNodeId: PublicKey, context: ActorContext[Command]): Behavior[Command] = { + Behaviors.receiveMessagePartial { + case WrappedRouteResponse(Router.RouteResponse(routes)) => + context.log.debug("found {} blinded paths starting at {} (amount={})", routes.size, blindedPathFirstNodeId, invoiceRequest.amount) + replyTo ! InvoiceRequestActor.ApproveRequest(invoiceRequest.amount, finalizeRoutes(nodeParams, routes.map(_.hops))) + Behaviors.stopped + case WrappedRouteResponse(Router.PaymentRouteNotFound(error)) => + context.log.warn("couldn't find blinded paths to create invoice amount={} firstNodeId={}: {}", invoiceRequest.amount, blindedPathFirstNodeId, error.getMessage) + replyTo ! InvoiceRequestActor.RejectRequest("internal error") + Behaviors.stopped + } + } + + def finalizeRoutes(nodeParams: NodeParams, routes: Seq[Seq[Router.ChannelHop]]): Seq[InvoiceRequestActor.Route] = { + (0 until nodeParams.offersConfig.paymentPathCount).map(i => { + // We always return the number of routes configured, regardless of how many routes were actually found by the + // router: this ensures that we don't leak information about our graph data. + // However, payers may eagerly use MPP whereas we actually have a single route available, which could result in + // a lower payment success rate. + val hops = routes(i % routes.length) + // We always pad blinded paths to the configured length, using dummy hops if necessary. + val dummyHops = Seq.fill(nodeParams.offersConfig.paymentPathLength - hops.length)(ChannelHop.dummy(nodeParams.nodeId, 0 msat, 0, CltvExpiryDelta(0))) + // We always override the fees of the payment path: the payer shouldn't be paying for our privacy. + // Note that we told the router to only find paths with a lower cltv_expiry_delta than what we'll be using, + // which ensures that we won't reject payments because of their expiry. + InvoiceRequestActor.Route(hops ++ dummyHops, nodeParams.channelConf.maxExpiryDelta, feeOverride_opt = Some(RelayFees.zero), cltvOverride_opt = Some(nodeParams.offersConfig.paymentPathCltvExpiryDelta)) + }) + } + } + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferCreator.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferCreator.scala new file mode 100644 index 0000000000..8788602295 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferCreator.scala @@ -0,0 +1,123 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.payment.offer + +import akka.actor.typed.Behavior +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.{ActorRef, typed} +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32} +import fr.acinq.eclair.message.OnionMessages +import fr.acinq.eclair.message.OnionMessages.{IntermediateNode, Recipient} +import fr.acinq.eclair.payment.offer.OfferCreator.CreateOfferResult +import fr.acinq.eclair.router.Router +import fr.acinq.eclair.wire.protocol.OfferTypes._ +import fr.acinq.eclair.wire.protocol.TlvStream +import fr.acinq.eclair.{MilliSatoshi, NodeParams, TimestampSecond, randomBytes32, randomKey} + +/** + * A short-lived actor that creates an offer based on the parameters provided. + * It will ask the router for a blinded path when [[OfferCreator.Create.blindedPathsFirstNodeId_opt]] is provided. + */ +object OfferCreator { + + // @formatter:off + sealed trait Command + case class Create(replyTo: typed.ActorRef[CreateOfferResult], + description_opt: Option[String], + amount_opt: Option[MilliSatoshi], + expiry_opt: Option[TimestampSecond], + issuer_opt: Option[String], + blindedPathsFirstNodeId_opt: Option[PublicKey]) extends Command + private case class WrappedRouterResponse(response: Router.MessageRouteResponse) extends Command + // @formatter:on + + // @formatter:off + sealed trait CreateOfferResult + case class CreatedOffer(offer: Offer) extends CreateOfferResult + case class CreateOfferError(reason: String) extends CreateOfferResult + // @formatter:on + + def apply(nodeParams: NodeParams, router: ActorRef, offerManager: typed.ActorRef[OfferManager.Command], defaultOfferHandler: typed.ActorRef[OfferManager.HandlerCommand]): Behavior[Command] = { + Behaviors.setup { context => + Behaviors.receiveMessagePartial { + case Create(replyTo, description_opt, amount_opt, expiry_opt, issuer_opt, blindedPathsFirstNodeId_opt) => + val actor = new OfferCreator(context, replyTo, nodeParams, router, offerManager, defaultOfferHandler) + actor.createOffer(description_opt, amount_opt, expiry_opt, issuer_opt, blindedPathsFirstNodeId_opt) + } + } + } +} + +private class OfferCreator(context: ActorContext[OfferCreator.Command], + replyTo: typed.ActorRef[CreateOfferResult], + nodeParams: NodeParams, router: ActorRef, + offerManager: typed.ActorRef[OfferManager.Command], + defaultOfferHandler: typed.ActorRef[OfferManager.HandlerCommand]) { + + import OfferCreator._ + + private def createOffer(description_opt: Option[String], + amount_opt: Option[MilliSatoshi], + expiry_opt: Option[TimestampSecond], + issuer_opt: Option[String], + blindedPathsFirstNodeId_opt: Option[PublicKey]): Behavior[Command] = { + if (amount_opt.nonEmpty && description_opt.isEmpty) { + replyTo ! CreateOfferError("Description is mandatory for offers with set amount.") + Behaviors.stopped + } else { + val tlvs: Set[OfferTlv] = Set( + if (nodeParams.chainHash != Block.LivenetGenesisBlock.hash) Some(OfferChains(Seq(nodeParams.chainHash))) else None, + amount_opt.map(OfferAmount), + description_opt.map(OfferDescription), + expiry_opt.map(OfferAbsoluteExpiry), + issuer_opt.map(OfferIssuer), + ).flatten + blindedPathsFirstNodeId_opt match { + case Some(firstNodeId) => + router ! Router.MessageRouteRequest(context.messageAdapter(WrappedRouterResponse(_)), firstNodeId, nodeParams.nodeId, Set.empty) + waitForRoute(firstNodeId, tlvs) + case None => + // When not using a blinded path, we use our public nodeId for the offer (no privacy). + val offer = Offer(TlvStream(tlvs + OfferNodeId(nodeParams.nodeId))) + registerOffer(offer, Some(nodeParams.privateKey), None) + } + } + } + + private def waitForRoute(firstNode: PublicKey, tlvs: Set[OfferTlv]): Behavior[Command] = { + Behaviors.receiveMessagePartial { + case WrappedRouterResponse(Router.MessageRoute(intermediateNodes, _)) => + val pathId = randomBytes32() + // We add dummy hops to the route if it is too short, which provides better privacy. + val nodes = firstNode +: (intermediateNodes ++ Seq.fill(nodeParams.offersConfig.messagePathMinLength - intermediateNodes.length - 1)(nodeParams.nodeId)) + val paths = Seq(OnionMessages.buildRoute(randomKey(), nodes.map(IntermediateNode(_)), Recipient(nodeParams.nodeId, Some(pathId))).route) + val offer = Offer(TlvStream(tlvs + OfferPaths(paths))) + registerOffer(offer, None, Some(pathId)) + case WrappedRouterResponse(Router.MessageRouteNotFound(_)) => + replyTo ! CreateOfferError("No route found") + Behaviors.stopped + } + } + + private def registerOffer(offer: Offer, nodeKey_opt: Option[PrivateKey], pathId_opt: Option[ByteVector32]): Behavior[Command] = { + nodeParams.db.offers.addOffer(offer, pathId_opt) + offerManager ! OfferManager.RegisterOffer(offer, nodeKey_opt, pathId_opt, defaultOfferHandler) + replyTo ! CreatedOffer(offer) + Behaviors.stopped + } +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferManager.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferManager.scala index f15c1aa035..f4a6edb12c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferManager.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferManager.scala @@ -34,7 +34,7 @@ import fr.acinq.eclair.router.Router import fr.acinq.eclair.wire.protocol.OfferTypes.{InvoiceRequest, InvoiceTlv, Offer} import fr.acinq.eclair.wire.protocol.PaymentOnion.FinalPayload import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{CltvExpiryDelta, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, TimestampMilli, TimestampSecond, nodeFee, randomBytes32} +import fr.acinq.eclair.{CltvExpiryDelta, Logs, MilliSatoshi, NodeParams, TimestampMilli, TimestampSecond, nodeFee, randomBytes32} import scodec.bits.ByteVector import scala.concurrent.duration.FiniteDuration @@ -47,14 +47,17 @@ object OfferManager { sealed trait Command /** - * Register an offer and its handler. + * Register an offer so that we can respond to invoice requests for it using the handler provided. * - * @param offer The offer. - * @param nodeKey The private key corresponding to the node id used in the offer. - * @param pathId_opt If the offer uses a blinded path, the path id of this blinded path. - * @param handler An actor that will be in charge of accepting or rejecting invoice requests and payments for this offer. + * @param offer The offer to register. + * @param nodeKey_opt If the offer has a node id, this must be the associated private key. + * @param pathId_opt If the offer uses a blinded path, the path id of this blinded path. + * @param handler An actor that will be in charge of accepting or rejecting invoice requests and payments for this offer. */ - case class RegisterOffer(offer: Offer, nodeKey: Option[PrivateKey], pathId_opt: Option[ByteVector32], handler: ActorRef[HandlerCommand]) extends Command + case class RegisterOffer(offer: Offer, nodeKey_opt: Option[PrivateKey], pathId_opt: Option[ByteVector32], handler: ActorRef[HandlerCommand]) extends Command { + require(offer.nodeId.isEmpty || nodeKey_opt.nonEmpty, "offers including the node_id field must be registered with the corresponding private key") + require(!offer.contactInfos.exists(_.isInstanceOf[OfferTypes.BlindedPath]) || pathId_opt.nonEmpty, "offers including a blinded path must be registered with the corresponding path_id") + } /** * Forget about an offer. Invoice requests and payment attempts for this offer will be ignored. @@ -84,14 +87,17 @@ object OfferManager { * When a payment is received for an offer invoice, a `HandlePayment` is sent to the handler registered for this offer. * The handler may receive several `HandlePayment` for the same payment, usually because of multi-part payments. * - * @param replyTo The handler must reply with either `PaymentActor.ApprovePayment` or `PaymentActor.RejectPayment`. - * @param offerId The id of the offer in case a single handler handles multiple offers. - * @param pluginData_opt If the plugin handler needs to associate data with a payment, it shouldn't store it to avoid - * DoS and should instead use that field to include that data in the blinded path. + * @param replyTo The handler must reply with either `PaymentActor.ApprovePayment` or `PaymentActor.RejectPayment`. + * @param offer The offer in case a single handler handles multiple offers. + * @param invoiceData Data from the invoice this payment is for (quantity, amount, creation time, etc.). */ - case class HandlePayment(replyTo: ActorRef[PaymentActor.Command], offerId: ByteVector32, pluginData_opt: Option[ByteVector] = None) extends HandlerCommand + case class HandlePayment(replyTo: ActorRef[PaymentActor.Command], offer: Offer, invoiceData: MinimalInvoiceData) extends HandlerCommand - private case class RegisteredOffer(offer: Offer, nodeKey: Option[PrivateKey], pathId_opt: Option[ByteVector32], handler: ActorRef[HandlerCommand]) + /** + * An active offer, for which we handle invoice requests. + * See [[RegisterOffer]] for more details about the fields. + */ + private case class RegisteredOffer(offer: Offer, nodeKey_opt: Option[PrivateKey], pathId_opt: Option[ByteVector32], handler: ActorRef[HandlerCommand]) def apply(nodeParams: NodeParams, paymentTimeout: FiniteDuration): Behavior[Command] = { Behaviors.setup { context => @@ -107,12 +113,13 @@ object OfferManager { case RegisterOffer(offer, nodeKey, pathId_opt, handler) => normal(registeredOffers + (offer.offerId -> RegisteredOffer(offer, nodeKey, pathId_opt, handler))) case DisableOffer(offer) => + nodeParams.db.offers.disableOffer(offer) normal(registeredOffers - offer.offerId) case RequestInvoice(messagePayload, blindedKey, postman) => registeredOffers.get(messagePayload.invoiceRequest.offer.offerId) match { case Some(registered) if registered.pathId_opt.map(_.bytes) == messagePayload.pathId_opt && messagePayload.invoiceRequest.isValid => context.log.debug("received valid invoice request for offerId={}", messagePayload.invoiceRequest.offer.offerId) - val child = context.spawnAnonymous(InvoiceRequestActor(nodeParams, messagePayload.invoiceRequest, registered.handler, registered.nodeKey.getOrElse(blindedKey), messagePayload.replyPath, postman)) + val child = context.spawnAnonymous(InvoiceRequestActor(nodeParams, messagePayload.invoiceRequest, registered.handler, registered.nodeKey_opt.getOrElse(blindedKey), messagePayload.replyPath, postman)) child ! InvoiceRequestActor.RequestInvoice case _ => context.log.debug("offer {} is not registered or invoice request is invalid", messagePayload.invoiceRequest.offer.offerId) } @@ -125,7 +132,7 @@ object OfferManager { MinimalInvoiceData.verify(nodeParams.nodeId, signed) match { case Some(metadata) if Crypto.sha256(metadata.preimage) == paymentHash => val child = context.spawnAnonymous(PaymentActor(nodeParams, replyTo, offer, metadata, amountReceived, paymentTimeout)) - handler ! HandlePayment(child, signed.offerId, metadata.pluginData_opt) + handler ! HandlePayment(child, offer, metadata) case Some(_) => replyTo ! MultiPartHandler.GetIncomingPaymentActor.RejectPayment(s"preimage does not match payment hash for offer ${signed.offerId.toHex}") case None => replyTo ! MultiPartHandler.GetIncomingPaymentActor.RejectPayment(s"invalid signature for metadata for offer ${signed.offerId.toHex}") } @@ -160,21 +167,23 @@ object OfferManager { customTlvs: Set[GenericTlv] = Set.empty) extends Command /** - * @param recipientPaysFees If true, fees for the blinded route will be hidden to the payer and paid by the recipient. + * Route used in payment blinded paths: [[feeOverride_opt]] and [[cltvOverride_opt]] allow hiding the routing + * parameters of the route's intermediate hops, which provides better privacy. + * + * @param feeOverride_opt fees that will be published for this route, the difference between these and the + * actual fees of the route will be paid by the recipient. + * @param cltvOverride_opt cltv_expiry_delta to publish for the route, which must be greater than the route's + * real cltv_expiry_delta. + * @param shortChannelIdDir_opt short channel id and direction to use for the first node instead of its node id. */ - case class Route(hops: Seq[Router.ChannelHop], recipientPaysFees: Boolean, maxFinalExpiryDelta: CltvExpiryDelta, shortChannelIdDir_opt: Option[ShortChannelIdDir] = None) { + case class Route(hops: Seq[Router.ChannelHop], maxFinalExpiryDelta: CltvExpiryDelta, feeOverride_opt: Option[RelayFees] = None, cltvOverride_opt: Option[CltvExpiryDelta] = None, shortChannelIdDir_opt: Option[ShortChannelIdDir] = None) { def finalize(nodePriv: PrivateKey, preimage: ByteVector32, amount: MilliSatoshi, invoiceRequest: InvoiceRequest, minFinalExpiryDelta: CltvExpiryDelta, pluginData_opt: Option[ByteVector]): ReceivingRoute = { - val (paymentInfo, metadata) = if (recipientPaysFees) { - val realPaymentInfo = aggregatePaymentInfo(amount, hops, minFinalExpiryDelta) - val recipientFees = RelayFees(realPaymentInfo.feeBase, realPaymentInfo.feeProportionalMillionths) - val metadata = MinimalInvoiceData(preimage, invoiceRequest.payerId, TimestampSecond.now(), invoiceRequest.quantity, amount, recipientFees, pluginData_opt) - val paymentInfo = realPaymentInfo.copy(feeBase = 0 msat, feeProportionalMillionths = 0) - (paymentInfo, metadata) - } else { - val paymentInfo = aggregatePaymentInfo(amount, hops, minFinalExpiryDelta) - val metadata = MinimalInvoiceData(preimage, invoiceRequest.payerId, TimestampSecond.now(), invoiceRequest.quantity, amount, RelayFees.zero, pluginData_opt) - (paymentInfo, metadata) - } + val aggregatedPaymentInfo = aggregatePaymentInfo(amount, hops, minFinalExpiryDelta) + val fees = feeOverride_opt.getOrElse(RelayFees(aggregatedPaymentInfo.feeBase, aggregatedPaymentInfo.feeProportionalMillionths)) + val cltvExpiryDelta = cltvOverride_opt.getOrElse(aggregatedPaymentInfo.cltvExpiryDelta) + val paymentInfo = aggregatedPaymentInfo.copy(feeBase = fees.feeBase, feeProportionalMillionths = fees.feeProportionalMillionths, cltvExpiryDelta = cltvExpiryDelta) + val recipientFees = RelayFees(aggregatedPaymentInfo.feeBase - paymentInfo.feeBase, aggregatedPaymentInfo.feeProportionalMillionths - paymentInfo.feeProportionalMillionths) + val metadata = MinimalInvoiceData(preimage, invoiceRequest.payerId, TimestampSecond.now(), invoiceRequest.quantity, amount, recipientFees, pluginData_opt) val pathId = MinimalInvoiceData.encode(nodePriv, invoiceRequest.offer.offerId, metadata) ReceivingRoute(hops, pathId, maxFinalExpiryDelta, paymentInfo, shortChannelIdDir_opt) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartHandler.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartHandler.scala index 3d7722037b..c2dc342bba 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartHandler.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartHandler.scala @@ -292,9 +292,7 @@ object MultiPartHandler { paymentPreimage: ByteVector32, additionalTlvs: Set[InvoiceTlv] = Set.empty, customTlvs: Set[GenericTlv] = Set.empty) extends ReceivePayment { - require(invoiceRequest.offer.amount.nonEmpty || invoiceRequest.amount.nonEmpty, "an amount must be specified in the offer or in the invoice request") - - val amount = invoiceRequest.amount.orElse(invoiceRequest.offer.amount.map(_ * invoiceRequest.quantity)).get + val amount = invoiceRequest.amount } object CreateInvoiceActor { 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 0d69468b06..dd706ecf6e 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 @@ -129,10 +129,7 @@ object Relayer extends Logging { Props(new Relayer(nodeParams, router, register, paymentHandler, initialized)) // @formatter:off - case class RelayFees(feeBase: MilliSatoshi, feeProportionalMillionths: Long) { - require(feeBase.toLong >= 0.0, "feeBase must be nonnegative") - require(feeProportionalMillionths >= 0.0, "feeProportionalMillionths must be nonnegative") - } + case class RelayFees(feeBase: MilliSatoshi, feeProportionalMillionths: Long) object RelayFees { val zero: RelayFees = RelayFees(MilliSatoshi(0), 0) 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 663c92e543..df284bf46f 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 @@ -14,7 +14,7 @@ import fr.acinq.eclair.router.Router import fr.acinq.eclair.wire.protocol.OfferTypes.PaymentInfo import fr.acinq.eclair.wire.protocol.RouteBlindingEncryptedDataCodecs.RouteBlindingDecryptedData import fr.acinq.eclair.wire.protocol.{BlindedRouteData, OfferTypes, RouteBlindingEncryptedDataCodecs} -import fr.acinq.eclair.{EncodedNodeId, Logs, MilliSatoshiLong, NodeParams, ShortChannelId} +import fr.acinq.eclair.{EncodedNodeId, Logs, NodeParams, ShortChannelId} import scodec.bits.ByteVector import scala.annotation.tailrec @@ -164,10 +164,7 @@ private class BlindedPathsResolver(nodeParams: NodeParams, val relayFees = getRelayFees(nodeParams, nextNodeId.publicKey, announceChannel = false) val shouldRelay = paymentRelayData.paymentRelay.feeBase >= relayFees.feeBase && paymentRelayData.paymentRelay.feeProportionalMillionths >= relayFees.feeProportionalMillionths && - paymentRelayData.paymentRelay.cltvExpiryDelta >= nodeParams.channelConf.expiryDelta && - nextPaymentInfo.feeBase >= 0.msat && - nextPaymentInfo.feeProportionalMillionths >= 0 && - nextPaymentInfo.cltvExpiryDelta.toInt >= 0 + paymentRelayData.paymentRelay.cltvExpiryDelta >= nodeParams.channelConf.expiryDelta if (shouldRelay) { context.log.debug("unwrapped blinded path starting at our node: next_node={}", nextNodeId.publicKey) val path = ResolvedPath(PartialBlindedRoute(nextNodeId, nextPathKey, nextBlindedNodes), nextPaymentInfo) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala index 0428cadab2..519ae90e1b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala @@ -347,22 +347,18 @@ object OfferTypes { val metadata: ByteVector = records.get[InvoiceRequestMetadata].get.data val chain: BlockHash = records.get[InvoiceRequestChain].map(_.hash).getOrElse(Block.LivenetGenesisBlock.hash) - val amount: Option[MilliSatoshi] = records.get[InvoiceRequestAmount].map(_.amount) + private val amount_opt: Option[MilliSatoshi] = records.get[InvoiceRequestAmount].map(_.amount) val features: Features[Bolt12Feature] = records.get[InvoiceRequestFeatures].map(_.features.bolt12Features()).getOrElse(Features.empty) val quantity_opt: Option[Long] = records.get[InvoiceRequestQuantity].map(_.quantity) val quantity: Long = quantity_opt.getOrElse(1) + private val baseInvoiceAmount_opt = offer.amount.map(_ * quantity) + val amount: MilliSatoshi = amount_opt.orElse(baseInvoiceAmount_opt).get val payerId: PublicKey = records.get[InvoiceRequestPayerId].get.publicKey val payerNote: Option[String] = records.get[InvoiceRequestPayerNote].map(_.note) private val signature: ByteVector64 = records.get[Signature].get.signature def isValid: Boolean = { - val amountOk = offer.amount match { - case Some(offerAmount) => - val baseInvoiceAmount = offerAmount * quantity - amount.forall(baseInvoiceAmount <= _) - case None => amount.nonEmpty - } - amountOk && + amount_opt.forall(a => baseInvoiceAmount_opt.forall(b => a >= b)) && offer.chains.contains(chain) && offer.quantityMax.forall(max => quantity_opt.nonEmpty && quantity <= max) && quantity_opt.forall(_ => offer.quantityMax.nonEmpty) && @@ -426,6 +422,7 @@ object OfferTypes { _ -> () ) if (records.get[InvoiceRequestMetadata].isEmpty) return Left(MissingRequiredTlv(UInt64(0))) + if (records.get[InvoiceRequestAmount].isEmpty && records.get[OfferAmount].isEmpty) return Left(MissingRequiredTlv(UInt64(82))) if (records.get[InvoiceRequestPayerId].isEmpty) return Left(MissingRequiredTlv(UInt64(88))) if (records.get[Signature].isEmpty) return Left(MissingRequiredTlv(UInt64(240))) if (records.unknown.exists(!isInvoiceRequestTlv(_))) return Left(ForbiddenTlv(records.unknown.find(!isInvoiceRequestTlv(_)).get.tag)) 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 4bf5a594ed..b449d431e3 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -73,6 +73,7 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I val balanceActor = TestProbe() val postman = TestProbe() val offerManager = TestProbe() + val defaultOfferHandler = TestProbe() val kit = Kit( TestConstants.Alice.nodeParams, system, @@ -88,6 +89,7 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I balanceActor.ref.toTyped, postman.ref.toTyped, offerManager.ref.toTyped, + defaultOfferHandler.ref.toTyped, new DummyOnChainWallet() ) withFixture(test.toNoArgTest(FixtureParam(register, relayer, router, paymentInitiator, switchboard, paymentHandler, TestProbe(), kit))) 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 9084e843dc..4906ab3e06 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -26,6 +26,7 @@ import fr.acinq.eclair.db.RevokedHtlcInfoCleaner import fr.acinq.eclair.io.MessageRelay.RelayAll import fr.acinq.eclair.io.{OpenChannelInterceptor, PeerConnection, PeerReadyNotifier} import fr.acinq.eclair.message.OnionMessages.OnionMessageConfig +import fr.acinq.eclair.payment.offer.OffersConfig import fr.acinq.eclair.payment.relay.OnTheFlyFunding import fr.acinq.eclair.payment.relay.Relayer.{AsyncPaymentsParams, RelayFees, RelayParams} import fr.acinq.eclair.router.Graph.{MessageWeightRatios, PaymentWeightRatios} @@ -246,7 +247,8 @@ object TestConstants { liquidityAdsConfig = LiquidityAds.Config(Some(defaultLiquidityRates), lockUtxos = true), peerWakeUpConfig = PeerReadyNotifier.WakeUpConfig(enabled = false, timeout = 30 seconds), onTheFlyFundingConfig = OnTheFlyFunding.Config(proposalTimeout = 90 seconds), - peerStorageConfig = PeerStorageConfig(writeDelay = 5 seconds, removalDelay = 10 seconds, cleanUpFrequency = 1 hour) + peerStorageConfig = PeerStorageConfig(writeDelay = 5 seconds, removalDelay = 10 seconds, cleanUpFrequency = 1 hour), + offersConfig = OffersConfig(messagePathMinLength = 2, paymentPathCount = 2, paymentPathLength = 4, paymentPathCltvExpiryDelta = CltvExpiryDelta(500)), ) def channelParams: LocalParams = OpenChannelInterceptor.makeChannelParams( @@ -428,7 +430,8 @@ object TestConstants { liquidityAdsConfig = LiquidityAds.Config(Some(defaultLiquidityRates), lockUtxos = true), peerWakeUpConfig = PeerReadyNotifier.WakeUpConfig(enabled = false, timeout = 30 seconds), onTheFlyFundingConfig = OnTheFlyFunding.Config(proposalTimeout = 90 seconds), - peerStorageConfig = PeerStorageConfig(writeDelay = 5 seconds, removalDelay = 10 seconds, cleanUpFrequency = 1 hour) + peerStorageConfig = PeerStorageConfig(writeDelay = 5 seconds, removalDelay = 10 seconds, cleanUpFrequency = 1 hour), + offersConfig = OffersConfig(messagePathMinLength = 2, paymentPathCount = 2, paymentPathLength = 4, paymentPathCltvExpiryDelta = CltvExpiryDelta(500)), ) def channelParams: LocalParams = OpenChannelInterceptor.makeChannelParams( 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 1f76345423..dbb4775563 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala @@ -33,6 +33,7 @@ sealed trait TestDatabases extends Databases { override def channels: ChannelsDb = db.channels override def peers: PeersDb = db.peers override def payments: PaymentsDb = db.payments + override def offers: OffersDb = db.offers override def pendingCommands: PendingCommandsDb = db.pendingCommands override def liquidity: LiquidityDb = db.liquidity def close(): Unit diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/OffersDbSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/OffersDbSpec.scala new file mode 100644 index 0000000000..5b64c2e00d --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/OffersDbSpec.scala @@ -0,0 +1,49 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.db + +import fr.acinq.bitcoin.scalacompat.Block +import fr.acinq.eclair._ +import fr.acinq.eclair.wire.protocol.OfferTypes.Offer +import org.scalatest.funsuite.AnyFunSuite + +class OffersDbSpec extends AnyFunSuite { + + import fr.acinq.eclair.TestDatabases.forAllDbs + + test("add/disable/enable/list offers") { + forAllDbs { dbs => + val db = dbs.offers + + assert(db.listOffers(onlyActive = false).isEmpty) + val offer1 = OfferData(Offer(None, Some("test 1"), randomKey().publicKey, Features(), Block.LivenetGenesisBlock.hash), None, TimestampMilli(100), None) + db.addOffer(offer1.offer, None, offer1.createdAt) + assert(db.listOffers(onlyActive = true) == Seq(offer1)) + val pathId = randomBytes32() + val offer2 = OfferData(Offer(Some(15_000 msat), Some("test 2"), randomKey().publicKey, Features(), Block.LivenetGenesisBlock.hash), Some(pathId), TimestampMilli(200), None) + db.addOffer(offer2.offer, Some(pathId), offer2.createdAt) + assert(db.listOffers(onlyActive = true) == Seq(offer2, offer1)) + db.disableOffer(offer1.offer, disabledAt = TimestampMilli(250)) + assert(db.listOffers(onlyActive = true) == Seq(offer2)) + assert(db.listOffers(onlyActive = false) == Seq(offer2, offer1.copy(disabledAt_opt = Some(TimestampMilli(250))))) + db.disableOffer(offer2.offer, disabledAt = TimestampMilli(300)) + assert(db.listOffers(onlyActive = true).isEmpty) + assert(db.listOffers(onlyActive = false) == Seq(offer2.copy(disabledAt_opt = Some(TimestampMilli(300))), offer1.copy(disabledAt_opt = Some(TimestampMilli(250))))) + } + } + +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala index 436a2d5e75..5493307146 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala @@ -634,15 +634,15 @@ class PaymentIntegrationSpec extends IntegrationSpec { val handleInvoiceRequest = offerHandler.expectMessageType[HandleInvoiceRequest] val receivingRoutes = Seq( - OfferManager.InvoiceRequestActor.Route(route1.hops, recipientPaysFees = false, CltvExpiryDelta(1000)), - OfferManager.InvoiceRequestActor.Route(route2.hops, recipientPaysFees = false, CltvExpiryDelta(1000)), - OfferManager.InvoiceRequestActor.Route(route3.hops, recipientPaysFees = false, CltvExpiryDelta(1000)), + OfferManager.InvoiceRequestActor.Route(route1.hops, CltvExpiryDelta(1000)), + OfferManager.InvoiceRequestActor.Route(route2.hops, CltvExpiryDelta(1000)), + OfferManager.InvoiceRequestActor.Route(route3.hops, CltvExpiryDelta(1000)), ) handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, receivingRoutes, pluginData_opt = Some(hex"abcd")) val handlePayment = offerHandler.expectMessageType[HandlePayment] - assert(handlePayment.offerId == offer.offerId) - assert(handlePayment.pluginData_opt.contains(hex"abcd")) + assert(handlePayment.offer == offer) + assert(handlePayment.invoiceData.pluginData_opt.contains(hex"abcd")) handlePayment.replyTo ! PaymentActor.AcceptPayment() val paymentSent = sender.expectMsgType[PaymentSent] @@ -668,14 +668,14 @@ class PaymentIntegrationSpec extends IntegrationSpec { val handleInvoiceRequest = offerHandler.expectMessageType[HandleInvoiceRequest] // C uses a 0-hop blinded route and signs the invoice with its public nodeId. val receivingRoutes = Seq( - OfferManager.InvoiceRequestActor.Route(Nil, recipientPaysFees = false, CltvExpiryDelta(1000)), - OfferManager.InvoiceRequestActor.Route(Nil, recipientPaysFees = false, CltvExpiryDelta(1000)), + OfferManager.InvoiceRequestActor.Route(Nil, CltvExpiryDelta(1000)), + OfferManager.InvoiceRequestActor.Route(Nil, CltvExpiryDelta(1000)), ) handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, receivingRoutes, pluginData_opt = Some(hex"0123")) val handlePayment = offerHandler.expectMessageType[HandlePayment] - assert(handlePayment.offerId == offer.offerId) - assert(handlePayment.pluginData_opt.contains(hex"0123")) + assert(handlePayment.offer == offer) + assert(handlePayment.invoiceData.pluginData_opt.contains(hex"0123")) handlePayment.replyTo ! PaymentActor.AcceptPayment() val paymentSent = sender.expectMsgType[PaymentSent] @@ -703,13 +703,13 @@ class PaymentIntegrationSpec extends IntegrationSpec { val handleInvoiceRequest = offerHandler.expectMessageType[HandleInvoiceRequest] val receivingRoutes = Seq( - OfferManager.InvoiceRequestActor.Route(Seq(ChannelHop.dummy(nodes("A").nodeParams.nodeId, 100 msat, 100, CltvExpiryDelta(48)), ChannelHop.dummy(nodes("A").nodeParams.nodeId, 150 msat, 50, CltvExpiryDelta(36))), recipientPaysFees = false, CltvExpiryDelta(1000)) + OfferManager.InvoiceRequestActor.Route(Seq(ChannelHop.dummy(nodes("A").nodeParams.nodeId, 100 msat, 100, CltvExpiryDelta(48)), ChannelHop.dummy(nodes("A").nodeParams.nodeId, 150 msat, 50, CltvExpiryDelta(36))), CltvExpiryDelta(1000)) ) handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, receivingRoutes) val handlePayment = offerHandler.expectMessageType[HandlePayment] - assert(handlePayment.offerId == offer.offerId) - assert(handlePayment.pluginData_opt.isEmpty) + assert(handlePayment.offer == offer) + assert(handlePayment.invoiceData.pluginData_opt.isEmpty) handlePayment.replyTo ! PaymentActor.AcceptPayment() val paymentSent = sender.expectMsgType[PaymentSent] @@ -740,13 +740,13 @@ class PaymentIntegrationSpec extends IntegrationSpec { val handleInvoiceRequest = offerHandler.expectMessageType[HandleInvoiceRequest] val receivingRoutes = Seq( - OfferManager.InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(nodes("C").nodeParams.nodeId, 55 msat, 55, CltvExpiryDelta(55)), recipientPaysFees = false, CltvExpiryDelta(555)) + OfferManager.InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(nodes("C").nodeParams.nodeId, 55 msat, 55, CltvExpiryDelta(55)), CltvExpiryDelta(555)) ) handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, receivingRoutes, pluginData_opt = Some(hex"eff0")) val handlePayment = offerHandler.expectMessageType[HandlePayment] - assert(handlePayment.offerId == offer.offerId) - assert(handlePayment.pluginData_opt.contains(hex"eff0")) + assert(handlePayment.offer == offer) + assert(handlePayment.invoiceData.pluginData_opt.contains(hex"eff0")) handlePayment.replyTo ! PaymentActor.AcceptPayment() val paymentSent = sender.expectMsgType[PaymentSent] @@ -773,12 +773,12 @@ class PaymentIntegrationSpec extends IntegrationSpec { val route = sender.expectMsgType[Router.RouteResponse].routes.head val handleInvoiceRequest = offerHandler.expectMessageType[HandleInvoiceRequest] - val receivingRoutes = Seq(OfferManager.InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, CltvExpiryDelta(500))) + val receivingRoutes = Seq(OfferManager.InvoiceRequestActor.Route(route.hops, CltvExpiryDelta(500))) handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, receivingRoutes, pluginData_opt = Some(hex"0123")) val handlePayment = offerHandler.expectMessageType[HandlePayment] - assert(handlePayment.offerId == offer.offerId) - assert(handlePayment.pluginData_opt.contains(hex"0123")) + assert(handlePayment.offer == offer) + assert(handlePayment.invoiceData.pluginData_opt.contains(hex"0123")) handlePayment.replyTo ! PaymentActor.AcceptPayment() val paymentSent = sender.expectMsgType[PaymentSent] @@ -821,12 +821,12 @@ class PaymentIntegrationSpec extends IntegrationSpec { ShortChannelIdDir(channelBC.nodeId1 == nodes("B").nodeParams.nodeId, channelBC.shortChannelId) } val receivingRoutes = Seq( - OfferManager.InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(nodes("C").nodeParams.nodeId, 55 msat, 55, CltvExpiryDelta(55)), recipientPaysFees = false, CltvExpiryDelta(555), Some(scidDirCB)) + OfferManager.InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(nodes("C").nodeParams.nodeId, 55 msat, 55, CltvExpiryDelta(55)), CltvExpiryDelta(555), shortChannelIdDir_opt = Some(scidDirCB)) ) handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, receivingRoutes) val handlePayment = offerHandler.expectMessageType[HandlePayment] - assert(handlePayment.offerId == offer.offerId) + assert(handlePayment.offer == offer) handlePayment.replyTo ! PaymentActor.AcceptPayment() val paymentSent = sender.expectMsgType[PaymentSent] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala index 30e9091349..eaff03f4a6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala @@ -24,13 +24,13 @@ import fr.acinq.eclair.io.{Peer, PeerConnection, PendingChannelsRateLimiter, Swi import fr.acinq.eclair.message.Postman import fr.acinq.eclair.payment.Bolt11Invoice.ExtraHop import fr.acinq.eclair.payment._ -import fr.acinq.eclair.payment.offer.OfferManager +import fr.acinq.eclair.payment.offer.{DefaultOfferHandler, OfferManager} import fr.acinq.eclair.payment.receive.{MultiPartHandler, PaymentHandler} import fr.acinq.eclair.payment.relay.{ChannelRelayer, PostRestartHtlcCleaner, Relayer} import fr.acinq.eclair.payment.send.PaymentInitiator import fr.acinq.eclair.router.Router import fr.acinq.eclair.wire.protocol.IPAddress -import fr.acinq.eclair.{BlockHeight, MilliSatoshi, MilliSatoshiLong, NodeParams, RealShortChannelId, SubscriptionsComplete, TestBitcoinCoreClient, TestDatabases} +import fr.acinq.eclair.{BlockHeight, EclairImpl, Kit, MilliSatoshi, MilliSatoshiLong, NodeParams, RealShortChannelId, SubscriptionsComplete, TestBitcoinCoreClient, TestDatabases, nodeFee} import org.scalatest.concurrent.{Eventually, IntegrationPatience} import org.scalatest.{Assertions, EitherValues} @@ -54,6 +54,7 @@ case class MinimalNodeFixture private(nodeParams: NodeParams, paymentInitiator: ActorRef, paymentHandler: ActorRef, offerManager: typed.ActorRef[OfferManager.Command], + defaultOfferHandler: typed.ActorRef[OfferManager.HandlerCommand], postman: typed.ActorRef[Postman.Command], watcher: TestProbe, wallet: SingleKeyOnChainWallet, @@ -94,6 +95,7 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat val register = system.actorOf(Register.props(), "register") val router = system.actorOf(Router.props(nodeParams, watcherTyped), "router") val offerManager = system.spawn(OfferManager(nodeParams, 1 minute), "offer-manager") + val defaultOfferHandler = system.spawn(DefaultOfferHandler(nodeParams, router), "default-offer-handler") val paymentHandler = system.actorOf(PaymentHandler.props(nodeParams, register, offerManager), "payment-handler") val relayer = system.actorOf(Relayer.props(nodeParams, router, register, paymentHandler), "relayer") val txPublisherFactory = Channel.SimpleTxPublisherFactory(nodeParams, bitcoinClient) @@ -122,6 +124,7 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat paymentInitiator = paymentInitiator, paymentHandler = paymentHandler, offerManager = offerManager, + defaultOfferHandler = defaultOfferHandler, postman = postman, watcher = watcher, wallet = wallet, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/payment/OfferPaymentSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/payment/OfferPaymentSpec.scala index 6d4e211e2f..b465fab95d 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/payment/OfferPaymentSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/payment/OfferPaymentSpec.scala @@ -20,6 +20,7 @@ import akka.actor.typed.Behavior import akka.actor.typed.scaladsl.Behaviors import akka.actor.typed.scaladsl.adapter.{ClassicActorRefOps, ClassicActorSystemOps} import akka.testkit.TestProbe +import akka.util.Timeout import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, SatoshiLong} @@ -32,14 +33,15 @@ import fr.acinq.eclair.integration.basic.fixtures.composite.ThreeNodesFixture import fr.acinq.eclair.message.OnionMessages import fr.acinq.eclair.message.OnionMessages.{IntermediateNode, Recipient, buildRoute} import fr.acinq.eclair.payment._ -import fr.acinq.eclair.payment.offer.OfferManager +import fr.acinq.eclair.payment.offer.{OfferCreator, OfferManager} import fr.acinq.eclair.payment.offer.OfferManager.InvoiceRequestActor +import fr.acinq.eclair.payment.relay.Relayer.RelayFees import fr.acinq.eclair.payment.send.OfferPayment import fr.acinq.eclair.payment.send.PaymentInitiator.{SendPaymentToNode, SendSpontaneousPayment} import fr.acinq.eclair.router.Router import fr.acinq.eclair.router.Router.ChannelHop import fr.acinq.eclair.testutils.FixtureSpec -import fr.acinq.eclair.wire.protocol.OfferTypes.{Offer, OfferPaths} +import fr.acinq.eclair.wire.protocol.OfferTypes.{BlindedPath, Offer, OfferPaths} import fr.acinq.eclair.wire.protocol.{IncorrectOrUnknownPaymentDetails, InvalidOnionBlinding} import fr.acinq.eclair.{CltvExpiryDelta, EncodedNodeId, Features, MilliSatoshi, MilliSatoshiLong, ShortChannelId, randomBytes32, randomKey} import org.scalatest.concurrent.IntegrationPatience @@ -47,6 +49,7 @@ import org.scalatest.{Tag, TestData} import scodec.bits.HexStringSyntax import java.util.UUID +import scala.concurrent.Await import scala.concurrent.duration.DurationInt class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { @@ -164,23 +167,29 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { } } - def sendOfferPayment(f: FixtureParam, payer: MinimalNodeFixture, recipient: MinimalNodeFixture, amount: MilliSatoshi, routes: Seq[InvoiceRequestActor.Route], maxAttempts: Int = 1): (Offer, PaymentEvent) = { - import f._ + def createOffer(recipient: MinimalNodeFixture, description_opt: Option[String], amount_opt: Option[MilliSatoshi], issuer_opt: Option[String], blindedPathsFirstNodeId_opt: Option[PublicKey]): Offer = { + val sender = TestProbe("sender")(recipient.system) + val offerCreator = recipient.system.spawnAnonymous(OfferCreator(recipient.nodeParams, recipient.router, recipient.offerManager, recipient.defaultOfferHandler)) + offerCreator ! OfferCreator.Create(sender.ref.toTyped, description_opt, amount_opt, None, issuer_opt, blindedPathsFirstNodeId_opt) + sender.expectMsgType[OfferCreator.CreatedOffer].offer + } - val sender = TestProbe("sender") - val offer = Offer(None, Some("test"), recipient.nodeId, Features.empty, recipient.nodeParams.chainHash) - val handler = recipient.system.spawnAnonymous(offerHandler(amount, routes)) - recipient.offerManager ! OfferManager.RegisterOffer(offer, Some(recipient.nodeParams.privateKey), None, handler) + def payOffer(payer: MinimalNodeFixture, offer: Offer, amount: MilliSatoshi, maxAttempts: Int = 1): PaymentEvent = { + val sender = TestProbe("sender")(payer.system) val offerPayment = payer.system.spawnAnonymous(OfferPayment(payer.nodeParams, payer.postman, payer.router, payer.register, payer.paymentInitiator)) val sendPaymentConfig = OfferPayment.SendPaymentConfig(None, connectDirectly = false, maxAttempts, payer.routeParams, blocking = true) offerPayment ! OfferPayment.PayOffer(sender.ref, offer, amount, 1, sendPaymentConfig) - (offer, sender.expectMsgType[PaymentEvent]) + sender.expectMsgType[PaymentEvent] } - def sendPrivateOfferPayment(f: FixtureParam, payer: MinimalNodeFixture, recipient: MinimalNodeFixture, amount: MilliSatoshi, routes: Seq[InvoiceRequestActor.Route], maxAttempts: Int = 1): (Offer, PaymentEvent) = { - import f._ + def sendOfferPayment(payer: MinimalNodeFixture, recipient: MinimalNodeFixture, amount: MilliSatoshi, routes: Seq[InvoiceRequestActor.Route], maxAttempts: Int = 1): (Offer, PaymentEvent) = { + val offer = Offer(None, Some("test"), recipient.nodeId, Features.empty, recipient.nodeParams.chainHash) + val handler = recipient.system.spawnAnonymous(offerHandler(amount, routes)) + recipient.offerManager ! OfferManager.RegisterOffer(offer, Some(recipient.nodeParams.privateKey), None, handler) + (offer, payOffer(payer, offer, amount, maxAttempts)) + } - val sender = TestProbe("sender") + def sendPrivateOfferPayment(payer: MinimalNodeFixture, recipient: MinimalNodeFixture, amount: MilliSatoshi, routes: Seq[InvoiceRequestActor.Route], maxAttempts: Int = 1): (Offer, PaymentEvent) = { val recipientKey = randomKey() val pathId = randomBytes32() val offerPaths = routes.map(route => { @@ -190,10 +199,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val offer = Offer(None, Some("test"), recipientKey.publicKey, Features.empty, recipient.nodeParams.chainHash, additionalTlvs = Set(OfferPaths(offerPaths))) val handler = recipient.system.spawnAnonymous(offerHandler(amount, routes)) recipient.offerManager ! OfferManager.RegisterOffer(offer, Some(recipientKey), Some(pathId), handler) - val offerPayment = payer.system.spawnAnonymous(OfferPayment(payer.nodeParams, payer.postman, payer.router, payer.register, payer.paymentInitiator)) - val sendPaymentConfig = OfferPayment.SendPaymentConfig(None, connectDirectly = false, maxAttempts, payer.routeParams, blocking = true) - offerPayment ! OfferPayment.PayOffer(sender.ref, offer, amount, 1, sendPaymentConfig) - (offer, sender.expectMsgType[PaymentEvent]) + (offer, payOffer(payer, offer, amount, maxAttempts)) } def sendOfferPaymentWithInvalidAmount(f: FixtureParam, payer: MinimalNodeFixture, recipient: MinimalNodeFixture, payerAmount: MilliSatoshi, recipientAmount: MilliSatoshi, routes: Seq[InvoiceRequestActor.Route]): PaymentFailed = { @@ -231,8 +237,8 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(amount, Seq(bob.nodeId, carol.nodeId))) val route = sender.expectMsgType[Router.RouteResponse].routes.head - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) - val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) + val (offer, result) = sendOfferPayment(alice, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) assert(payment.parts.head.feesPaid > 0.msat) @@ -247,8 +253,8 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(amount, Seq(bob.nodeId, carol.nodeId))) val route = sender.expectMsgType[Router.RouteResponse].routes.head - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = true, maxFinalExpiryDelta)) - val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta, feeOverride_opt = Some(RelayFees.zero))) + val (offer, result) = sendOfferPayment(alice, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) assert(payment.parts.head.feesPaid == 0.msat) @@ -264,10 +270,10 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val route = sender.expectMsgType[Router.RouteResponse].routes.head val routes = Seq( - InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta), - InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta), + InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta), + InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta), ) - val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes, maxAttempts = 3) + val (offer, result) = sendOfferPayment(alice, carol, amount, routes, maxAttempts = 3) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 2) assert(payment.parts.forall(_.feesPaid > 0.msat)) @@ -283,10 +289,10 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val route = sender.expectMsgType[Router.RouteResponse].routes.head val routes = Seq( - InvoiceRequestActor.Route(route.hops, recipientPaysFees = true, maxFinalExpiryDelta), - InvoiceRequestActor.Route(route.hops, recipientPaysFees = true, maxFinalExpiryDelta), + InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta, feeOverride_opt = Some(RelayFees.zero)), + InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta, feeOverride_opt = Some(RelayFees.zero)), ) - val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes, maxAttempts = 3) + val (offer, result) = sendOfferPayment(alice, carol, amount, routes, maxAttempts = 3) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 2) assert(payment.parts.forall(_.feesPaid == 0.msat)) @@ -300,17 +306,17 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val route = sender.expectMsgType[Router.RouteResponse].routes.head // Carol advertises a single blinded path from Bob to herself. - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) // We make a first set of payments to ensure channels have less than 50 000 sat on Bob's side. Seq(50_000_000 msat, 50_000_000 msat).foreach(amount => { - val (offer, result) = sendPrivateOfferPayment(f, alice, carol, amount, routes) + val (offer, result) = sendPrivateOfferPayment(alice, carol, amount, routes) verifyPaymentSuccess(offer, amount, result) }) // None of the channels between Bob and Carol have enough balance for the payment: Alice needs to split it. val amount = 50_000_000 msat - val (offer, result) = sendPrivateOfferPayment(f, alice, carol, amount, routes, maxAttempts = 3) + val (offer, result) = sendPrivateOfferPayment(alice, carol, amount, routes, maxAttempts = 3) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length > 1) } @@ -332,9 +338,9 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(10_000_000 msat, Seq(bob.nodeId, carol.nodeId))) val route = sender.expectMsgType[Router.RouteResponse].routes.head - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) val amount1 = 150_000_000 msat - val (offer, result) = sendPrivateOfferPayment(f, alice, carol, amount1, routes, maxAttempts = 3) + val (offer, result) = sendPrivateOfferPayment(alice, carol, amount1, routes, maxAttempts = 3) val payment = verifyPaymentSuccess(offer, amount1, result) assert(payment.parts.length > 1) } @@ -348,10 +354,10 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val amount = 125_000_000 msat val routes = Seq( - InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(carol.nodeId, 150 msat, 0, CltvExpiryDelta(50)), recipientPaysFees = false, maxFinalExpiryDelta), - InvoiceRequestActor.Route(route.hops ++ Seq(ChannelHop.dummy(carol.nodeId, 50 msat, 0, CltvExpiryDelta(20)), ChannelHop.dummy(carol.nodeId, 100 msat, 0, CltvExpiryDelta(30))), recipientPaysFees = false, maxFinalExpiryDelta), + InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(carol.nodeId, 150 msat, 0, CltvExpiryDelta(50)), maxFinalExpiryDelta), + InvoiceRequestActor.Route(route.hops ++ Seq(ChannelHop.dummy(carol.nodeId, 50 msat, 0, CltvExpiryDelta(20)), ChannelHop.dummy(carol.nodeId, 100 msat, 0, CltvExpiryDelta(30))), maxFinalExpiryDelta), ) - val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes) + val (offer, result) = sendOfferPayment(alice, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 2) assert(payment.parts.forall(_.feesPaid > 0.msat)) @@ -366,10 +372,10 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val amount = 125_000_000 msat val routes = Seq( - InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(carol.nodeId, 150 msat, 0, CltvExpiryDelta(50)), recipientPaysFees = true, maxFinalExpiryDelta), - InvoiceRequestActor.Route(route.hops ++ Seq(ChannelHop.dummy(carol.nodeId, 50 msat, 0, CltvExpiryDelta(20)), ChannelHop.dummy(carol.nodeId, 100 msat, 0, CltvExpiryDelta(30))), recipientPaysFees = true, maxFinalExpiryDelta), + InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(carol.nodeId, 150 msat, 0, CltvExpiryDelta(50)), maxFinalExpiryDelta, feeOverride_opt = Some(RelayFees.zero)), + InvoiceRequestActor.Route(route.hops ++ Seq(ChannelHop.dummy(carol.nodeId, 50 msat, 0, CltvExpiryDelta(20)), ChannelHop.dummy(carol.nodeId, 100 msat, 0, CltvExpiryDelta(30))), maxFinalExpiryDelta, feeOverride_opt = Some(RelayFees.zero)), ) - val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes) + val (offer, result) = sendOfferPayment(alice, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 2) assert(payment.parts.forall(_.feesPaid == 0.msat)) @@ -384,8 +390,8 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(amount, Seq(bob.nodeId, carol.nodeId))) val route = sender.expectMsgType[Router.RouteResponse].routes.head - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) - val (offer, result) = sendPrivateOfferPayment(f, alice, carol, amount, routes) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) + val (offer, result) = sendPrivateOfferPayment(alice, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.forall(_.feesPaid > 0.msat)) } @@ -399,8 +405,8 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(amount, Seq(bob.nodeId, carol.nodeId))) val route = sender.expectMsgType[Router.RouteResponse].routes.head - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = true, maxFinalExpiryDelta)) - val (offer, result) = sendPrivateOfferPayment(f, alice, carol, amount, routes) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta, feeOverride_opt = Some(RelayFees.zero))) + val (offer, result) = sendPrivateOfferPayment(alice, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.forall(_.feesPaid == 0.msat)) } @@ -409,8 +415,8 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { import f._ val amount = 75_000_000 msat - val routes = Seq(InvoiceRequestActor.Route(Nil, recipientPaysFees = false, maxFinalExpiryDelta)) - val (offer, result) = sendOfferPayment(f, alice, bob, amount, routes) + val routes = Seq(InvoiceRequestActor.Route(Nil, maxFinalExpiryDelta)) + val (offer, result) = sendOfferPayment(alice, bob, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) } @@ -419,8 +425,8 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { import f._ val amount = 250_000_000 msat - val routes = Seq(InvoiceRequestActor.Route(Seq(ChannelHop.dummy(bob.nodeId, 10 msat, 25, CltvExpiryDelta(24)), ChannelHop.dummy(bob.nodeId, 5 msat, 10, CltvExpiryDelta(36))), recipientPaysFees = false, maxFinalExpiryDelta)) - val (offer, result) = sendOfferPayment(f, alice, bob, amount, routes) + val routes = Seq(InvoiceRequestActor.Route(Seq(ChannelHop.dummy(bob.nodeId, 10 msat, 25, CltvExpiryDelta(24)), ChannelHop.dummy(bob.nodeId, 5 msat, 10, CltvExpiryDelta(36))), maxFinalExpiryDelta)) + val (offer, result) = sendOfferPayment(alice, bob, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) assert(payment.parts.forall(_.feesPaid > 0.msat)) @@ -430,8 +436,8 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { import f._ val amount = 250_000_000 msat - val routes = Seq(InvoiceRequestActor.Route(Seq(ChannelHop.dummy(bob.nodeId, 10 msat, 25, CltvExpiryDelta(24)), ChannelHop.dummy(bob.nodeId, 5 msat, 10, CltvExpiryDelta(36))), recipientPaysFees = true, maxFinalExpiryDelta)) - val (offer, result) = sendOfferPayment(f, alice, bob, amount, routes) + val routes = Seq(InvoiceRequestActor.Route(Seq(ChannelHop.dummy(bob.nodeId, 10 msat, 25, CltvExpiryDelta(24)), ChannelHop.dummy(bob.nodeId, 5 msat, 10, CltvExpiryDelta(36))), maxFinalExpiryDelta, feeOverride_opt = Some(RelayFees.zero))) + val (offer, result) = sendOfferPayment(alice, bob, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) assert(payment.parts.forall(_.feesPaid == 0.msat)) @@ -446,8 +452,8 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(amount, Seq(bob.nodeId, carol.nodeId))) val route = sender.expectMsgType[Router.RouteResponse].routes.head - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) - val (offer, result) = sendOfferPayment(f, bob, carol, amount, routes) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) + val (offer, result) = sendOfferPayment(bob, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) } @@ -464,7 +470,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val route = sender.expectMsgType[Router.RouteResponse].routes.head // Carol creates a blinded path using that channel. - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) // We make a payment to ensure that the channel contains less than 150 000 sat on Bob's side. assert(sendPayment(bob, carol, 50_000_000 msat).isRight) @@ -475,7 +481,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { // None of the channels have enough balance for the payment: it must be split. val amount = 150_000_000 msat - val (offer, result) = sendOfferPayment(f, bob, carol, amount, routes, maxAttempts = 3) + val (offer, result) = sendOfferPayment(bob, carol, amount, routes, maxAttempts = 3) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length > 1) } @@ -489,8 +495,8 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(amount, Seq(bob.nodeId, carol.nodeId))) val route = sender.expectMsgType[Router.RouteResponse].routes.head - val routes = Seq(InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(carol.nodeId, 25 msat, 250, CltvExpiryDelta(75)), recipientPaysFees = false, maxFinalExpiryDelta)) - val (offer, result) = sendOfferPayment(f, bob, carol, amount, routes) + val routes = Seq(InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(carol.nodeId, 25 msat, 250, CltvExpiryDelta(75)), maxFinalExpiryDelta)) + val (offer, result) = sendOfferPayment(bob, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) } @@ -515,9 +521,9 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { // Carol receives a first payment through those channels. { - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) val amount1 = 100_000_000 msat - val (offer, result) = sendOfferPayment(f, alice, carol, amount1, routes) + val (offer, result) = sendOfferPayment(alice, carol, amount1, routes) val payment = verifyPaymentSuccess(offer, amount1, result) assert(payment.parts.length == 1) } @@ -531,9 +537,9 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { // Carol receives a second payment that requires using MPP. { - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) val amount2 = 200_000_000 msat - val (offer, result) = sendOfferPayment(f, alice, carol, amount2, routes, maxAttempts = 3) + val (offer, result) = sendOfferPayment(alice, carol, amount2, routes, maxAttempts = 3) val payment = verifyPaymentSuccess(offer, amount2, result) assert(payment.parts.length > 1) } @@ -560,9 +566,9 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val route = sender.expectMsgType[Router.RouteResponse].routes.head // Carol receives a payment that requires using MPP. - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) val amount = 300_000_000 msat - val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes, maxAttempts = 3) + val (offer, result) = sendOfferPayment(alice, carol, amount, routes, maxAttempts = 3) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length > 1) } @@ -588,9 +594,9 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val route = sender.expectMsgType[Router.RouteResponse].routes.head // Carol receives a payment that requires using MPP. - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) val amount = 200_000_000 msat - val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes, maxAttempts = 3) + val (offer, result) = sendOfferPayment(alice, carol, amount, routes, maxAttempts = 3) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length > 1) } @@ -618,8 +624,8 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { sender.expectMsgType[PaymentSent] }) // Bob now doesn't have enough funds to relay the payment. - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) - val (_, result) = sendOfferPayment(f, alice, carol, 75_000_000 msat, routes) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) + val (_, result) = sendOfferPayment(alice, carol, 75_000_000 msat, routes) verifyBlindedFailure(result, bob.nodeId) } @@ -630,8 +636,8 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(25_000_000 msat, Seq(bob.nodeId, carol.nodeId))) val route = sender.expectMsgType[Router.RouteResponse].routes.head - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, CltvExpiryDelta(-500))) - val (_, result) = sendOfferPayment(f, alice, carol, 25_000_000 msat, routes) + val routes = Seq(InvoiceRequestActor.Route(route.hops, CltvExpiryDelta(-500))) + val (_, result) = sendOfferPayment(alice, carol, 25_000_000 msat, routes) verifyBlindedFailure(result, bob.nodeId) } @@ -645,7 +651,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(recipientAmount, Seq(bob.nodeId, carol.nodeId))) val route = sender.expectMsgType[Router.RouteResponse].routes.head - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) // The amount is below what Carol expects. val payment = sendOfferPaymentWithInvalidAmount(f, alice, carol, payerAmount, recipientAmount, routes) verifyBlindedFailure(payment, bob.nodeId) @@ -656,7 +662,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val payerAmount = 25_000_000 msat val recipientAmount = 50_000_000 msat - val routes = Seq(InvoiceRequestActor.Route(Nil, recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(Nil, maxFinalExpiryDelta)) // The amount is below what Bob expects: since he is both the introduction node and the final recipient, he sends // back a normal error. val payment = sendOfferPaymentWithInvalidAmount(f, alice, bob, payerAmount, recipientAmount, routes) @@ -672,7 +678,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val payerAmount = 25_000_000 msat val recipientAmount = 50_000_000 msat - val routes = Seq(InvoiceRequestActor.Route(Seq(ChannelHop.dummy(bob.nodeId, 1 msat, 100, CltvExpiryDelta(48))), recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(Seq(ChannelHop.dummy(bob.nodeId, 1 msat, 100, CltvExpiryDelta(48))), maxFinalExpiryDelta)) // The amount is below what Bob expects: since he is both the introduction node and the final recipient, he sends // back a normal error. val payment = sendOfferPaymentWithInvalidAmount(f, alice, bob, payerAmount, recipientAmount, routes) @@ -693,7 +699,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(recipientAmount, Seq(bob.nodeId, carol.nodeId))) val route = sender.expectMsgType[Router.RouteResponse].routes.head - val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) // The amount is below what Carol expects. val payment = sendOfferPaymentWithInvalidAmount(f, bob, carol, payerAmount, recipientAmount, routes) assert(payment.failures.head.isInstanceOf[PaymentFailure]) @@ -718,7 +724,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(amount, Seq(bob.nodeId, carol.nodeId))) val route = sender.expectMsgType[Router.RouteResponse].routes.head - val receivingRoute = InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta) + val receivingRoute = InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta) val handler = carol.system.spawnAnonymous(offerHandler(amount, Seq(receivingRoute))) carol.offerManager ! OfferManager.RegisterOffer(compactOffer, Some(recipientKey), Some(pathId), handler) val offerPayment = alice.system.spawnAnonymous(OfferPayment(alice.nodeParams, alice.postman, alice.router, alice.register, alice.paymentInitiator)) @@ -740,7 +746,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val offerPaths = Seq(OnionMessages.buildRoute(randomKey(), Seq(IntermediateNode(bob.nodeId)), Recipient(carol.nodeId, Some(pathId))).route) val offer = Offer.withPaths(None, Some("implicit node id"), offerPaths, Features.empty, carol.nodeParams.chainHash) - val handler = carol.system.spawnAnonymous(offerHandler(amount, Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)))) + val handler = carol.system.spawnAnonymous(offerHandler(amount, Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)))) carol.offerManager ! OfferManager.RegisterOffer(offer, None, Some(pathId), handler) val offerPayment = alice.system.spawnAnonymous(OfferPayment(alice.nodeParams, alice.postman, alice.router, alice.register, alice.paymentInitiator)) val sendPaymentConfig = OfferPayment.SendPaymentConfig(None, connectDirectly = false, maxAttempts = 1, alice.routeParams, blocking = true) @@ -749,4 +755,67 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) } + + test("basic offer") { f => + import f._ + + val amount = 20_000_000 msat + val offer = createOffer(carol, description_opt = Some("test offer"), amount_opt = Some(amount), issuer_opt = None, blindedPathsFirstNodeId_opt = None) + + assert(offer.nodeId == Some(carol.nodeId)) + assert(offer.description == Some("test offer")) + assert(offer.amount == Some(amount)) + + val payment = payOffer(alice, offer, amount) + assert(payment.isInstanceOf[PaymentSent]) + assert(payment.asInstanceOf[PaymentSent].feesPaid > 0.msat) + } + + test("offer without node id (dummy hops only)") { f => + import f._ + + val amount = 20_000_000 msat + val offer = createOffer(carol, description_opt = Some("test offer"), amount_opt = Some(amount), issuer_opt = None, blindedPathsFirstNodeId_opt = Some(carol.nodeId)) + + assert(offer.nodeId == None) + assert(offer.contactInfos.head.asInstanceOf[BlindedPath].route.firstNodeId == EncodedNodeId.WithPublicKey.Plain(carol.nodeId)) + assert(offer.description == Some("test offer")) + assert(offer.amount == Some(amount)) + + val payment = payOffer(alice, offer, amount) + assert(payment.isInstanceOf[PaymentSent]) + assert(payment.asInstanceOf[PaymentSent].feesPaid > 0.msat) + } + + test("offer without node id (real and dummy blinded hops)") { f => + import f._ + + val amount = 20_000_000 msat + val offer = createOffer(carol, description_opt = Some("test offer"), amount_opt = Some(amount), issuer_opt = None, blindedPathsFirstNodeId_opt = Some(bob.nodeId)) + + assert(offer.nodeId == None) + assert(offer.contactInfos.head.asInstanceOf[BlindedPath].route.firstNodeId == EncodedNodeId.WithPublicKey.Plain(bob.nodeId)) + assert(offer.description == Some("test offer")) + assert(offer.amount == Some(amount)) + + val payment = payOffer(alice, offer, amount) + assert(payment.isInstanceOf[PaymentSent]) + assert(payment.asInstanceOf[PaymentSent].feesPaid == 0.msat) + } + + test("offer without node id (payer is first node of blinded path)") { f => + import f._ + + val amount = 20_000_000 msat + val offer = createOffer(carol, description_opt = Some("test offer"), amount_opt = Some(amount), issuer_opt = None, blindedPathsFirstNodeId_opt = Some(alice.nodeId)) + + assert(offer.nodeId == None) + assert(offer.contactInfos.head.asInstanceOf[BlindedPath].route.firstNodeId == EncodedNodeId.WithPublicKey.Plain(alice.nodeId)) + assert(offer.description == Some("test offer")) + assert(offer.amount == Some(amount)) + + val payment = payOffer(alice, offer, amount) + assert(payment.isInstanceOf[PaymentSent]) + assert(payment.asInstanceOf[PaymentSent].feesPaid < 0.msat) + } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt12InvoiceSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt12InvoiceSpec.scala index 10410d4d3a..15282f14f2 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt12InvoiceSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt12InvoiceSpec.scala @@ -185,6 +185,7 @@ class Bolt12InvoiceSpec extends AnyFunSuite { val tlvs = Set[InvoiceTlv]( InvoiceRequestMetadata(hex"012345"), OfferNodeId(nodeKey.publicKey), + InvoiceRequestAmount(1684 msat), InvoiceRequestPayerId(randomKey().publicKey), InvoicePaths(Seq(createPaymentBlindedRoute(randomKey().publicKey).route)), InvoiceBlindedPay(Seq(PaymentInfo(0 msat, 0, CltvExpiryDelta(0), 0 msat, 765432 msat, Features.empty))), diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/offer/OfferManagerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/offer/OfferManagerSpec.scala index 0f0b8596df..16fad765a8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/offer/OfferManagerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/offer/OfferManagerSpec.scala @@ -28,6 +28,7 @@ import fr.acinq.eclair.payment.Bolt12Invoice import fr.acinq.eclair.payment.offer.OfferManager._ import fr.acinq.eclair.payment.receive.MultiPartHandler import fr.acinq.eclair.payment.receive.MultiPartHandler.GetIncomingPaymentActor.{ProcessPayment, RejectPayment} +import fr.acinq.eclair.payment.relay.Relayer.RelayFees import fr.acinq.eclair.router.Router.ChannelHop import fr.acinq.eclair.wire.protocol.OfferTypes.{InvoiceRequest, Offer} import fr.acinq.eclair.wire.protocol.RouteBlindingEncryptedDataCodecs.RouteBlindingDecryptedData @@ -76,7 +77,8 @@ class OfferManagerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val handleInvoiceRequest = handler.expectMessageType[HandleInvoiceRequest] assert(handleInvoiceRequest.invoiceRequest.isValid) assert(handleInvoiceRequest.invoiceRequest.payerId == payerKey.publicKey) - handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, Seq(InvoiceRequestActor.Route(hops, hideFees, CltvExpiryDelta(1000))), pluginData_opt) + val feeOverride_opt = if (hideFees) Some(RelayFees.zero) else None + handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, Seq(InvoiceRequestActor.Route(hops, CltvExpiryDelta(1000), feeOverride_opt)), pluginData_opt) val invoiceMessage = postman.expectMessageType[Postman.SendMessage] val Right(invoice) = Bolt12Invoice.validate(invoiceMessage.message.get[OnionMessagePayloadTlv.Invoice].get.tlvs) assert(invoice.validateFor(handleInvoiceRequest.invoiceRequest, pathNodeId).isRight) @@ -126,8 +128,8 @@ class OfferManagerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val paymentPayload = createPaymentPayload(f, invoice) offerManager ! ReceivePayment(paymentHandler.ref, invoice.paymentHash, paymentPayload, amount) val handlePayment = handler.expectMessageType[HandlePayment] - assert(handlePayment.offerId == offer.offerId) - assert(handlePayment.pluginData_opt.contains(hex"deadbeef")) + assert(handlePayment.offer == offer) + assert(handlePayment.invoiceData.pluginData_opt.contains(hex"deadbeef")) handlePayment.replyTo ! PaymentActor.AcceptPayment() val ProcessPayment(incomingPayment, _) = paymentHandler.expectMessageType[ProcessPayment] assert(Crypto.sha256(incomingPayment.paymentPreimage) == invoice.paymentHash) @@ -298,7 +300,7 @@ class OfferManagerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app offerManager ! ReceivePayment(paymentHandler.ref, invoice.paymentHash, paymentPayload, amount) val handlePayment = handler.expectMessageType[HandlePayment] - assert(handlePayment.offerId == offer.offerId) + assert(handlePayment.offer == offer) handlePayment.replyTo ! PaymentActor.AcceptPayment() val ProcessPayment(incomingPayment, maxRecipientPathFees) = paymentHandler.expectMessageType[ProcessPayment] assert(Crypto.sha256(incomingPayment.paymentPreimage) == invoice.paymentHash) @@ -324,7 +326,7 @@ class OfferManagerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app offerManager ! ReceivePayment(paymentHandler.ref, invoice.paymentHash, paymentPayload, amountReceived) val handlePayment = handler.expectMessageType[HandlePayment] - assert(handlePayment.offerId == offer.offerId) + assert(handlePayment.offer == offer) handlePayment.replyTo ! PaymentActor.AcceptPayment() val ProcessPayment(incomingPayment, maxRecipientPathFees) = paymentHandler.expectMessageType[ProcessPayment] assert(Crypto.sha256(incomingPayment.paymentPreimage) == invoice.paymentHash) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala index 988a22c391..b0a541ce7a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala @@ -122,8 +122,11 @@ class OfferTypesSpec extends AnyFunSuite { val request = InvoiceRequest(offer, 500 msat, 1, Features.empty, payerKey, Block.LivenetGenesisBlock.hash) assert(request.isValid) assert(request.offer == offer) - val withoutAmount = signInvoiceRequest(request.copy(records = TlvStream(request.records.records.filter { case InvoiceRequestAmount(_) => false case _ => true })), payerKey) - assert(!withoutAmount.isValid) + // Since the offer doesn't contain an amount, the invoice_request must contain one to be valid. + assertThrows[Exception](request.copy(records = TlvStream(request.records.records.filter { + case InvoiceRequestAmount(_) => false + case _ => true + }))) } test("check that invoice request matches offer (chain compatibility)") { @@ -188,11 +191,12 @@ class OfferTypesSpec extends AnyFunSuite { InvoiceRequestMetadata(hex"abcdef"), OfferNodeId(nodeId), InvoiceRequestPayerId(payerKey.publicKey), + InvoiceRequestAmount(21000 msat) ) val signature = signSchnorr(InvoiceRequest.signatureTag, rootHash(TlvStream[InvoiceRequestTlv](tlvsWithoutSignature), OfferCodecs.invoiceRequestTlvCodec), payerKey) val tlvs = tlvsWithoutSignature + Signature(signature) val invoiceRequest = InvoiceRequest(TlvStream(tlvs)) - val encoded = "lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupetqssynwewhp70gwlp4chhm53g90jt9fpnx7rpmrzla3zd0nvxymm8e0p7pq06rwacy8756zgl3hdnsyfepq573astyz94rgn9uhxlyqj4gdyk6q8q0yrv6al909v3435amuvjqvkuq6k8fyld78r8srdyx7wnmwsdu" + val encoded = "lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2gp9yzzcyypymkt4c0n6rhcdw9a7ay2ptuje2gvehscwcchlvgntump3x7e7tc0sgp9k43qeu892gfnz2hrr7akh2x8erh7zm2tv52884vyl462dm5tfcahgtuzt7j0npy7getf4trv5d4g78a9fkwu3kke6hcxdr6t2n7vz" assert(InvoiceRequest.decode(encoded).get == invoiceRequest) assert(invoiceRequest.offer.amount.isEmpty) assert(invoiceRequest.offer.description.isEmpty) diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala index 6e96a9c092..60bfcfce2d 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala @@ -23,7 +23,7 @@ import fr.acinq.eclair.api.directives.EclairDirectives import fr.acinq.eclair.api.handlers._ import grizzled.slf4j.Logging -trait Service extends EclairDirectives with WebSocket with Node with Control with Channel with Fees with PathFinding with Invoice with Payment with Message with OnChain with Logging { +trait Service extends EclairDirectives with WebSocket with Node with Control with Channel with Fees with PathFinding with Invoice with Offer with Payment with Message with OnChain with Logging { /** * Allows router access to the API password as configured in eclair.conf @@ -46,7 +46,7 @@ trait Service extends EclairDirectives with WebSocket with Node with Control wit * This is where we handle errors to ensure all routes are correctly tried before rejecting. */ def finalRoutes(extraRouteProviders: Seq[RouteProvider] = Nil): Route = securedHandler { - val baseRoutes = nodeRoutes ~ controlRoutes ~ channelRoutes ~ feeRoutes ~ pathFindingRoutes ~ invoiceRoutes ~ paymentRoutes ~ messageRoutes ~ onChainRoutes ~ webSocket + val baseRoutes = nodeRoutes ~ controlRoutes ~ channelRoutes ~ feeRoutes ~ pathFindingRoutes ~ invoiceRoutes ~ offerRoutes ~ paymentRoutes ~ messageRoutes ~ onChainRoutes ~ webSocket extraRouteProviders.map(_.route(this)).foldLeft(baseRoutes)(_ ~ _) } } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Offer.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Offer.scala new file mode 100644 index 0000000000..96949c9d34 --- /dev/null +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Offer.scala @@ -0,0 +1,50 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.api.handlers + +import akka.http.scaladsl.server.Route +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.eclair.api.Service +import fr.acinq.eclair.api.directives.EclairDirectives +import fr.acinq.eclair.api.serde.FormParamExtractors._ + +trait Offer { + this: Service with EclairDirectives => + + import fr.acinq.eclair.api.serde.JsonSupport.{formats, marshaller, serialization} + + val createOffer: Route = postRequest("createoffer") { implicit t => + formFields("description".?, amountMsatFormParam.?, "expireInSeconds".as[Long].?, "issuer".?, "blindedPathsFirstNodeId".as[PublicKey].?) { + (description_opt, amount_opt, expireInSeconds_opt, issuer_opt, blindedPathsFirstNodeId_opt) => complete(eclairApi.createOffer(description_opt, amount_opt, expireInSeconds_opt, issuer_opt, blindedPathsFirstNodeId_opt)) + } + } + + val disableOffer: Route = postRequest("disableoffer") { implicit t => + formFields(offerFormParam) { offer => + complete(eclairApi.disableOffer(offer)) + } + } + + val listoffers: Route = postRequest("listoffers") { implicit t => + formFields("activeOnly".as[Boolean].?) { onlyActive => + complete(eclairApi.listOffers(onlyActive.getOrElse(true))) + } + } + + val offerRoutes: Route = createOffer ~ disableOffer ~ listoffers + +}