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

Add offer manager #2566

Merged
merged 23 commits into from Mar 27, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/release-notes/eclair-vnext.md
Expand Up @@ -6,7 +6,8 @@

### Offers

Eclair now supports paying offers:
#### Paying offers

```shell
$ ./eclair-cli payoffer --offer=<offer-to-pay> --amountMsat=<amountToPay>
```
Expand All @@ -17,6 +18,17 @@ Eclair will request an invoice and pay it (assuming it matches our request) with

Offers are still experimental and some details could still change before they are widely supported.

#### Receiving payments for offers

To be able to receive payments for offers, you will need to use a plugin.
The plugin needs to create the offer and register a handler that will accept or reject the invoice requests and the payments.
Eclair will check that these satisfy all the protocol requirements and the handler only needs to consider whether the item on offer can be delivered or not.

Invoices generated for offers are not stored in the database to prevent a DoS vector.
Instead, all the relevant data (offer id, preimage, amount, quantity, creation date and payer id) is included in the blinded route that will be used for payment.
The handler can also add its own data.
All this data is signed and encrypted so that it can not be read or forged by the payer.

### API changes

- `audit` now accepts `--count` and `--skip` parameters to limit the number of retrieved items (#2474, #2487)
Expand Down
16 changes: 10 additions & 6 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Expand Up @@ -57,6 +57,7 @@ import java.nio.charset.StandardCharsets
import java.util.UUID
import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future, Promise}
import scala.util.Try

case class GetInfoResponse(version: String, nodeId: PublicKey, alias: String, color: String, features: Features[Feature], chainHash: ByteVector32, network: String, blockHeight: Int, publicAddresses: Seq[NodeAddress], onionAddress: Option[NodeAddress], instanceId: String)

