Skip to content

Commit 722c9ff

Browse files
Manage Bolt 12 offers without extra plugin (#2976)
Offers allow a wide range of use-cases and it would be impossible to cover everything in eclair, which is why we have relied on plugins to manage offers. However most users will not need advanced features, and we can handle default offer management that should work for most users. Advanced users can still use a plugin to manage more complex offer flows. Supported use-cases: - accepting donations - selling items without inventory management - compact (non-private) offers using our public node id - private offers where our identity is protected by a blinded path When blinded paths are used, the node operators specifies the introduction node and the number of paths and their length. Eclair will use its graph data to build payment paths based on those parameters and add dummy hops / duplicate paths if necessary.
1 parent e3b3261 commit 722c9ff

32 files changed

+972
-161
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,39 @@
99
With this release, eclair requires using Bitcoin Core 28.1.
1010
Newer versions of Bitcoin Core may be used, but have not been extensively tested.
1111

12+
### Offers
13+
14+
You can now create an offer with
15+
16+
```
17+
./eclair-cli createoffer --description=coffee --amountMsat=20000 --expireInSeconds=3600 --issuer=me@example.com --blindedPathsFirstNodeId=03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f
18+
```
19+
20+
All parameters are optional and omitting all of them will create a minimal offer with your public node id.
21+
You can also disable offers and list offers with
22+
23+
```
24+
./eclair-cli disableoffer --offer=lnoxxx
25+
./eclair-cli listoffers
26+
```
27+
28+
If you specify `--blindedPathsFirstNodeId`, your public node id will not be in the offer, you will instead be hidden behind a blinded path starting at the node that you have chosen.
29+
You can configure the number and length of blinded paths used in `eclair.conf`:
30+
31+
```
32+
offers {
33+
// Minimum length of an offer blinded path
34+
message-path-min-length = 2
35+
36+
// Number of payment paths to put in Bolt12 invoices
37+
payment-path-count = 2
38+
// Length of payment paths to put in Bolt12 invoices
39+
payment-path-length = 4
40+
// Expiry delta of payment paths to put in Bolt12 invoices
41+
payment-path-expiry-delta = 500
42+
}
43+
```
44+
1245
### Simplified mutual close
1346

1447
This release includes support for the latest [mutual close protocol](https://github.com/lightning/bolts/pull/1205).

eclair-core/src/main/resources/reference.conf

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,18 @@ eclair {
636636
// Frequency at which we clean our DB to remove peer storage from nodes with whom we don't have channels anymore.
637637
cleanup-frequency = 1 day
638638
}
639+
640+
offers {
641+
// Minimum length of an offer blinded path when hiding our real node id
642+
message-path-min-length = 2
643+
644+
// Number of payment paths to put in Bolt12 invoices when hiding our real node id
645+
payment-path-count = 2
646+
// Length of payment paths to put in Bolt12 invoices when hiding our real node id
647+
payment-path-length = 4
648+
// Expiry delta of payment paths to put in Bolt12 invoices when hiding our real node id
649+
payment-path-expiry-delta = 500
650+
}
639651
}
640652

641653
akka {

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,17 @@ import fr.acinq.eclair.db.AuditDb.{NetworkFee, Stats}
3939
import fr.acinq.eclair.db.{IncomingPayment, OutgoingPayment, OutgoingPaymentStatus}
4040
import fr.acinq.eclair.io.Peer.{GetPeerInfo, OpenChannelResponse, PeerInfo}
4141
import fr.acinq.eclair.io._
42+
import fr.acinq.eclair.message.OnionMessages.{IntermediateNode, Recipient}
4243
import fr.acinq.eclair.message.{OnionMessages, Postman}
4344
import fr.acinq.eclair.payment._
45+
import fr.acinq.eclair.payment.offer.{OfferCreator, OfferManager}
4446
import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceiveStandardPayment
4547
import fr.acinq.eclair.payment.relay.Relayer.{ChannelBalance, GetOutgoingChannels, OutgoingChannels, RelayFees}
4648
import fr.acinq.eclair.payment.send.PaymentInitiator._
4749
import fr.acinq.eclair.payment.send.{ClearRecipient, OfferPayment, PaymentIdentifier}
4850
import fr.acinq.eclair.router.Router
4951
import fr.acinq.eclair.router.Router._
50-
import fr.acinq.eclair.wire.protocol.OfferTypes.Offer
52+
import fr.acinq.eclair.wire.protocol.OfferTypes.{Offer, OfferAbsoluteExpiry, OfferIssuer, OfferQuantityMax, OfferTlv}
5153
import fr.acinq.eclair.wire.protocol._
5254
import grizzled.slf4j.Logging
5355
import scodec.bits.ByteVector
@@ -126,6 +128,12 @@ trait Eclair {
126128

127129
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]
128130

131+
def createOffer(description_opt: Option[String], amount_opt: Option[MilliSatoshi], expire_opt: Option[Long], issuer_opt: Option[String], blindedPathsFirstNodeId_opt: Option[PublicKey])(implicit timeout: Timeout): Future[Offer]
132+
133+
def disableOffer(offer: Offer)(implicit timeout: Timeout): Future[Unit]
134+
135+
def listOffers(onlyActive: Boolean = true)(implicit timeout: Timeout): Future[Seq[Offer]]
136+
129137
def newAddress(): Future[String]
130138

131139
def receivedInfo(paymentHash: ByteVector32)(implicit timeout: Timeout): Future[Option[IncomingPayment]]
@@ -388,6 +396,24 @@ class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChan
388396
}
389397
}
390398

