Skip to content

Commit 665ad0a

Browse files
committed
Basic offer management without plugins
1 parent 2d94d22 commit 665ad0a

File tree

16 files changed

+449
-25
lines changed

16 files changed

+449
-25
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,17 @@ import fr.acinq.eclair.db.AuditDb.{NetworkFee, Stats}
3838
import fr.acinq.eclair.db.{IncomingPayment, OutgoingPayment, OutgoingPaymentStatus}
3939
import fr.acinq.eclair.io.Peer.{GetPeerInfo, OpenChannelResponse, PeerInfo}
4040
import fr.acinq.eclair.io._
41+
import fr.acinq.eclair.message.OnionMessages.{IntermediateNode, Recipient}
4142
import fr.acinq.eclair.message.{OnionMessages, Postman}
4243
import fr.acinq.eclair.payment._
44+
import fr.acinq.eclair.payment.offer.{OfferCreator, OfferManager}
4345
import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceiveStandardPayment
4446
import fr.acinq.eclair.payment.relay.Relayer.{ChannelBalance, GetOutgoingChannels, OutgoingChannels, RelayFees}
4547
import fr.acinq.eclair.payment.send.PaymentInitiator._
4648
import fr.acinq.eclair.payment.send.{ClearRecipient, OfferPayment, PaymentIdentifier}
4749
import fr.acinq.eclair.router.Router
4850
import fr.acinq.eclair.router.Router._
49-
import fr.acinq.eclair.wire.protocol.OfferTypes.Offer
51+
import fr.acinq.eclair.wire.protocol.OfferTypes.{Offer, OfferAbsoluteExpiry, OfferIssuer, OfferQuantityMax, OfferTlv}
5052
import fr.acinq.eclair.wire.protocol._
5153
import grizzled.slf4j.Logging
5254
import scodec.bits.ByteVector
@@ -120,6 +122,12 @@ trait Eclair {
120122

121123
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]
122124

125+
def createOffer(description_opt: Option[String], amount_opt: Option[MilliSatoshi], expiry_opt: Option[TimestampSecond], issuer_opt: Option[String], firstNodeId_opt: Option[PublicKey], hideNodeId: Boolean)(implicit timeout: Timeout): Future[Offer]
126+
127+
def disableOffer(offer: Offer)(implicit timeout: Timeout): Unit
128+
129+
def listOffers(onlyActive: Boolean = true)(implicit timeout: Timeout): Future[Seq[Offer]]
130+
123131
def newAddress(): Future[String]
124132

125133
def receivedInfo(paymentHash: ByteVector32)(implicit timeout: Timeout): Future[Option[IncomingPayment]]
@@ -370,6 +378,24 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
370378
}
371379
}
372380