Expand Down Expand Up @@ -165,9 +166,9 @@ trait Eclair {

def sendOnionMessage(intermediateNodes: Seq[PublicKey], destination: Either[PublicKey, Sphinx.RouteBlinding.BlindedRoute], replyPath: Option[Seq[PublicKey]], userCustomContent: ByteVector)(implicit timeout: Timeout): Future[SendOnionMessageResponse]

def payOffer(offer: Offer, amount: MilliSatoshi, quantity: Long, externalId_opt: Option[String] = None, maxAttempts_opt: Option[Int] = None, maxFeeFlat_opt: Option[Satoshi] = None, maxFeePct_opt: Option[Double] = None, pathFindingExperimentName_opt: Option[String] = None)(implicit timeout: Timeout): Future[UUID]
def payOffer(offer: Offer, amount: MilliSatoshi, quantity: Long, path: Seq[PublicKey] = Nil, externalId_opt: Option[String] = None, maxAttempts_opt: Option[Int] = None, maxFeeFlat_opt: Option[Satoshi] = None, maxFeePct_opt: Option[Double] = None, pathFindingExperimentName_opt: Option[String] = None)(implicit timeout: Timeout): Future[UUID]
t-bast marked this conversation as resolved.
Show resolved Hide resolved

def payOfferBlocking(offer: Offer, amount: MilliSatoshi, quantity: Long, externalId_opt: Option[String] = None, maxAttempts_opt: Option[Int] = None, maxFeeFlat_opt: Option[Satoshi] = None, maxFeePct_opt: Option[Double] = None, pathFindingExperimentName_opt: Option[String] = None)(implicit timeout: Timeout): Future[PaymentEvent]
def payOfferBlocking(offer: Offer, amount: MilliSatoshi, quantity: Long, path: Seq[PublicKey] = Nil, externalId_opt: Option[String] = None, maxAttempts_opt: Option[Int] = None, maxFeeFlat_opt: Option[Satoshi] = None, maxFeePct_opt: Option[Double] = None, pathFindingExperimentName_opt: Option[String] = None)(implicit timeout: Timeout): Future[PaymentEvent]

def stop(): Future[Unit]
}
Expand Down Expand Up @@ -279,7 +280,7 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {

override def receive(description: Either[String, ByteVector32], amount_opt: Option[MilliSatoshi], expire_opt: Option[Long], fallbackAddress_opt: Option[String], paymentPreimage_opt: Option[ByteVector32])(implicit timeout: Timeout): Future[Bolt11Invoice] = {
fallbackAddress_opt.map { fa => fr.acinq.eclair.addressToPublicKeyScript(fa, appKit.nodeParams.chainHash) } // if it's not a bitcoin address throws an exception
(appKit.paymentHandler ? ReceiveStandardPayment(amount_opt, description, expire_opt, fallbackAddress_opt = fallbackAddress_opt, paymentPreimage_opt = paymentPreimage_opt)).mapTo[Bolt11Invoice]
(appKit.paymentHandler ? ReceiveStandardPayment(amount_opt, description, expire_opt, fallbackAddress_opt = fallbackAddress_opt, paymentPreimage_opt = paymentPreimage_opt)).mapTo[Try[Bolt11Invoice]].map(_.get)
}

override def newAddress(): Future[String] = {
Expand Down Expand Up @@ -591,6 +592,7 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
def payOfferInternal(offer: Offer,
amount: MilliSatoshi,
quantity: Long,
path: Seq[PublicKey],
externalId_opt: Option[String],
maxAttempts_opt: Option[Int],
maxFeeFlat_opt: Option[Satoshi],
Expand All @@ -607,7 +609,7 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
.modify(_.boundaries.maxFeeFlat).setToIfDefined(maxFeeFlat_opt.map(_.toMilliSatoshi))
case Left(t) => return Future.failed(t)
}
val sendPaymentConfig = OfferPayment.SendPaymentConfig(externalId_opt, maxAttempts_opt.getOrElse(appKit.nodeParams.maxPaymentAttempts), routeParams, blocking)
val sendPaymentConfig = OfferPayment.SendPaymentConfig(externalId_opt, maxAttempts_opt.getOrElse(appKit.nodeParams.maxPaymentAttempts), path, routeParams, blocking)
val offerPayment = appKit.system.spawnAnonymous(OfferPayment(appKit.nodeParams, appKit.postman, appKit.paymentInitiator))
offerPayment.ask((ref: typed.ActorRef[Any]) => OfferPayment.PayOffer(ref.toClassic, offer, amount, quantity, sendPaymentConfig)).flatMap {
case f: OfferPayment.Failure => Future.failed(new Exception(f.toString))
Expand All @@ -618,23 +620,25 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
override def payOffer(offer: Offer,
amount: MilliSatoshi,
quantity: Long,
path: Seq[PublicKey],
externalId_opt: Option[String],
maxAttempts_opt: Option[Int],
maxFeeFlat_opt: Option[Satoshi],
maxFeePct_opt: Option[Double],
pathFindingExperimentName_opt: Option[String])(implicit timeout: Timeout): Future[UUID] = {
payOfferInternal(offer, amount, quantity, externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, blocking = false).mapTo[UUID]
payOfferInternal(offer, amount, quantity, path, externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, blocking = false).mapTo[UUID]
}

override def payOfferBlocking(offer: Offer,
amount: MilliSatoshi,
quantity: Long,
path: Seq[PublicKey],
externalId_opt: Option[String],
maxAttempts_opt: Option[Int],
maxFeeFlat_opt: Option[Satoshi],
maxFeePct_opt: Option[Double],
pathFindingExperimentName_opt: Option[String])(implicit timeout: Timeout): Future[PaymentEvent] = {
payOfferInternal(offer, amount, quantity, externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, blocking = true).mapTo[PaymentEvent]
payOfferInternal(offer, amount, quantity, path, externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, blocking = true).mapTo[PaymentEvent]
}

override def stop(): Future[Unit] = {
Expand Down
8 changes: 6 additions & 2 deletions eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala
Expand Up @@ -40,6 +40,7 @@ import fr.acinq.eclair.db.FileBackupHandler.FileBackupParams
import fr.acinq.eclair.db.{Databases, DbEventHandler, FileBackupHandler}
import fr.acinq.eclair.io.{ClientSpawner, Peer, PendingChannelsRateLimiter, Server, Switchboard}
import fr.acinq.eclair.message.Postman
import fr.acinq.eclair.payment.offer.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 @@ -349,7 +350,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))
paymentHandler = system.actorOf(SimpleSupervisor.props(PaymentHandler.props(nodeParams, register), "payment-handler", SupervisorStrategy.Resume))
offerManager = system.spawn(Behaviors.supervise(OfferManager(nodeParams, router)).onFailure(typed.SupervisorStrategy.resume), name = "offer-manager")
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")
relayer = system.actorOf(SimpleSupervisor.props(Relayer.props(nodeParams, router, register, paymentHandler, triggerer, Some(postRestartCleanUpInitialized)), "relayer", SupervisorStrategy.Resume))
_ = relayer ! PostRestartHtlcCleaner.Init(channels)
Expand All @@ -372,7 +374,7 @@ class Setup(val datadir: File,
_ = triggerer ! AsyncPaymentTriggerer.Start(switchboard.toTyped)
balanceActor = system.spawn(BalanceActor(nodeParams.db, bitcoinClient, channelsListener, nodeParams.balanceCheckInterval), name = "balance-actor")

postman = system.spawn(Behaviors.supervise(Postman(nodeParams, switchboard.toTyped)).onFailure(typed.SupervisorStrategy.restart), name = "postman")
postman = system.spawn(Behaviors.supervise(Postman(nodeParams, switchboard.toTyped, offerManager)).onFailure(typed.SupervisorStrategy.restart), name = "postman")

kit = Kit(
nodeParams = nodeParams,
Expand All @@ -388,6 +390,7 @@ class Setup(val datadir: File,
channelsListener = channelsListener,
balanceActor = balanceActor,
postman = postman,
offerManager = offerManager,
wallet = bitcoinClient)

zmqBlockTimeout = after(5 seconds, using = system.scheduler)(Future.failed(BitcoinZMQConnectionTimeoutException))
Expand Down Expand Up @@ -456,6 +459,7 @@ case class Kit(nodeParams: NodeParams,
channelsListener: typed.ActorRef[ChannelsListener.Command],
balanceActor: typed.ActorRef[BalanceActor.Command],
postman: typed.ActorRef[Postman.Command],
offerManager: typed.ActorRef[OfferManager.Command],
wallet: OnChainWallet with OnchainPubkeyCache)

object Kit {
Expand Down
Expand Up @@ -286,16 +286,16 @@ case class DualPaymentsDb(primary: PaymentsDb, secondary: PaymentsDb) extends Pa
primary.addIncomingPayment(pr, preimage, paymentType)
}

override def addIncomingBlindedPayment(pr: Bolt12Invoice, preimage: ByteVector32, pathIds: Map[PublicKey, ByteVector], paymentType: String): Unit = {
runAsync(secondary.addIncomingBlindedPayment(pr, preimage, pathIds, paymentType))
primary.addIncomingBlindedPayment(pr, preimage, pathIds, paymentType)
}

override def receiveIncomingPayment(paymentHash: ByteVector32, amount: MilliSatoshi, receivedAt: TimestampMilli): Boolean = {
runAsync(secondary.receiveIncomingPayment(paymentHash, amount, receivedAt))
primary.receiveIncomingPayment(paymentHash, amount, receivedAt)
}

override def receiveAddIncomingBlindedPayment(pr: Bolt12Invoice, preimage: ByteVector32, amount: MilliSatoshi, receivedAt: TimestampMilli, paymentType: String): Unit = {
runAsync(secondary.receiveAddIncomingBlindedPayment(pr, preimage, amount, receivedAt, paymentType))
primary.receiveAddIncomingBlindedPayment(pr, preimage, amount, receivedAt, paymentType)
}

override def getIncomingPayment(paymentHash: ByteVector32): Option[IncomingPayment] = {
runAsync(secondary.getIncomingPayment(paymentHash))
primary.getIncomingPayment(paymentHash)
Expand Down
16 changes: 7 additions & 9 deletions eclair-core/src/main/scala/fr/acinq/eclair/db/PaymentsDb.scala
Expand Up @@ -33,15 +33,18 @@ trait IncomingPaymentsDb {
/** Add a new expected standard incoming payment (not yet received). */
def addIncomingPayment(pr: Bolt11Invoice, preimage: ByteVector32, paymentType: String = PaymentType.Standard): Unit

/** Add a new expected blinded incoming payment (not yet received). */
def addIncomingBlindedPayment(pr: Bolt12Invoice, preimage: ByteVector32, pathIds: Map[PublicKey, ByteVector], paymentType: String = PaymentType.Blinded): Unit

/**
* Mark an incoming payment as received (paid). The received amount may exceed the invoice amount.
* If there was no matching invoice in the DB, this will return false.
*/
def receiveIncomingPayment(paymentHash: ByteVector32, amount: MilliSatoshi, receivedAt: TimestampMilli = TimestampMilli.now()): Boolean

/**
* Add a new incoming offer payment as received.
* If the invoice is already paid, adds `amount` to the amount paid.
*/
def receiveAddIncomingBlindedPayment(pr: Bolt12Invoice, preimage: ByteVector32, amount: MilliSatoshi, receivedAt: TimestampMilli = TimestampMilli.now(), paymentType: String = PaymentType.Blinded): Unit
t-bast marked this conversation as resolved.
Show resolved Hide resolved

/** Get information about the incoming payment (paid or not) for the given payment hash, if any. */
def getIncomingPayment(paymentHash: ByteVector32): Option[IncomingPayment]

Expand Down Expand Up @@ -128,15 +131,10 @@ case class IncomingStandardPayment(invoice: Bolt11Invoice,
createdAt: TimestampMilli,
status: IncomingPaymentStatus) extends IncomingPayment

/**
* A blinded incoming payment received by this node.
*
* @param pathIds map the last blinding point of a blinded path to the corresponding pathId.
*/
/** A blinded incoming payment received by this node. */
case class IncomingBlindedPayment(invoice: Bolt12Invoice,
paymentPreimage: ByteVector32,
paymentType: String,
pathIds: Map[PublicKey, ByteVector],
createdAt: TimestampMilli,
status: IncomingPaymentStatus) extends IncomingPayment

Expand Down
35 changes: 18 additions & 17 deletions eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgPaymentsDb.scala
Expand Up @@ -268,21 +268,6 @@ class PgPaymentsDb(implicit ds: DataSource, lock: PgLock) extends PaymentsDb wit
}
}

override def addIncomingBlindedPayment(invoice: Bolt12Invoice, preimage: ByteVector32, pathIds: Map[PublicKey, ByteVector], paymentType: String): Unit = withMetrics("payments/add-incoming-blinded", DbBackends.Postgres) {
withLock { pg =>
using(pg.prepareStatement("INSERT INTO payments.received (payment_hash, payment_preimage, path_ids, payment_type, payment_request, created_at, expire_at) VALUES (?, ?, ?, ?, ?, ?, ?)")) { statement =>
statement.setString(1, invoice.paymentHash.toHex)
statement.setString(2, preimage.toHex)
statement.setBytes(3, encodePathIds(pathIds))
statement.setString(4, paymentType)
statement.setString(5, invoice.toString)
statement.setTimestamp(6, invoice.createdAt.toSqlTimestamp)
statement.setTimestamp(7, (invoice.createdAt + invoice.relativeExpiry.toSeconds).toSqlTimestamp)
statement.executeUpdate()
}
}
}

override def receiveIncomingPayment(paymentHash: ByteVector32, amount: MilliSatoshi, receivedAt: TimestampMilli): Boolean = withMetrics("payments/receive-incoming", DbBackends.Postgres) {
withLock { pg =>
using(pg.prepareStatement("UPDATE payments.received SET (received_msat, received_at) = (? + COALESCE(received_msat, 0), ?) WHERE payment_hash = ?")) { update =>
Expand All @@ -295,6 +280,23 @@ class PgPaymentsDb(implicit ds: DataSource, lock: PgLock) extends PaymentsDb wit
}
}

override def receiveAddIncomingBlindedPayment(invoice: Bolt12Invoice, preimage: ByteVector32, amount: MilliSatoshi, receivedAt: TimestampMilli, paymentType: String): Unit = withMetrics("payments/receive-incoming-blinded", DbBackends.Postgres) {
withLock { pg =>
using(pg.prepareStatement("INSERT INTO payments.received (payment_hash, payment_preimage, payment_type, payment_request, created_at, expire_at, received_msat, received_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" +
"ON CONFLICT (payment_hash) DO UPDATE SET (received_msat, received_at) = (payments.received.received_msat + EXCLUDED.received_msat, EXCLUDED.received_at)")) { statement =>
statement.setString(1, invoice.paymentHash.toHex)
statement.setString(2, preimage.toHex)
statement.setString(3, paymentType)
statement.setString(4, invoice.toString)
statement.setTimestamp(5, invoice.createdAt.toSqlTimestamp)
statement.setTimestamp(6, (invoice.createdAt + invoice.relativeExpiry.toSeconds).toSqlTimestamp)
statement.setLong(7, amount.toLong)
statement.setTimestamp(8, receivedAt.toSqlTimestamp)
statement.executeUpdate()
}
}
}

private def parseIncomingPayment(rs: ResultSet): Option[IncomingPayment] = {
val invoice = rs.getString("payment_request")
val preimage = rs.getByteVector32FromHex("payment_preimage")
Expand All @@ -306,8 +308,7 @@ class PgPaymentsDb(implicit ds: DataSource, lock: PgLock) extends PaymentsDb wit
Some(IncomingStandardPayment(invoice, preimage, paymentType, createdAt, status))
case Success(invoice: Bolt12Invoice) =>
val status = buildIncomingPaymentStatus(rs.getMilliSatoshiNullable("received_msat"), invoice, rs.getTimestampNullable("received_at").map(TimestampMilli.fromSqlTimestamp))
val pathIds = decodePathIds(BitVector(rs.getBytes("path_ids")))
Some(IncomingBlindedPayment(invoice, preimage, paymentType, pathIds, createdAt, status))
Some(IncomingBlindedPayment(invoice, preimage, paymentType, createdAt, status))
case _ =>
logger.error(s"could not parse DB invoice=$invoice, this should not happen")
None
Expand Down