399+
override def createOffer(description_opt: Option[String], amount_opt: Option[MilliSatoshi], expireInSeconds_opt: Option[Long], issuer_opt: Option[String], blindedPathsFirstNodeId_opt: Option[PublicKey])(implicit timeout: Timeout): Future[Offer] = {
400+
val offerCreator = appKit.system.spawnAnonymous(OfferCreator(appKit.nodeParams, appKit.router, appKit.offerManager, appKit.defaultOfferHandler))
401+
val expiry_opt = expireInSeconds_opt.map(TimestampSecond.now() + _)
402+
offerCreator.ask[OfferCreator.CreateOfferResult](replyTo => OfferCreator.Create(replyTo, description_opt, amount_opt, expiry_opt, issuer_opt, blindedPathsFirstNodeId_opt))
403+
.flatMap {
404+
case OfferCreator.CreateOfferError(reason) => Future.failed(new Exception(reason))
405+
case OfferCreator.CreatedOffer(offer) => Future.successful(offer)
406+
}
407+
}
408+
409+
override def disableOffer(offer: Offer)(implicit timeout: Timeout): Future[Unit] = Future {
410+
appKit.offerManager ! OfferManager.DisableOffer(offer)
411+
}
412+
413+
override def listOffers(onlyActive: Boolean = true)(implicit timeout: Timeout): Future[Seq[Offer]] = Future {
414+
appKit.nodeParams.db.offers.listOffers(onlyActive).map(_.offer)
415+
}
416+
391417
override def newAddress(): Future[String] = {
392418
appKit.wallet match {
393419
case w: BitcoinCoreClient => w.getReceiveAddress()

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ object Logs {
4949
parentPaymentId_opt: Option[UUID] = None,
5050
paymentId_opt: Option[UUID] = None,
5151
paymentHash_opt: Option[ByteVector32] = None,
52+
offerId_opt: Option[ByteVector32] = None,
5253
txPublishId_opt: Option[UUID] = None,
5354
messageId_opt: Option[ByteVector32] = None,
5455
nodeAlias_opt: Option[String] = None): Map[String, String] =
@@ -60,6 +61,7 @@ object Logs {
6061
parentPaymentId_opt.map(p => "parentPaymentId" -> s" p:$p"),
6162
paymentId_opt.map(i => "paymentId" -> s" i:$i"),
6263
paymentHash_opt.map(h => "paymentHash" -> s" h:$h"),
64+
offerId_opt.map(o => "offerId" -> s" o:$o"),
6365
txPublishId_opt.map(t => "txPublishId" -> s" t:$t"),
6466
messageId_opt.map(m => "messageId" -> s" m:$m"),
6567
nodeAlias_opt.map(a => "nodeAlias" -> s" a:$a"),

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import fr.acinq.eclair.db._
3030
import fr.acinq.eclair.io.MessageRelay.{RelayAll, RelayChannelsOnly, RelayPolicy}
3131
import fr.acinq.eclair.io.{PeerConnection, PeerReadyNotifier}
3232
import fr.acinq.eclair.message.OnionMessages.OnionMessageConfig
33+
import fr.acinq.eclair.payment.offer.OffersConfig
3334
import fr.acinq.eclair.payment.relay.OnTheFlyFunding
3435
import fr.acinq.eclair.payment.relay.Relayer.{AsyncPaymentsParams, RelayFees, RelayParams}
3536
import fr.acinq.eclair.router.Announcements.AddressException
@@ -92,7 +93,8 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
9293
liquidityAdsConfig: LiquidityAds.Config,
9394
peerWakeUpConfig: PeerReadyNotifier.WakeUpConfig,
9495
onTheFlyFundingConfig: OnTheFlyFunding.Config,
95-
peerStorageConfig: PeerStorageConfig) {
96+
peerStorageConfig: PeerStorageConfig,
97+
offersConfig: OffersConfig) {
9698
val privateKey: Crypto.PrivateKey = nodeKeyManager.nodeKey.privateKey
9799

98100
val nodeId: PublicKey = nodeKeyManager.nodeId
@@ -705,6 +707,12 @@ object NodeParams extends Logging {
705707
writeDelay = FiniteDuration(config.getDuration("peer-storage.write-delay").getSeconds, TimeUnit.SECONDS),
706708
removalDelay = FiniteDuration(config.getDuration("peer-storage.removal-delay").getSeconds, TimeUnit.SECONDS),
707709
cleanUpFrequency = FiniteDuration(config.getDuration("peer-storage.cleanup-frequency").getSeconds, TimeUnit.SECONDS),
710+
),
711+
offersConfig = OffersConfig(
712+
messagePathMinLength = config.getInt("offers.message-path-min-length"),
713+
paymentPathCount = config.getInt("offers.payment-path-count"),
714+
paymentPathLength = config.getInt("offers.payment-path-length"),
715+
paymentPathCltvExpiryDelta = CltvExpiryDelta(config.getInt("offers.payment-path-expiry-delta")),
708716
)
709717
)
710718
}

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.{DefaultOfferHandler, 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(DefaultOfferHandler(nodeParams, router)).onFailure(typed.SupervisorStrategy.resume), name = "default-offer-handler")
362+
_ = for (offer <- nodeParams.db.offers.listOffers(onlyActive = true)) offerManager ! OfferManager.RegisterOffer(offer.offer, if (offer.pathId_opt.isEmpty) Some(nodeParams.privateKey) else None, offer.pathId_opt, defaultOfferHandler)
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 offers: 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+
offers: 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+
offers = 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+
offers: 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+
offers = 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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 offers: OffersDb = DualOffersDb(primary.offers, secondary.offers)
3940
override val pendingCommands: PendingCommandsDb = DualPendingCommandsDb(primary.pendingCommands, secondary.pendingCommands)
4041
override val liquidity: LiquidityDb = DualLiquidityDb(primary.liquidity, secondary.liquidity)
4142

@@ -405,6 +406,26 @@ case class DualPaymentsDb(primary: PaymentsDb, secondary: PaymentsDb) extends Pa
405406
}
406407
}
407408

