Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Offers without extra plugin #2976

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,39 @@
With this release, eclair requires using Bitcoin Core 28.1.
Newer versions of Bitcoin Core may be used, but have not been extensively tested.

### Offers

You can now create an offer with

```
./eclair-cli createoffer --description=coffee --amountMsat=20000 --expireInSeconds=3600 [email protected] --blindedPathsFirstNodeId=03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f
```

All parameters are optional and omitting all of them will create a minimal offer with your public node id.
You can also disable offers and list offers with

```
./eclair-cli disableoffer --offer=lnoxxx
./eclair-cli listoffers
```

If you specify `--blindedPathsFirstNodeId`, your public node id will not be in the offer, you will instead be hidden behind a blinded path starting at the node that you have chosen.
You can configure the number and length of blinded paths used in `eclair.conf`:

```
offers {
// Minimum length of an offer blinded path
message-path-min-length = 2

// Number of payment paths to put in Bolt12 invoices
payment-path-count = 2
// Length of payment paths to put in Bolt12 invoices
payment-path-length = 4
// Expiry delta of payment paths to put in Bolt12 invoices
payment-path-expiry-delta = 500
}
```

### Simplified mutual close

This release includes support for the latest [mutual close protocol](https://github.com/lightning/bolts/pull/1205).
Expand Down
12 changes: 12 additions & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,18 @@ eclair {
// Frequency at which we clean our DB to remove peer storage from nodes with whom we don't have channels anymore.
cleanup-frequency = 1 day
}

offers {
// Minimum length of an offer blinded path when hiding our real node id
message-path-min-length = 2

// Number of payment paths to put in Bolt12 invoices when hiding our real node id
payment-path-count = 2
// Length of payment paths to put in Bolt12 invoices when hiding our real node id
payment-path-length = 4
// Expiry delta of payment paths to put in Bolt12 invoices when hiding our real node id
payment-path-expiry-delta = 500
}
}

