diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 403c8d5e3f..6e32d5165c 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -636,6 +636,14 @@ 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 } + + managed-offers { + message-path-min-length = 2 + + payment-path-count = 2 + payment-path-length = 4 + 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 207fce90e9..19ea4f1560 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -38,15 +38,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 @@ -120,6 +122,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], expiry_opt: Option[TimestampSecond], issuer_opt: Option[String], firstNodeId_opt: Option[PublicKey])(implicit timeout: Timeout): Future[Offer] + + def disableOffer(offer: Offer)(implicit timeout: Timeout): 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]] @@ -370,6 +378,24 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { } } + override def createOffer(description_opt: Option[String], amount_opt: Option[MilliSatoshi], expiry_opt: Option[TimestampSecond], issuer_opt: Option[String], firstNodeId_opt: Option[PublicKey])(implicit timeout: Timeout): Future[Offer] = { + val offerCreator = appKit.system.spawnAnonymous(OfferCreator(appKit.nodeParams, appKit.router, appKit.offerManager, appKit.defaultOfferHandler)) + offerCreator.ask[Either[String, Offer]](replyTo => OfferCreator.Create(replyTo, description_opt, amount_opt, expiry_opt, issuer_opt, firstNodeId_opt)) + .flatMap { + case Left(errorMessage) => Future.failed(new Exception(errorMessage)) + case Right(offer) => Future.successful(offer) + } + } + + override def disableOffer(offer: Offer)(implicit timeout: Timeout): Unit = { + appKit.offerManager ! OfferManager.DisableOffer(offer) + appKit.nodeParams.db.managedOffers.disableOffer(offer) + } + + override def listOffers(onlyActive: Boolean = true)(implicit timeout: Timeout): Future[Seq[Offer]] = Future { + appKit.nodeParams.db.managedOffers.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/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index 48b502c0b8..1d349054cf 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("managed-offers.message-path-min-length"), + paymentPathCount = config.getInt("managed-offers.payment-path-count"), + paymentPathLength = config.getInt("managed-offers.payment-path-length"), + paymentPathCltvExpiryDelta = CltvExpiryDelta(config.getInt("managed-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..2c05b28e01 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.{DefaultHandler, 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(DefaultHandler(nodeParams, router)).onFailure(typed.SupervisorStrategy.resume), name = "default-offer-handler") + _ = for (offer <- nodeParams.db.managedOffers.listOffers(onlyActive = true)) offerManager ! OfferManager.RegisterOffer(offer.offer, 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..4ef14b8dd8 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 managedOffers: OffersDb def pendingCommands: PendingCommandsDb def liquidity: LiquidityDb //@formatter:on @@ -66,6 +67,7 @@ object Databases extends Logging { channels: SqliteChannelsDb, peers: SqlitePeersDb, payments: SqlitePaymentsDb, + managedOffers: 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), + managedOffers = new SqliteOffersDb(eclairJdbc), pendingCommands = new SqlitePendingCommandsDb(eclairJdbc), backupConnection = eclairJdbc ) @@ -97,6 +100,7 @@ object Databases extends Logging { channels: PgChannelsDb, peers: PgPeersDb, payments: PgPaymentsDb, + managedOffers: 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, + managedOffers = 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..c0a3a7eed4 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 managedOffers: OffersDb = DualOffersDb(primary.managedOffers, secondary.managedOffers) override val pendingCommands: PendingCommandsDb = DualPendingCommandsDb(primary.pendingCommands, secondary.pendingCommands) override val liquidity: LiquidityDb = DualLiquidityDb(primary.liquidity, secondary.liquidity) @@ -405,6 +406,31 @@ 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]): Unit = { + runAsync(secondary.addOffer(offer, pathId_opt)) + primary.addOffer(offer, pathId_opt) + } + + override def disableOffer(offer: OfferTypes.Offer): Unit = { + runAsync(secondary.disableOffer(offer)) + primary.disableOffer(offer) + } + + override def enableOffer(offer: OfferTypes.Offer): Unit = { + runAsync(secondary.enableOffer(offer)) + primary.enableOffer(offer) + } + + 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..f7d1409f83 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/OffersDb.scala @@ -0,0 +1,51 @@ +/* + * 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]): 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): Unit + + /** + * Activate an offer that was previously disabled. + */ + def enableOffer(offer: Offer): 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, isActive: Boolean) \ No newline at end of file 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..43cc5ff31a --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgOffersDb.scala @@ -0,0 +1,113 @@ +/* + * 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.{OfferData, OffersDb} +import fr.acinq.eclair.db.pg.PgUtils.PgLock +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 offers") + statement.executeUpdate("CREATE TABLE offers.managed (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)") + statement.executeUpdate("CREATE INDEX offer_is_active_idx ON offers.managed(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]): Unit = withMetrics("offers/add", DbBackends.Postgres){ + withLock { pg => + using(pg.prepareStatement("INSERT INTO offers.managed (offer_id, offer, path_id, created_at, is_active) VALUES (?, ?, ?, ?, TRUE)")) { statement => + statement.setString(1, offer.offerId.toHex) + statement.setString(2, offer.toString) + pathId_opt match { + case Some(pathId) => statement.setString(3, pathId.toHex) + case None => statement.setNull(3, java.sql.Types.VARCHAR) + } + statement.setTimestamp(4, TimestampMilli.now().toSqlTimestamp) + statement.executeUpdate() + } + } + } + + override def disableOffer(offer: OfferTypes.Offer): Unit = withMetrics("offers/disable", DbBackends.Postgres){ + withLock { pg => + using(pg.prepareStatement("UPDATE offers.managed SET is_active = FALSE WHERE offer_id = ?")) { statement => + statement.setString(1, offer.offerId.toHex) + statement.executeUpdate() + } + } + } + + override def enableOffer(offer: OfferTypes.Offer): Unit = withMetrics("offers/enable", DbBackends.Postgres){ + withLock { pg => + using(pg.prepareStatement("UPDATE offers.managed SET is_active = TRUE WHERE offer_id = ?")) { statement => + statement.setString(1, 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.getBoolean("is_active") + ) + } + + override def listOffers(onlyActive: Boolean): Seq[OfferData] = withMetrics("offers/list", DbBackends.Postgres){ + withLock { pg => + if (onlyActive) { + using(pg.prepareStatement("SELECT * FROM offers.managed WHERE is_active = TRUE")) { statement => + statement.executeQuery().map(parseOfferData).toSeq + } + } else { + using(pg.prepareStatement("SELECT * FROM offers.managed")) { statement => + statement.executeQuery().map(parseOfferData).toSeq + } + } + } + } +} 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..e252881d60 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteOffersDb.scala @@ -0,0 +1,99 @@ +/* + * 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 managed_offers (offer_id BLOB NOT NULL PRIMARY KEY, offer TEXT NOT NULL, path_id BLOB, created_at INTEGER NOT NULL, is_active INTEGER NOT NULL)") + statement.executeUpdate("CREATE INDEX offer_is_active_idx ON managed_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]): Unit = withMetrics("offers/add", DbBackends.Sqlite) { + using(sqlite.prepareStatement("INSERT INTO managed_offers (offer_id, offer, path_id, created_at, is_active) VALUES (?, ?, ?, ?, TRUE)")) { statement => + statement.setBytes(1, offer.offerId.toArray) + statement.setString(2, offer.toString) + pathId_opt match { + case Some(pathId) => statement.setBytes(3, pathId.toArray) + case None => statement.setNull(3, java.sql.Types.VARBINARY) + } + statement.setLong(4, TimestampMilli.now().toLong) + statement.executeUpdate() + } + } + + override def disableOffer(offer: OfferTypes.Offer): Unit = withMetrics("offers/disable", DbBackends.Sqlite) { + using(sqlite.prepareStatement("UPDATE managed_offers SET is_active = FALSE WHERE offer_id = ?")) { statement => + statement.setBytes(1, offer.offerId.toArray) + statement.executeUpdate() + } + } + + override def enableOffer(offer: OfferTypes.Offer): Unit = withMetrics("offers/enable", DbBackends.Sqlite) { + using(sqlite.prepareStatement("UPDATE managed_offers SET is_active = TRUE WHERE offer_id = ?")) { statement => + statement.setBytes(1, 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.getBoolean("is_active") + ) + } + + override def listOffers(onlyActive: Boolean): Seq[OfferData] = withMetrics("offers/list", DbBackends.Sqlite) { + if (onlyActive) { + using(sqlite.prepareStatement("SELECT * FROM managed_offers WHERE is_active = TRUE")) { statement => + statement.executeQuery().map(parseOfferData).toSeq + } + } else { + using(sqlite.prepareStatement("SELECT * FROM managed_offers")) { statement => + statement.executeQuery().map(parseOfferData).toSeq + } + } + } +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultHandler.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultHandler.scala new file mode 100644 index 0000000000..d4bf390016 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultHandler.scala @@ -0,0 +1,79 @@ +/* + * 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.Behaviors +import akka.actor.{ActorRef, typed} +import fr.acinq.eclair.crypto.Sphinx.RouteBlinding.BlindedRoute +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.{CltvExpiryDelta, EncodedNodeId, MilliSatoshi, MilliSatoshiLong, NodeParams} + +object DefaultHandler { + def apply(nodeParams: NodeParams, router: ActorRef): Behavior[OfferManager.HandlerCommand] = { + Behaviors.setup(context => + Behaviors.receiveMessage { + case OfferManager.HandleInvoiceRequest(replyTo, invoiceRequest) => + val amount = invoiceRequest.amount.getOrElse(10_000_000 msat) + invoiceRequest.offer.contactInfos.head match { + case OfferTypes.RecipientNodeId(_) => + val route = InvoiceRequestActor.Route(Nil, nodeParams.channelConf.maxExpiryDelta) + replyTo ! InvoiceRequestActor.ApproveRequest(amount, Seq(route)) + case OfferTypes.BlindedPath(BlindedRoute(firstNodeId: EncodedNodeId.WithPublicKey, _, _)) if firstNodeId.publicKey == nodeParams.nodeId => + replyTo ! InvoiceRequestActor.ApproveRequest(amount, makeRoutes(nodeParams, Seq(Nil))) + case OfferTypes.BlindedPath(BlindedRoute(firstNodeId: EncodedNodeId.WithPublicKey, _, _)) => + val baseParams = nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams + val routeParams = baseParams.copy(boundaries = baseParams.boundaries.copy(maxRouteLength = nodeParams.offersConfig.paymentPathLength, maxCltv = nodeParams.offersConfig.paymentPathCltvExpiryDelta)) + router ! BlindedRouteRequest(context.spawnAnonymous(waitForRoute(nodeParams, replyTo, invoiceRequest.offer, amount)), firstNodeId.publicKey, nodeParams.nodeId, amount, routeParams, pathsToFind = 2) + case OfferTypes.BlindedPath(BlindedRoute(_: 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 + } + ) + } + + def waitForRoute(nodeParams: NodeParams, replyTo: typed.ActorRef[InvoiceRequestActor.Command], offer: OfferTypes.Offer, amount: MilliSatoshi): Behavior[Router.PaymentRouteResponse] = { + Behaviors.receive { + case (_, Router.RouteResponse(routes)) => + replyTo ! InvoiceRequestActor.ApproveRequest(amount, makeRoutes(nodeParams, routes.map(_.hops))) + Behaviors.stopped + case (context, Router.PaymentRouteNotFound(error)) => + context.log.error("Couldn't find blinded route for creating invoice offer={} amount={} : {}", offer, amount, error.getMessage) + replyTo ! InvoiceRequestActor.RejectRequest("internal error") + Behaviors.stopped + } + } + + def makeRoutes(nodeParams: NodeParams, routes: Seq[Seq[Router.ChannelHop]]): Seq[InvoiceRequestActor.Route] = { + (0 until nodeParams.offersConfig.paymentPathCount).map(i => { + val hops = routes(i % routes.length) + val dummyHops = Seq.fill(nodeParams.offersConfig.paymentPathLength - hops.length)(ChannelHop.dummy(nodeParams.nodeId, 0 msat, 0, CltvExpiryDelta(0))) + InvoiceRequestActor.Route(hops ++ dummyHops, nodeParams.channelConf.maxExpiryDelta, feeOverride = Some(RelayFees.zero), cltvOverride = Some(nodeParams.offersConfig.paymentPathCltvExpiryDelta)) + }) + } +} + +case class OffersConfig(messagePathMinLength: Int, paymentPathCount: Int, paymentPathLength: Int, paymentPathCltvExpiryDelta: CltvExpiryDelta) 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..cd98b31545 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferCreator.scala @@ -0,0 +1,105 @@ +/* + * 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.{ActorRef, typed} +import akka.actor.typed.Behavior +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +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.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} + +object OfferCreator { + sealed trait Command + + case class Create(replyTo: typed.ActorRef[Either[String, Offer]], + description_opt: Option[String], + amount_opt: Option[MilliSatoshi], + expiry_opt: Option[TimestampSecond], + issuer_opt: Option[String], + firstNodeId_opt: Option[PublicKey]) extends Command + + case class RouteResponseWrapper(response: Router.MessageRouteResponse) extends Command + + def apply(nodeParams: NodeParams, router: ActorRef, offerManager: typed.ActorRef[OfferManager.Command], defaultOfferHandler: typed.ActorRef[OfferManager.HandlerCommand]): Behavior[Command] = + Behaviors.receivePartial { + case (context, Create(replyTo, description_opt, amount_opt, expiry_opt, issuer_opt, firstNodeId_opt)) => + new OfferCreator(context, replyTo, nodeParams, router, offerManager, defaultOfferHandler).init(description_opt, amount_opt, expiry_opt, issuer_opt, firstNodeId_opt) + } +} + +private class OfferCreator(context: ActorContext[OfferCreator.Command], + replyTo: typed.ActorRef[Either[String, Offer]], + nodeParams: NodeParams, router: ActorRef, + offerManager: typed.ActorRef[OfferManager.Command], + defaultOfferHandler: typed.ActorRef[OfferManager.HandlerCommand]) { + + import OfferCreator._ + + private def init(description_opt: Option[String], + amount_opt: Option[MilliSatoshi], + expiry_opt: Option[TimestampSecond], + issuer_opt: Option[String], + firstNodeId_opt: Option[PublicKey]): Behavior[Command] = { + if (amount_opt.nonEmpty && description_opt.isEmpty) { + replyTo ! Left("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 + firstNodeId_opt match { + case Some(firstNodeId) => + router ! Router.MessageRouteRequest(context.messageAdapter(RouteResponseWrapper(_)), firstNodeId, nodeParams.nodeId, Set.empty) + waitForRoute(firstNodeId, tlvs) + case None => + 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 RouteResponseWrapper(Router.MessageRoute(intermediateNodes, _)) => + val pathId = randomBytes32() + 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 RouteResponseWrapper(Router.MessageRouteNotFound(_)) => + replyTo ! Left("No route found") + Behaviors.stopped + } + } + + private def registerOffer(offer: Offer, nodeKey: Option[PrivateKey], pathId_opt: Option[ByteVector32]): Behavior[Command] = { + nodeParams.db.managedOffers.addOffer(offer, pathId_opt) + offerManager ! OfferManager.RegisterOffer(offer, nodeKey, pathId_opt, defaultOfferHandler) + replyTo ! Right(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..314b225469 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 @@ -84,12 +84,11 @@ 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]) @@ -125,7 +124,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}") } @@ -162,19 +161,14 @@ object OfferManager { /** * @param recipientPaysFees If true, fees for the blinded route will be hidden to the payer and paid by the recipient. */ - 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: Option[RelayFees] = None, cltvOverride: 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.getOrElse(RelayFees(aggregatedPaymentInfo.feeBase, aggregatedPaymentInfo.feeProportionalMillionths)) + val cltvExpiryDelta = cltvOverride.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/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..e5e2d47fd6 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 @@ -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/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..a1a111d00f 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 managedOffers: OffersDb = db.managedOffers 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..ca14979871 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/OffersDbSpec.scala @@ -0,0 +1,70 @@ +/* + * 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.TestDatabases.{TestPgDatabases, TestSqliteDatabases} +import fr.acinq.eclair._ +import fr.acinq.eclair.db.pg.PgOffersDb +import fr.acinq.eclair.db.sqlite.SqliteOffersDb +import fr.acinq.eclair.wire.protocol.OfferTypes.Offer +import org.scalatest.funsuite.AnyFunSuite + +class OffersDbSpec extends AnyFunSuite { + + import fr.acinq.eclair.TestDatabases.forAllDbs + + test("init database two times in a row") { + forAllDbs { + case sqlite: TestSqliteDatabases => + new SqliteOffersDb(sqlite.connection) + new SqliteOffersDb(sqlite.connection) + case pg: TestPgDatabases => + new PgOffersDb()(pg.datasource, pg.lock) + new PgOffersDb()(pg.datasource, pg.lock) + } + } + + test("add/disable/enable/list offers") { + forAllDbs { dbs => + val db = dbs.managedOffers + + assert(db.listOffers(onlyActive = false).isEmpty) + val offer1 = Offer(None, Some("test 1"), randomKey().publicKey, Features(), Block.LivenetGenesisBlock.hash) + db.addOffer(offer1, None) + val listed1 = db.listOffers(onlyActive = true) + assert(listed1.length == 1) + assert(listed1.head.offer == offer1) + assert(listed1.head.pathId_opt == None) + assert(listed1.head.isActive) + val offer2 = Offer(None, Some("test 2"), randomKey().publicKey, Features(), Block.LivenetGenesisBlock.hash) + val pathId = randomBytes32() + db.addOffer(offer2, Some(pathId)) + assert(db.listOffers(onlyActive = true).length == 2) + db.disableOffer(offer1) + assert(db.listOffers(onlyActive = false).length == 2) + val listed2 = db.listOffers(onlyActive = true) + assert(listed2.length == 1) + assert(listed2.head.offer == offer2) + assert(listed2.head.pathId_opt == Some(pathId)) + assert(listed2.head.isActive) + db.disableOffer(offer2) + db.enableOffer(offer1) + assert(db.listOffers(onlyActive = true) == listed1) + } + } +} 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..3351257a51 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.{DefaultHandler, 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,12 +54,15 @@ 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, bitcoinClient: TestBitcoinCoreClient) { val nodeId = nodeParams.nodeId val routeParams = nodeParams.routerConf.pathFindingExperimentConf.experiments.values.head.getDefaultRouteParams + + val eclairImpl = new EclairImpl(Kit(nodeParams, system, watcher.ref.toTyped, paymentHandler, register, relayer, router, switchboard, paymentInitiator, TestProbe()(system).ref, TestProbe()(system).ref.toTyped, TestProbe()(system).ref.toTyped, postman, offerManager, defaultOfferHandler, wallet)) } object MinimalNodeFixture extends Assertions with Eventually with IntegrationPatience with EitherValues { @@ -94,6 +97,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(DefaultHandler(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 +126,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..5b4583b709 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} @@ -34,12 +35,13 @@ import fr.acinq.eclair.message.OnionMessages.{IntermediateNode, Recipient, build import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.offer.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 { @@ -231,7 +234,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 routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) @@ -247,7 +250,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 routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = true, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta, feeOverride = Some(RelayFees.zero))) val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) @@ -264,8 +267,8 @@ 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 payment = verifyPaymentSuccess(offer, amount, result) @@ -283,8 +286,8 @@ 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 = Some(RelayFees.zero)), + InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta, feeOverride = Some(RelayFees.zero)), ) val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes, maxAttempts = 3) val payment = verifyPaymentSuccess(offer, amount, result) @@ -300,7 +303,7 @@ 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 => { @@ -332,7 +335,7 @@ 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 payment = verifyPaymentSuccess(offer, amount1, result) @@ -348,8 +351,8 @@ 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 payment = verifyPaymentSuccess(offer, amount, result) @@ -366,8 +369,8 @@ 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 = 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 = Some(RelayFees.zero)), ) val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) @@ -384,7 +387,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 routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) val (offer, result) = sendPrivateOfferPayment(f, alice, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.forall(_.feesPaid > 0.msat)) @@ -399,7 +402,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 routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = true, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta, feeOverride = Some(RelayFees.zero))) val (offer, result) = sendPrivateOfferPayment(f, alice, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.forall(_.feesPaid == 0.msat)) @@ -409,7 +412,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { import f._ val amount = 75_000_000 msat - val routes = Seq(InvoiceRequestActor.Route(Nil, recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(Nil, maxFinalExpiryDelta)) val (offer, result) = sendOfferPayment(f, alice, bob, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) @@ -419,7 +422,7 @@ 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 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(f, alice, bob, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) @@ -430,7 +433,7 @@ 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 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 = Some(RelayFees.zero))) val (offer, result) = sendOfferPayment(f, alice, bob, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) @@ -446,7 +449,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 routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) val (offer, result) = sendOfferPayment(f, bob, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) @@ -464,7 +467,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) @@ -489,7 +492,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 routes = Seq(InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(carol.nodeId, 25 msat, 250, CltvExpiryDelta(75)), recipientPaysFees = false, maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(carol.nodeId, 25 msat, 250, CltvExpiryDelta(75)), maxFinalExpiryDelta)) val (offer, result) = sendOfferPayment(f, bob, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) @@ -515,7 +518,7 @@ 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 payment = verifyPaymentSuccess(offer, amount1, result) @@ -531,7 +534,7 @@ 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 payment = verifyPaymentSuccess(offer, amount2, result) @@ -560,7 +563,7 @@ 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 payment = verifyPaymentSuccess(offer, amount, result) @@ -588,7 +591,7 @@ 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 payment = verifyPaymentSuccess(offer, amount, result) @@ -618,7 +621,7 @@ 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 routes = Seq(InvoiceRequestActor.Route(route.hops, maxFinalExpiryDelta)) val (_, result) = sendOfferPayment(f, alice, carol, 75_000_000 msat, routes) verifyBlindedFailure(result, bob.nodeId) } @@ -630,7 +633,7 @@ 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 routes = Seq(InvoiceRequestActor.Route(route.hops, CltvExpiryDelta(-500))) val (_, result) = sendOfferPayment(f, alice, carol, 25_000_000 msat, routes) verifyBlindedFailure(result, bob.nodeId) } @@ -645,7 +648,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 +659,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 +675,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 +696,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 +721,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 +743,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 +752,76 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) } + + test("basic offer") { f => + import f._ + + implicit val timeout: Timeout = 10 seconds + + val amount = 20_000_000 msat + val offer = Await.result(carol.eclairImpl.createOffer(description_opt = Some("test offer"), amount_opt = Some(amount), expiry_opt = None, issuer_opt = None, firstNodeId_opt = None), 10 seconds) + + assert(offer.nodeId == Some(carol.nodeId)) + assert(offer.description == Some("test offer")) + assert(offer.amount == Some(amount)) + + val payment = Await.result(alice.eclairImpl.payOfferBlocking(offer, amount, 1), 10 seconds) + assert(payment.isInstanceOf[PaymentSent]) + assert(payment.asInstanceOf[PaymentSent].feesPaid > 0.msat) + } + + test("offer without node id (dummy hops only)") { f => + import f._ + + implicit val timeout: Timeout = 10 seconds + + val amount = 20_000_000 msat + val offer = Await.result(carol.eclairImpl.createOffer(description_opt = Some("test offer"), amount_opt = Some(amount), expiry_opt = None, issuer_opt = None, firstNodeId_opt = Some(carol.nodeId)), 10 seconds) + + 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 = Await.result(alice.eclairImpl.payOfferBlocking(offer, amount, 1), 10 seconds) + assert(payment.isInstanceOf[PaymentSent]) + assert(payment.asInstanceOf[PaymentSent].feesPaid > 0.msat) + } + + test("offer without node id (real and dummy blinded hops)") { f => + import f._ + + implicit val timeout: Timeout = 10 seconds + + val amount = 20_000_000 msat + val offer = Await.result(carol.eclairImpl.createOffer(description_opt = Some("test offer"), amount_opt = Some(amount), expiry_opt = None, issuer_opt = None, firstNodeId_opt = Some(bob.nodeId)), 10 seconds) + + 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 = Await.result(alice.eclairImpl.payOfferBlocking(offer, amount, 1), 10 seconds) + assert(payment.isInstanceOf[PaymentSent]) + payment.asInstanceOf[PaymentSent].parts.foreach(println) + assert(payment.asInstanceOf[PaymentSent].feesPaid == 0.msat) + } + + test("offer without node id (payer is first node of blinded path)") { f => + import f._ + + implicit val timeout: Timeout = 10 seconds + + val amount = 20_000_000 msat + val offer = Await.result(carol.eclairImpl.createOffer(description_opt = Some("test offer"), amount_opt = Some(amount), expiry_opt = None, issuer_opt = None, firstNodeId_opt = Some(alice.nodeId)), 10 seconds) + + 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 = Await.result(alice.eclairImpl.payOfferBlocking(offer, amount, 1), 10 seconds) + assert(payment.isInstanceOf[PaymentSent]) + assert(payment.asInstanceOf[PaymentSent].feesPaid < 0.msat) + } } 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..c8c42636f4 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,7 @@ 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) + handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, Seq(InvoiceRequestActor.Route(hops, CltvExpiryDelta(1000), feeOverride = if (hideFees) Some(RelayFees.zero) else None)), 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 +127,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 +299,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 +325,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)