381+
override def createOffer(description_opt: Option[String], amount_opt: Option[MilliSatoshi], expiry_opt: Option[TimestampSecond], issuer_opt: Option[String], firstNodeId_opt: Option[PublicKey], hideNodeId: Boolean)(implicit timeout: Timeout): Future[Offer] = {
382+
val offerCreator = appKit.system.spawnAnonymous(OfferCreator(appKit.nodeParams, appKit.router, appKit.offerManager, appKit.defaultOfferHandler))
383+
offerCreator.ask[Either[String, Offer]](replyTo => OfferCreator.Create(replyTo, description_opt, amount_opt, expiry_opt, issuer_opt, firstNodeId_opt, hideNodeId))
384+
.flatMap {
385+
case Left(errorMessage) => Future.failed(new Exception(errorMessage))
386+
case Right(offer) => Future.successful(offer)
387+
}
388+
}
389+
390+
override def disableOffer(offer: Offer)(implicit timeout: Timeout): Unit = {
391+
appKit.offerManager ! OfferManager.DisableOffer(offer)
392+
appKit.nodeParams.db.managedOffers.disableOffer(offer)
393+
}
394+
395+
override def listOffers(onlyActive: Boolean = true)(implicit timeout: Timeout): Future[Seq[Offer]] = Future {
396+
appKit.nodeParams.db.managedOffers.listOffers(onlyActive).map(_.offer)
397+
}
398+
373399
override def newAddress(): Future[String] = {
374400
appKit.wallet match {
375401
case w: BitcoinCoreClient => w.getReceiveAddress()

eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import fr.acinq.eclair.db.FileBackupHandler.FileBackupParams
4040
import fr.acinq.eclair.db.{Databases, DbEventHandler, FileBackupHandler, PeerStorageCleaner}
4141
import fr.acinq.eclair.io._
4242
import fr.acinq.eclair.message.Postman
43-
import fr.acinq.eclair.payment.offer.OfferManager
43+
import fr.acinq.eclair.payment.offer.{DefaultHandler, OfferManager}
4444
import fr.acinq.eclair.payment.receive.PaymentHandler
4545
import fr.acinq.eclair.payment.relay.{AsyncPaymentTriggerer, PostRestartHtlcCleaner, Relayer}
4646
import fr.acinq.eclair.payment.send.{Autoprobe, PaymentInitiator}
@@ -358,6 +358,8 @@ class Setup(val datadir: File,
358358
dbEventHandler = system.actorOf(SimpleSupervisor.props(DbEventHandler.props(nodeParams), "db-event-handler", SupervisorStrategy.Resume))
359359
register = system.actorOf(SimpleSupervisor.props(Register.props(), "register", SupervisorStrategy.Resume))
360360
offerManager = system.spawn(Behaviors.supervise(OfferManager(nodeParams, paymentTimeout = 1 minute)).onFailure(typed.SupervisorStrategy.resume), name = "offer-manager")
361+
defaultOfferHandler = system.spawn(Behaviors.supervise(DefaultHandler(nodeParams, router)).onFailure(typed.SupervisorStrategy.resume), name = "default-offer-handler")
362+
_ = for (offer <- nodeParams.db.managedOffers.listOffers(onlyActive = true)) offerManager ! OfferManager.RegisterOffer(offer.offer, None, offer.pathId_opt, defaultOfferHandler)
361363
paymentHandler = system.actorOf(SimpleSupervisor.props(PaymentHandler.props(nodeParams, register, offerManager), "payment-handler", SupervisorStrategy.Resume))
362364
triggerer = system.spawn(Behaviors.supervise(AsyncPaymentTriggerer()).onFailure(typed.SupervisorStrategy.resume), name = "async-payment-triggerer")
363365
peerReadyManager = system.spawn(Behaviors.supervise(PeerReadyManager()).onFailure(typed.SupervisorStrategy.restart), name = "peer-ready-manager")
@@ -399,6 +401,7 @@ class Setup(val datadir: File,
399401
balanceActor = balanceActor,
400402
postman = postman,
401403
offerManager = offerManager,
404+
defaultOfferHandler = defaultOfferHandler,
402405
wallet = bitcoinClient)
403406

404407
zmqBlockTimeout = after(5 seconds, using = system.scheduler)(Future.failed(BitcoinZMQConnectionTimeoutException))
@@ -468,6 +471,7 @@ case class Kit(nodeParams: NodeParams,
468471
balanceActor: typed.ActorRef[BalanceActor.Command],
469472
postman: typed.ActorRef[Postman.Command],
470473
offerManager: typed.ActorRef[OfferManager.Command],
474+
defaultOfferHandler: typed.ActorRef[OfferManager.HandlerCommand],
471475
wallet: OnChainWallet with OnchainPubkeyCache)
472476

473477
object Kit {

eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ trait Databases {
4343
def channels: ChannelsDb
4444
def peers: PeersDb
4545
def payments: PaymentsDb
46+
def managedOffers: OffersDb
4647
def pendingCommands: PendingCommandsDb
4748
def liquidity: LiquidityDb
4849
//@formatter:on
@@ -66,6 +67,7 @@ object Databases extends Logging {
6667
channels: SqliteChannelsDb,
6768
peers: SqlitePeersDb,
6869
payments: SqlitePaymentsDb,
70+
managedOffers: SqliteOffersDb,
6971
pendingCommands: SqlitePendingCommandsDb,
7072
private val backupConnection: Connection) extends Databases with FileBackup {
7173
override def backup(backupFile: File): Unit = SqliteUtils.using(backupConnection.createStatement()) {
@@ -85,6 +87,7 @@ object Databases extends Logging {
8587
channels = new SqliteChannelsDb(eclairJdbc),
8688
peers = new SqlitePeersDb(eclairJdbc),
8789
payments = new SqlitePaymentsDb(eclairJdbc),
90+
managedOffers = new SqliteOffersDb(eclairJdbc),
8891
pendingCommands = new SqlitePendingCommandsDb(eclairJdbc),
8992
backupConnection = eclairJdbc
9093
)
@@ -97,6 +100,7 @@ object Databases extends Logging {
97100
channels: PgChannelsDb,
98101
peers: PgPeersDb,
99102
payments: PgPaymentsDb,
103+
managedOffers: PgOffersDb,
100104
pendingCommands: PgPendingCommandsDb,
101105
dataSource: HikariDataSource,
102106
lock: PgLock) extends Databases with ExclusiveLock {
@@ -157,6 +161,7 @@ object Databases extends Logging {
157161
channels = new PgChannelsDb,
158162
peers = new PgPeersDb,
159163
payments = new PgPaymentsDb,
164+
managedOffers = new PgOffersDb,
160165
pendingCommands = new PgPendingCommandsDb,
161166
dataSource = ds,
162167
lock = lock)

eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import fr.acinq.eclair.payment._
1212
import fr.acinq.eclair.payment.relay.OnTheFlyFunding
1313
import fr.acinq.eclair.payment.relay.Relayer.RelayFees
1414
import fr.acinq.eclair.router.Router
15-
import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelUpdate, NodeAddress, NodeAnnouncement}
15+
import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelUpdate, NodeAddress, NodeAnnouncement, OfferTypes}
1616
import fr.acinq.eclair.{CltvExpiry, MilliSatoshi, Paginated, RealShortChannelId, ShortChannelId, TimestampMilli, TimestampSecond}
1717
import grizzled.slf4j.Logging
1818
import scodec.bits.ByteVector
@@ -36,6 +36,7 @@ case class DualDatabases(primary: Databases, secondary: Databases) extends Datab
3636
override val channels: ChannelsDb = DualChannelsDb(primary.channels, secondary.channels)
3737
override val peers: PeersDb = DualPeersDb(primary.peers, secondary.peers)
3838
override val payments: PaymentsDb = DualPaymentsDb(primary.payments, secondary.payments)
39+
override val managedOffers: OffersDb = DualOffersDb(primary.managedOffers, secondary.managedOffers)
3940
override val pendingCommands: PendingCommandsDb = DualPendingCommandsDb(primary.pendingCommands, secondary.pendingCommands)
4041
override val liquidity: LiquidityDb = DualLiquidityDb(primary.liquidity, secondary.liquidity)
4142

@@ -400,6 +401,26 @@ case class DualPaymentsDb(primary: PaymentsDb, secondary: PaymentsDb) extends Pa
400401
}
401402
}
402403

404+
case class DualOffersDb(primary: OffersDb, secondary: OffersDb) extends OffersDb {
405+
406+
private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-offers").build()))
407+
408+
override def addOffer(offer: OfferTypes.Offer, pathId_opt: Option[ByteVector32]): Unit = {
409+
runAsync(secondary.addOffer(offer, pathId_opt))
410+
primary.addOffer(offer, pathId_opt)
411+
}
412+
413+
override def disableOffer(offer: OfferTypes.Offer): Unit = {
414+
runAsync(secondary.disableOffer(offer))
415+
primary.disableOffer(offer)
416+
}
417+
418+
override def listOffers(onlyActive: Boolean): Seq[OfferData] = {
419+
runAsync(secondary.listOffers(onlyActive))
420+
primary.listOffers(onlyActive)
421+
}
422+
}
423+
403424
case class DualPendingCommandsDb(primary: PendingCommandsDb, secondary: PendingCommandsDb) extends PendingCommandsDb {
404425

405426
private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-pending-commands").build()))
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright 2024 ACINQ SAS
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package fr.acinq.eclair.db
18+
19+
import fr.acinq.bitcoin.scalacompat.ByteVector32
20+
import fr.acinq.eclair.wire.protocol.OfferTypes.Offer
21+
22+
case class OfferData(offer: Offer, pathId_opt: Option[ByteVector32])
23+
24+
/**
25+
* Database for offers fully managed by eclair, as opposed to offers managed by a plugin.
26+
*/
27+
trait OffersDb {
28+
/**
29+
* Add an offer managed by eclair.
30+
* @param pathId_opt If the offer uses a blinded path, this is the corresponding pathId.
31+
*/
32+
def addOffer(offer: Offer, pathId_opt: Option[ByteVector32]): Unit
33+
34+
/**
35+
* Disable an offer. The offer is still stored but new invoice requests and new payment attempts for already emitted
36+
* invoices will be rejected. To reenable an offer, use `addOffer`.
37+
*/
38+
def disableOffer(offer: Offer): Unit
39+
40+
/**
41+
* List offers managed by eclair.
42+
* @param onlyActive Whether to return only active offers or also disabled ones.
43+
*/
44+
def listOffers(onlyActive: Boolean): Seq[OfferData]
45+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright 2024 ACINQ SAS
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package fr.acinq.eclair.db.pg
18+
19+
import fr.acinq.bitcoin.scalacompat.ByteVector32
20+
import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics
21+
import fr.acinq.eclair.db.Monitoring.Tags.DbBackends
22+
import fr.acinq.eclair.db.{OfferData, OffersDb}
23+
import fr.acinq.eclair.db.pg.PgUtils.PgLock
24+
import fr.acinq.eclair.wire.protocol.OfferTypes
25+
import grizzled.slf4j.Logging
26+
27+
import java.sql.Statement
28+
import javax.sql.DataSource
29+
30+
object PgOffersDb {
31+
val DB_NAME = "offers"
32+
val CURRENT_VERSION = 1
33+
}
34+
35+
class PgOffersDb(implicit ds: DataSource, lock: PgLock) extends OffersDb with Logging {
36+
37+
import PgPaymentsDb._
38+
import PgUtils.ExtendedResultSet._
39+
import PgUtils._
40+
import lock._
41+
42+
inTransaction { pg =>
43+
using(pg.createStatement()) { statement =>
44+
getVersion(statement, DB_NAME) match {
45+
case None =>
46+
statement.executeUpdate("CREATE SCHEMA offers")
47+
48+
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)")
49+
50+
statement.executeUpdate("CREATE INDEX offer_is_active_idx ON offers.managed(is_active)")
51+
case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do
52+
case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion")
53+
}
54+
setVersion(statement, DB_NAME, CURRENT_VERSION)
55+
}
56+
}
57+
58+
override def addOffer(offer: OfferTypes.Offer, pathId_opt: Option[ByteVector32]): Unit = withMetrics("offers/add", DbBackends.Postgres){
59+
withLock { pg =>
60+
using(pg.prepareStatement("INSERT INTO offers.managed (offer_id, offer, path_id, created_at, is_active) VALUES (?, ?, ?, NOW, TRUE)")) { statement =>
61+
statement.setString(1, offer.offerId.toHex)
62+
statement.setString(2, offer.toString)
63+
pathId_opt match {
64+
case Some(pathId) => statement.setString(3, pathId.toHex)
65+
case None => statement.setNull(3, java.sql.Types.VARCHAR)
66+
}
67+
68+
statement.executeUpdate()
69+
}
70+
}
71+
}
72+
73+
override def disableOffer(offer: OfferTypes.Offer): Unit = ???
74+
75+
override def listOffers(onlyActive: Boolean): Seq[OfferData] = ???
76+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2024 ACINQ SAS
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package fr.acinq.eclair.db.sqlite
18+
19+
import fr.acinq.bitcoin.scalacompat.ByteVector32
20+
import fr.acinq.eclair.db.{OfferData, OffersDb}
21+
import fr.acinq.eclair.wire.protocol.OfferTypes
22+
import grizzled.slf4j.Logging
23+
24+
import java.sql.Connection
25+
26+
class SqliteOffersDb(val sqlite: Connection) extends OffersDb with Logging {
27+
28+
override def addOffer(offer: OfferTypes.Offer, pathId_opt: Option[ByteVector32]): Unit = ???
29+
30+
override def disableOffer(offer: OfferTypes.Offer): Unit = ???
31+
32+
override def listOffers(onlyActive: Boolean): Seq[OfferData] = ???
33+
}

0 commit comments

Comments
 (0)