akka {
Expand Down
28 changes: 27 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,17 @@ import fr.acinq.eclair.db.AuditDb.{NetworkFee, Stats}
import fr.acinq.eclair.db.{IncomingPayment, OutgoingPayment, OutgoingPaymentStatus}
import fr.acinq.eclair.io.Peer.{GetPeerInfo, OpenChannelResponse, PeerInfo}
import fr.acinq.eclair.io._
import fr.acinq.eclair.message.OnionMessages.{IntermediateNode, Recipient}
import fr.acinq.eclair.message.{OnionMessages, Postman}
import fr.acinq.eclair.payment._
import fr.acinq.eclair.payment.offer.{OfferCreator, OfferManager}
import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceiveStandardPayment
import fr.acinq.eclair.payment.relay.Relayer.{ChannelBalance, GetOutgoingChannels, OutgoingChannels, RelayFees}
import fr.acinq.eclair.payment.send.PaymentInitiator._
import fr.acinq.eclair.payment.send.{ClearRecipient, OfferPayment, PaymentIdentifier}
import fr.acinq.eclair.router.Router
import fr.acinq.eclair.router.Router._
import fr.acinq.eclair.wire.protocol.OfferTypes.Offer
import fr.acinq.eclair.wire.protocol.OfferTypes.{Offer, OfferAbsoluteExpiry, OfferIssuer, OfferQuantityMax, OfferTlv}
import fr.acinq.eclair.wire.protocol._
import grizzled.slf4j.Logging
import scodec.bits.ByteVector
Expand Down Expand Up @@ -126,6 +128,12 @@ trait Eclair {

def receive(description: Either[String, ByteVector32], amount_opt: Option[MilliSatoshi], expire_opt: Option[Long], fallbackAddress_opt: Option[String], paymentPreimage_opt: Option[ByteVector32], privateChannelIds_opt: Option[List[ByteVector32]])(implicit timeout: Timeout): Future[Bolt11Invoice]

def createOffer(description_opt: Option[String], amount_opt: Option[MilliSatoshi], expire_opt: Option[Long], issuer_opt: Option[String], blindedPathsFirstNodeId_opt: Option[PublicKey])(implicit timeout: Timeout): Future[Offer]

def disableOffer(offer: Offer)(implicit timeout: Timeout): Future[Unit]

def listOffers(onlyActive: Boolean = true)(implicit timeout: Timeout): Future[Seq[Offer]]

def newAddress(): Future[String]

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

override def createOffer(description_opt: Option[String], amount_opt: Option[MilliSatoshi], expireInSeconds_opt: Option[Long], issuer_opt: Option[String], blindedPathsFirstNodeId_opt: Option[PublicKey])(implicit timeout: Timeout): Future[Offer] = {
val offerCreator = appKit.system.spawnAnonymous(OfferCreator(appKit.nodeParams, appKit.router, appKit.offerManager, appKit.defaultOfferHandler))
val expiry_opt = expireInSeconds_opt.map(TimestampSecond.now() + _)
offerCreator.ask[OfferCreator.CreateOfferResult](replyTo => OfferCreator.Create(replyTo, description_opt, amount_opt, expiry_opt, issuer_opt, blindedPathsFirstNodeId_opt))
.flatMap {
case OfferCreator.CreateOfferError(reason) => Future.failed(new Exception(reason))
case OfferCreator.CreatedOffer(offer) => Future.successful(offer)
}
}

override def disableOffer(offer: Offer)(implicit timeout: Timeout): Future[Unit] = Future {
appKit.offerManager ! OfferManager.DisableOffer(offer)
}

override def listOffers(onlyActive: Boolean = true)(implicit timeout: Timeout): Future[Seq[Offer]] = Future {
appKit.nodeParams.db.offers.listOffers(onlyActive).map(_.offer)
}

override def newAddress(): Future[String] = {
appKit.wallet match {
case w: BitcoinCoreClient => w.getReceiveAddress()
Expand Down
2 changes: 2 additions & 0 deletions eclair-core/src/main/scala/fr/acinq/eclair/Logs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ object Logs {
parentPaymentId_opt: Option[UUID] = None,
paymentId_opt: Option[UUID] = None,
paymentHash_opt: Option[ByteVector32] = None,
offerId_opt: Option[ByteVector32] = None,
txPublishId_opt: Option[UUID] = None,
messageId_opt: Option[ByteVector32] = None,
nodeAlias_opt: Option[String] = None): Map[String, String] =
Expand All @@ -60,6 +61,7 @@ object Logs {
parentPaymentId_opt.map(p => "parentPaymentId" -> s" p:$p"),
paymentId_opt.map(i => "paymentId" -> s" i:$i"),
paymentHash_opt.map(h => "paymentHash" -> s" h:$h"),
offerId_opt.map(o => "offerId" -> s" o:$o"),
txPublishId_opt.map(t => "txPublishId" -> s" t:$t"),
messageId_opt.map(m => "messageId" -> s" m:$m"),
nodeAlias_opt.map(a => "nodeAlias" -> s" a:$a"),
Expand Down
10 changes: 9 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -705,6 +707,12 @@ object NodeParams extends Logging {
writeDelay = FiniteDuration(config.getDuration("peer-storage.write-delay").getSeconds, TimeUnit.SECONDS),
removalDelay = FiniteDuration(config.getDuration("peer-storage.removal-delay").getSeconds, TimeUnit.SECONDS),
cleanUpFrequency = FiniteDuration(config.getDuration("peer-storage.cleanup-frequency").getSeconds, TimeUnit.SECONDS),
),
offersConfig = OffersConfig(
messagePathMinLength = config.getInt("offers.message-path-min-length"),
paymentPathCount = config.getInt("offers.payment-path-count"),
paymentPathLength = config.getInt("offers.payment-path-length"),
paymentPathCltvExpiryDelta = CltvExpiryDelta(config.getInt("offers.payment-path-expiry-delta")),
)
)
}
Expand Down
6 changes: 5 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import fr.acinq.eclair.db.FileBackupHandler.FileBackupParams
import fr.acinq.eclair.db.{Databases, DbEventHandler, FileBackupHandler, PeerStorageCleaner}
import fr.acinq.eclair.io._
import fr.acinq.eclair.message.Postman
import fr.acinq.eclair.payment.offer.OfferManager
import fr.acinq.eclair.payment.offer.{DefaultOfferHandler, OfferManager}
import fr.acinq.eclair.payment.receive.PaymentHandler
import fr.acinq.eclair.payment.relay.{AsyncPaymentTriggerer, PostRestartHtlcCleaner, Relayer}
import fr.acinq.eclair.payment.send.{Autoprobe, PaymentInitiator}
Expand Down Expand Up @@ -358,6 +358,8 @@ class Setup(val datadir: File,
dbEventHandler = system.actorOf(SimpleSupervisor.props(DbEventHandler.props(nodeParams), "db-event-handler", SupervisorStrategy.Resume))
register = system.actorOf(SimpleSupervisor.props(Register.props(), "register", SupervisorStrategy.Resume))
offerManager = system.spawn(Behaviors.supervise(OfferManager(nodeParams, paymentTimeout = 1 minute)).onFailure(typed.SupervisorStrategy.resume), name = "offer-manager")
defaultOfferHandler = system.spawn(Behaviors.supervise(DefaultOfferHandler(nodeParams, router)).onFailure(typed.SupervisorStrategy.resume), name = "default-offer-handler")
_ = for (offer <- nodeParams.db.offers.listOffers(onlyActive = true)) offerManager ! OfferManager.RegisterOffer(offer.offer, if (offer.pathId_opt.isEmpty) Some(nodeParams.privateKey) else None, offer.pathId_opt, defaultOfferHandler)
paymentHandler = system.actorOf(SimpleSupervisor.props(PaymentHandler.props(nodeParams, register, offerManager), "payment-handler", SupervisorStrategy.Resume))
triggerer = system.spawn(Behaviors.supervise(AsyncPaymentTriggerer()).onFailure(typed.SupervisorStrategy.resume), name = "async-payment-triggerer")
peerReadyManager = system.spawn(Behaviors.supervise(PeerReadyManager()).onFailure(typed.SupervisorStrategy.restart), name = "peer-ready-manager")
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ trait Databases {
def channels: ChannelsDb
def peers: PeersDb
def payments: PaymentsDb
def offers: OffersDb
def pendingCommands: PendingCommandsDb
def liquidity: LiquidityDb
//@formatter:on
Expand All @@ -66,6 +67,7 @@ object Databases extends Logging {
channels: SqliteChannelsDb,
peers: SqlitePeersDb,
payments: SqlitePaymentsDb,
offers: SqliteOffersDb,
pendingCommands: SqlitePendingCommandsDb,
private val backupConnection: Connection) extends Databases with FileBackup {
override def backup(backupFile: File): Unit = SqliteUtils.using(backupConnection.createStatement()) {
Expand All @@ -85,6 +87,7 @@ object Databases extends Logging {
channels = new SqliteChannelsDb(eclairJdbc),
peers = new SqlitePeersDb(eclairJdbc),
payments = new SqlitePaymentsDb(eclairJdbc),
offers = new SqliteOffersDb(eclairJdbc),
pendingCommands = new SqlitePendingCommandsDb(eclairJdbc),
backupConnection = eclairJdbc
)
Expand All @@ -97,6 +100,7 @@ object Databases extends Logging {
channels: PgChannelsDb,
peers: PgPeersDb,
payments: PgPaymentsDb,
offers: PgOffersDb,
pendingCommands: PgPendingCommandsDb,
dataSource: HikariDataSource,
lock: PgLock) extends Databases with ExclusiveLock {
Expand Down Expand Up @@ -157,6 +161,7 @@ object Databases extends Logging {
channels = new PgChannelsDb,
peers = new PgPeersDb,
payments = new PgPaymentsDb,
offers = new PgOffersDb,
pendingCommands = new PgPendingCommandsDb,
dataSource = ds,
lock = lock)
Expand Down
21 changes: 21 additions & 0 deletions eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ case class DualDatabases(primary: Databases, secondary: Databases) extends Datab
override val channels: ChannelsDb = DualChannelsDb(primary.channels, secondary.channels)
override val peers: PeersDb = DualPeersDb(primary.peers, secondary.peers)
override val payments: PaymentsDb = DualPaymentsDb(primary.payments, secondary.payments)
override val offers: OffersDb = DualOffersDb(primary.offers, secondary.offers)
override val pendingCommands: PendingCommandsDb = DualPendingCommandsDb(primary.pendingCommands, secondary.pendingCommands)
override val liquidity: LiquidityDb = DualLiquidityDb(primary.liquidity, secondary.liquidity)

Expand Down Expand Up @@ -405,6 +406,26 @@ case class DualPaymentsDb(primary: PaymentsDb, secondary: PaymentsDb) extends Pa
}
}

case class DualOffersDb(primary: OffersDb, secondary: OffersDb) extends OffersDb {

private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-offers").build()))

override def addOffer(offer: OfferTypes.Offer, pathId_opt: Option[ByteVector32], createdAt: TimestampMilli = TimestampMilli.now()): Unit = {
runAsync(secondary.addOffer(offer, pathId_opt, createdAt))
primary.addOffer(offer, pathId_opt, createdAt)
}

override def disableOffer(offer: OfferTypes.Offer, disabledAt: TimestampMilli = TimestampMilli.now()): Unit = {
runAsync(secondary.disableOffer(offer, disabledAt))
primary.disableOffer(offer, disabledAt)
}

override def listOffers(onlyActive: Boolean): Seq[OfferData] = {
runAsync(secondary.listOffers(onlyActive))
primary.listOffers(onlyActive)
}
}

case class DualPendingCommandsDb(primary: PendingCommandsDb, secondary: PendingCommandsDb) extends PendingCommandsDb {

private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-pending-commands").build()))
Expand Down
50 changes: 50 additions & 0 deletions eclair-core/src/main/scala/fr/acinq/eclair/db/OffersDb.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright 2025 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package fr.acinq.eclair.db

import fr.acinq.bitcoin.scalacompat.ByteVector32
import fr.acinq.eclair.TimestampMilli
import fr.acinq.eclair.wire.protocol.OfferTypes.Offer

/**
* Database for offers fully managed by eclair, as opposed to offers managed by a plugin.
*/
trait OffersDb {
/**
* Add an offer managed by eclair.
*
* @param pathId_opt If the offer uses a blinded path, this is the corresponding pathId.
*/
def addOffer(offer: Offer, pathId_opt: Option[ByteVector32], createdAt: TimestampMilli = TimestampMilli.now()): Unit

/**
* Disable an offer. The offer is still stored but new invoice requests and new payment attempts for already emitted
* invoices will be rejected.
*/
def disableOffer(offer: Offer, disabledAt: TimestampMilli = TimestampMilli.now()): Unit

/**
* List offers managed by eclair.
*
* @param onlyActive Whether to return only active offers or also disabled ones.
*/
def listOffers(onlyActive: Boolean): Seq[OfferData]
}

case class OfferData(offer: Offer, pathId_opt: Option[ByteVector32], createdAt: TimestampMilli, disabledAt_opt: Option[TimestampMilli]) {
val disabled: Boolean = disabledAt_opt.nonEmpty
}
Loading