409+
case class DualOffersDb(primary: OffersDb, secondary: OffersDb) extends OffersDb {
410+
411+
private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-offers").build()))
412+
413+
override def addOffer(offer: OfferTypes.Offer, pathId_opt: Option[ByteVector32], createdAt: TimestampMilli = TimestampMilli.now()): Unit = {
414+
runAsync(secondary.addOffer(offer, pathId_opt, createdAt))
415+
primary.addOffer(offer, pathId_opt, createdAt)
416+
}
417+
418+
override def disableOffer(offer: OfferTypes.Offer, disabledAt: TimestampMilli = TimestampMilli.now()): Unit = {
419+
runAsync(secondary.disableOffer(offer, disabledAt))
420+
primary.disableOffer(offer, disabledAt)
421+
}
422+
423+
override def listOffers(onlyActive: Boolean): Seq[OfferData] = {
424+
runAsync(secondary.listOffers(onlyActive))
425+
primary.listOffers(onlyActive)
426+
}
427+
}
428+
408429
case class DualPendingCommandsDb(primary: PendingCommandsDb, secondary: PendingCommandsDb) extends PendingCommandsDb {
409430

410431
private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-pending-commands").build()))
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright 2025 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.TimestampMilli
21+
import fr.acinq.eclair.wire.protocol.OfferTypes.Offer
22+
23+
/**
24+
* Database for offers fully managed by eclair, as opposed to offers managed by a plugin.
25+
*/
26+
trait OffersDb {
27+
/**
28+
* Add an offer managed by eclair.
29+
*
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], createdAt: TimestampMilli = TimestampMilli.now()): 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.
37+
*/
38+
def disableOffer(offer: Offer, disabledAt: TimestampMilli = TimestampMilli.now()): Unit
39+
40+
/**
41+
* List offers managed by eclair.
42+
*
43+
* @param onlyActive Whether to return only active offers or also disabled ones.
44+
*/
45+
def listOffers(onlyActive: Boolean): Seq[OfferData]
46+
}
47+
48+
case class OfferData(offer: Offer, pathId_opt: Option[ByteVector32], createdAt: TimestampMilli, disabledAt_opt: Option[TimestampMilli]) {
49+
val disabled: Boolean = disabledAt_opt.nonEmpty
50+
}

0 commit comments

Comments
 (0)