From 060344cc3f4ceda0d4f786656da9d5d18df55410 Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Thu, 5 Jun 2025 17:57:42 +0200 Subject: [PATCH 1/3] Parse offers and pay offers with currency The `payoffer` API requires an amount but the user has currently no way to read the amount from the offer. We add `parseoffer` so that the user can read the offer amount (among other things). Since the user needs to specify the amount they want to pay, we allow paying offers with a currency (other than BTC). --- .../acinq/eclair/json/JsonSerializers.scala | 31 ++++++++++++++++++- .../eclair/payment/offer/OfferCreator.scala | 2 +- .../eclair/wire/protocol/OfferCodecs.scala | 2 +- .../eclair/wire/protocol/OfferTypes.scala | 14 ++++----- .../eclair/json/JsonSerializersSpec.scala | 25 +++++++++++++-- .../eclair/payment/Bolt12InvoiceSpec.scala | 8 ++--- .../eclair/wire/protocol/OfferTypesSpec.scala | 17 ++++------ .../fr/acinq/eclair/api/handlers/Offer.scala | 8 ++++- 8 files changed, 78 insertions(+), 29 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala index 2abb7c6453..dd3c26e21e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala @@ -34,8 +34,9 @@ import fr.acinq.eclair.payment._ import fr.acinq.eclair.router.Router._ import fr.acinq.eclair.transactions.DirectedHtlc import fr.acinq.eclair.transactions.Transactions._ +import fr.acinq.eclair.wire.protocol.OfferTypes.Offer import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, EncodedNodeId, Feature, FeatureSupport, MilliSatoshi, RealShortChannelId, ShortChannelId, TimestampMilli, TimestampSecond, UInt64, UnknownFeature} +import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, EncodedNodeId, Feature, FeatureSupport, Features, MilliSatoshi, RealShortChannelId, ShortChannelId, TimestampMilli, TimestampSecond, UInt64, UnknownFeature} import org.json4s import org.json4s.JsonAST._ import org.json4s.jackson.Serialization @@ -476,6 +477,33 @@ object InvoiceSerializer extends MinimalSerializer({ JObject(fieldList) }) +private case class OfferJson(chains: Option[Seq[String]], + currency: Option[String], + amount: Option[Long], + description: Option[String], + expiry: Option[TimestampSecond], + issuer: Option[String], + nodeId: Option[PublicKey], + paths: Option[Seq[Sphinx.RouteBlinding.BlindedRoute]], + quantityMax: Option[Long], + features: Option[Features[Feature]], + metadata: Option[String], + unknownTlvs: Option[Map[String, String]]) +object OfferSerializer extends ConvertClassSerializer[Offer](o => OfferJson( + chains = o.records.get[OfferTypes.OfferChains].map(_.chains.map(_.toString())), + currency = o.records.get[OfferTypes.OfferCurrency].map(_.iso4217), + amount = o.records.get[OfferTypes.OfferAmount].map(_.amount), + description = o.records.get[OfferTypes.OfferDescription].map(_.description), + expiry = o.records.get[OfferTypes.OfferAbsoluteExpiry].map(_.absoluteExpiry), + issuer = o.records.get[OfferTypes.OfferIssuer].map(_.issuer), + nodeId = o.records.get[OfferTypes.OfferNodeId].map(_.publicKey), + paths = o.records.get[OfferTypes.OfferPaths].map(_.paths), + quantityMax = o.records.get[OfferTypes.OfferQuantityMax].map(_.max), + features = o.records.get[OfferTypes.OfferFeatures].map(_.features), + metadata = o.records.get[OfferTypes.OfferMetadata].map(_.data.toHex), + unknownTlvs = if (o.records.unknown.isEmpty) None else Some(o.records.unknown.map(tlv => tlv.tag.toString -> tlv.value.toHex).toMap) +)) + private case class OfferDataJson(amountMsat: Option[MilliSatoshi], description: Option[String], issuer: Option[String], @@ -733,6 +761,7 @@ object JsonSerializers { NodeAddressSerializer + DirectedHtlcSerializer + InvoiceSerializer + + OfferSerializer + OfferDataSerializer + JavaUUIDSerializer + OriginSerializer + 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 index 9cfe7346a0..30c671c3ef 100644 --- 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 @@ -83,7 +83,7 @@ private class OfferCreator(context: ActorContext[OfferCreator.Command], } else { val tlvs: Set[OfferTlv] = Set( if (nodeParams.chainHash != Block.LivenetGenesisBlock.hash) Some(OfferChains(Seq(nodeParams.chainHash))) else None, - amount_opt.map(OfferAmount), + amount_opt.map(_.toLong).map(OfferAmount), description_opt.map(OfferDescription), expiry_opt.map(OfferAbsoluteExpiry), issuer_opt.map(OfferIssuer), diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala index 5b8604bc26..119722761b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala @@ -33,7 +33,7 @@ object OfferCodecs { private val offerCurrency: Codec[OfferCurrency] = tlvField(utf8) - private val offerAmount: Codec[OfferAmount] = tlvField(tmillisatoshi) + private val offerAmount: Codec[OfferAmount] = tlvField(tu64overflow) private val offerDescription: Codec[OfferDescription] = tlvField(utf8) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala index 519ae90e1b..d0e8f3695d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala @@ -23,7 +23,7 @@ import fr.acinq.eclair.crypto.Sphinx.RouteBlinding.BlindedRoute import fr.acinq.eclair.wire.protocol.CommonCodecs.varint import fr.acinq.eclair.wire.protocol.OnionRoutingCodecs.{ForbiddenTlv, InvalidTlvPayload, MissingRequiredTlv} import fr.acinq.eclair.wire.protocol.TlvCodecs.genericTlv -import fr.acinq.eclair.{Bolt12Feature, CltvExpiryDelta, Feature, Features, MilliSatoshi, TimestampSecond, UInt64, nodeFee, randomBytes32} +import fr.acinq.eclair.{Bolt12Feature, CltvExpiryDelta, Feature, Features, MilliSatoshi, MilliSatoshiLong, TimestampSecond, UInt64, nodeFee, randomBytes32} import scodec.Codec import scodec.bits.ByteVector import scodec.codecs.vector @@ -74,9 +74,9 @@ object OfferTypes { case class OfferCurrency(iso4217: String) extends OfferTlv /** - * Amount to pay per item. As we only support bitcoin, the amount is in msat. + * Amount to pay per item. */ - case class OfferAmount(amount: MilliSatoshi) extends OfferTlv + case class OfferAmount(amount: Long) extends OfferTlv /** * Description of the purpose of the payment. @@ -238,7 +238,7 @@ object OfferTypes { case class Offer(records: TlvStream[OfferTlv]) { val chains: Seq[BlockHash] = records.get[OfferChains].map(_.chains).getOrElse(Seq(Block.LivenetGenesisBlock.hash)) val metadata: Option[ByteVector] = records.get[OfferMetadata].map(_.data) - val amount: Option[MilliSatoshi] = records.get[OfferAmount].map(_.amount) + val amount: Option[MilliSatoshi] = if (records.get[OfferCurrency].isEmpty) records.get[OfferAmount].map(_.amount.msat) else None val description: Option[String] = records.get[OfferDescription].map(_.description) val features: Features[Bolt12Feature] = records.get[OfferFeatures].map(_.features.bolt12Features()).getOrElse(Features.empty) val expiry: Option[TimestampSecond] = records.get[OfferAbsoluteExpiry].map(_.absoluteExpiry) @@ -279,7 +279,7 @@ object OfferTypes { require(amount_opt.isEmpty || description_opt.nonEmpty) val tlvs: Set[OfferTlv] = Set( if (chain != Block.LivenetGenesisBlock.hash) Some(OfferChains(Seq(chain))) else None, - amount_opt.map(OfferAmount), + amount_opt.map(_.toLong).map(OfferAmount), description_opt.map(OfferDescription), if (!features.isEmpty) Some(OfferFeatures(features.unscoped())) else None, Some(OfferNodeId(nodeId)), @@ -297,7 +297,7 @@ object OfferTypes { require(amount_opt.isEmpty || description_opt.nonEmpty) val tlvs: Set[OfferTlv] = Set( if (chain != Block.LivenetGenesisBlock.hash) Some(OfferChains(Seq(chain))) else None, - amount_opt.map(OfferAmount), + amount_opt.map(_.toLong).map(OfferAmount), description_opt.map(OfferDescription), if (!features.isEmpty) Some(OfferFeatures(features.unscoped())) else None, Some(OfferPaths(paths)) @@ -308,8 +308,6 @@ object OfferTypes { def validate(records: TlvStream[OfferTlv]): Either[InvalidTlvPayload, Offer] = { if (records.get[OfferDescription].isEmpty && records.get[OfferAmount].nonEmpty) return Left(MissingRequiredTlv(UInt64(10))) if (records.get[OfferNodeId].isEmpty && records.get[OfferPaths].forall(_.paths.isEmpty)) return Left(MissingRequiredTlv(UInt64(22))) - // Currency conversion isn't supported yet. - if (records.get[OfferCurrency].nonEmpty) return Left(ForbiddenTlv(UInt64(6))) if (records.unknown.exists(!isOfferTlv(_))) return Left(ForbiddenTlv(records.unknown.find(!isOfferTlv(_)).get.tag)) Right(Offer(records)) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala index ee5ef3f741..23917e2d4b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala @@ -27,7 +27,7 @@ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.ChannelSpendSignature.IndividualSignature import fr.acinq.eclair.channel.Helpers.Funding import fr.acinq.eclair.channel._ -import fr.acinq.eclair.crypto.ShaChain +import fr.acinq.eclair.crypto.{ShaChain, Sphinx} import fr.acinq.eclair.db.OfferData import fr.acinq.eclair.io.Peer import fr.acinq.eclair.io.Peer.PeerInfo @@ -35,7 +35,7 @@ import fr.acinq.eclair.payment.{Invoice, PaymentSettlingOnChain} import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.transactions.{CommitmentSpec, IncomingHtlc, OutgoingHtlc} import fr.acinq.eclair.wire.internal.channel.ChannelCodecs -import fr.acinq.eclair.wire.protocol.OfferTypes.Offer +import fr.acinq.eclair.wire.protocol.OfferTypes.{Offer, OfferTlv} import fr.acinq.eclair.wire.protocol._ import org.scalatest.funsuite.AnyFunSuiteLike import org.scalatest.matchers.should.Matchers @@ -334,6 +334,27 @@ class JsonSerializersSpec extends TestKitBaseClass with AnyFunSuiteLike with Mat JsonSerializers.serialization.write(pr)(JsonSerializers.formats) shouldBe """{"amount":456001234,"nodeId":"03c48ac97e09f3cbbaeb35b02aaa6d072b57726841a34d25952157caca60a1caf5","paymentHash":"2cb0e7b052366787450c33daf6d2f2c3cb6132221326e1c1b49ac97fdd7eb720","description":"minimal offer","features":{"activated":{},"unknown":[]},"blindedPaths":[{"introductionNodeId":"03c48ac97e09f3cbbaeb35b02aaa6d072b57726841a34d25952157caca60a1caf5","blindedNodeIds":["031fca650042031dcb777156ef66806c73b01a7f52c4e73c89a0d15823a1ac6237"]}],"createdAt":1665412681,"expiresAt":1665412981,"serialized":"lni1qqsf4h8fsnpjkj057gjg9c3eqhv889440xh0z6f5kng9vsaad8pgq7sgqsdjuqsqpgxk66twd9kkzmpqdanxvetjzcss83y2e9lqnu7tht4ntvp24fksw26hwf5yrg6dyk2jz472efs2rjh42qsxlc5vp2m0rvmjcxn2y34wv0m5lyc7sdj7zksgn35dvxgqqqqqqqzjqsdjupkjtqssx05572ha26x39rczan5yft22pgwa72jw8gytavkm5ydn7yf5kpgh5zsq83y2e9lqnu7tht4ntvp24fksw26hwf5yrg6dyk2jz472efs2rjh4q2rd3ny0elv9m7mh38xxwe6ypfheeqeqlwgft05r6dhc50gtw0nv2qgrrl9x2qzzqvwukam32mhkdqrvwwcp5l6jcnnnezdq69vz8gdvvgmsqwk3efqf3f6gmf0ul63940awz429rdhhsts86s0r30e5nffwhrqw90xgxf7f60sm7tcclvyqwz7cer5q9223madstdy2p5q6y8qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqf2qheqqqqq2gprrgshynfszqyk2sgpvkrnmq53kv7r52rpnmtmd9ukredsnygsnymsurdy6e9la6l4hyz4qgxewqmftqggrcj9vjlsf709m46e4kq425mg89dthy6zp5dxjt9fp2l9v5c9pet6lqsx4k5r7rsld3hhe87psyy5cnhhzt4dz838f75734mted7pdsrflpvys23tkafmhctf3musnsaa42h6qjdggyqlhtevutzzpzlnwd8alq"}""" } + test("Bolt 12 offer") { + val ref = "lno1pqzqzltcgq9q6urvv4shxefqv3hkuct5v58qxrp4qqfquctvd93k2srpvd5kuufwvdh3vggzg2hd49ueds8phzcahvh4p2m3pnen649dza2h3k6gxpaequr8fhtq" + val offer = Offer.decode(ref).get + JsonSerializers.serialization.write(offer)(JsonSerializers.formats) shouldBe """{"amount":25000000,"description":"please donate","expiry":{"iso":"1970-01-10T06:13:20Z","unix":800000},"issuer":"alice@acinq.co","nodeId":"0242aeda97996c0e1b8b1dbb2f50ab710cf33d54ad175578db48307b9070674dd6"}""" + + val bigOffer = Offer(TlvStream(Set[OfferTlv]( + OfferTypes.OfferChains(Seq(Block.Testnet4GenesisBlock.hash)), + OfferTypes.OfferMetadata(hex"d5f4a6"), + OfferTypes.OfferCurrency("EUR"), + OfferTypes.OfferAmount(42), + OfferTypes.OfferDescription("offer with a lot of fields in it"), + OfferTypes.OfferFeatures(Features(Features.ProvideStorage -> FeatureSupport.Mandatory)), + OfferTypes.OfferAbsoluteExpiry(TimestampSecond(3600)), + OfferTypes.OfferPaths(Seq(Sphinx.RouteBlinding.BlindedRoute(EncodedNodeId.WithPublicKey.Plain(PublicKey(hex"022812e3a3760ac989b8749ee9fc70fd12e4d7f3cad5e3e2bf572e9e4eaaa7b7d9")), PublicKey(hex"028a2b20b2debdfd97de08f6e2374f2946116492f358b78acf9eac05f6fdac632d"), Seq(Sphinx.RouteBlinding.BlindedHop(PublicKey(hex"031b27d9e97dbb0ef87c48bb0231c96c6bca1ee54b0e0cfe869ad2388ce247719f"), hex"def5"))))), + OfferTypes.OfferIssuer("bob@bobcorp.com"), + OfferTypes.OfferQuantityMax(5), + OfferTypes.OfferNodeId(PublicKey(hex"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f")), + ), Set(GenericTlv(UInt64(71), hex"bd4e85ce")))) + JsonSerializers.serialization.write(bigOffer)(JsonSerializers.formats) shouldBe """{"chains":["43f08bdab050e35b567c864b91f47f50ae725ae2de53bcfbbaf284da00000000"],"currency":"EUR","amount":42,"description":"offer with a lot of fields in it","expiry":{"iso":"1970-01-01T01:00:00Z","unix":3600},"issuer":"bob@bobcorp.com","nodeId":"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f","paths":[{"firstNodeId":{"publicKey":"022812e3a3760ac989b8749ee9fc70fd12e4d7f3cad5e3e2bf572e9e4eaaa7b7d9"},"firstPathKey":"028a2b20b2debdfd97de08f6e2374f2946116492f358b78acf9eac05f6fdac632d","blindedHops":[{"blindedPublicKey":"031b27d9e97dbb0ef87c48bb0231c96c6bca1ee54b0e0cfe869ad2388ce247719f","encryptedPayload":"def5"}]}],"quantityMax":5,"features":{"activated":{"option_provide_storage":"mandatory"},"unknown":[]},"metadata":"d5f4a6","unknownTlvs":{"71":"bd4e85ce"}}""" + } + test("Bolt 12 offer data") { val ref = "lno1pqzqzltcgq9q6urvv4shxefqv3hkuct5v58qxrp4qqfquctvd93k2srpvd5kuufwvdh3vggzg2hd49ueds8phzcahvh4p2m3pnen649dza2h3k6gxpaequr8fhtq" val offer = OfferData(Offer.decode(ref).get, None, createdAt = TimestampMilli(100), disabledAt_opt = None) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt12InvoiceSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt12InvoiceSpec.scala index 15282f14f2..97931d710a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt12InvoiceSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt12InvoiceSpec.scala @@ -70,7 +70,7 @@ class Bolt12InvoiceSpec extends AnyFunSuite { // changing fields makes the signature invalid val withModifiedUnknownTlv = Bolt12Invoice(invoice.records.copy(unknown = Set(GenericTlv(UInt64(7), hex"ade4")))) assert(!withModifiedUnknownTlv.checkSignature()) - val withModifiedAmount = Bolt12Invoice(TlvStream(invoice.records.records.map { case OfferAmount(amount) => OfferAmount(amount + 100.msat) case x => x }, invoice.records.unknown)) + val withModifiedAmount = Bolt12Invoice(TlvStream(invoice.records.records.map { case OfferAmount(amount) => OfferAmount(amount + 100) case x => x }, invoice.records.unknown)) assert(!withModifiedAmount.checkSignature()) } @@ -92,7 +92,7 @@ class Bolt12InvoiceSpec extends AnyFunSuite { val invoice = Bolt12Invoice(request, randomBytes32(), nodeKey, 300 seconds, Features.empty, Seq(createPaymentBlindedRoute(nodeKey.publicKey))) assert(invoice.validateFor(request, nodeKey.publicKey).isRight) // amount must match the request - val withOtherAmount = signInvoice(Bolt12Invoice(TlvStream(invoice.records.records.map { case OfferAmount(_) => OfferAmount(9000 msat) case x => x })), nodeKey) + val withOtherAmount = signInvoice(Bolt12Invoice(TlvStream(invoice.records.records.map { case OfferAmount(_) => OfferAmount(9000) case x => x })), nodeKey) assert(withOtherAmount.validateFor(request, nodeKey.publicKey).isLeft) // description must match the offer val withOtherDescription = signInvoice(Bolt12Invoice(TlvStream(invoice.records.records.map { case OfferDescription(_) => OfferDescription("other description") case x => x })), nodeKey) @@ -229,7 +229,7 @@ class Bolt12InvoiceSpec extends AnyFunSuite { val tlvs = TlvStream[InvoiceTlv](Set[InvoiceTlv]( InvoiceRequestMetadata(payerInfo), OfferChains(Seq(chain)), - OfferAmount(amount), + OfferAmount(amount.toLong), OfferDescription(description), OfferFeatures(Features.empty), OfferIssuer(issuer), @@ -339,7 +339,7 @@ class Bolt12InvoiceSpec extends AnyFunSuite { val preimage = ByteVector32(hex"99221825b86576e94391b179902be8b22c7cfa7c3d14aec6ae86657dfd9bd2a8") val offer = Offer(TlvStream[OfferTlv]( OfferChains(Seq(Block.Testnet3GenesisBlock.hash)), - OfferAmount(100000 msat), + OfferAmount(100000), OfferDescription("offer with quantity"), OfferIssuer("alice@bigshop.com"), OfferQuantityMax(1000), diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala index b0a541ce7a..16b132747c 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala @@ -65,7 +65,7 @@ class OfferTypesSpec extends AnyFunSuite { test("offer with amount and quantity") { val offer = Offer(TlvStream[OfferTlv]( OfferChains(Seq(Block.Testnet3GenesisBlock.hash)), - OfferAmount(50 msat), + OfferAmount(50), OfferDescription("offer with quantity"), OfferIssuer("alice@bigshop.com"), OfferQuantityMax(0), @@ -131,7 +131,7 @@ class OfferTypesSpec extends AnyFunSuite { test("check that invoice request matches offer (chain compatibility)") { { - val offer = Offer(TlvStream(OfferAmount(100 msat), OfferDescription("offer without chains"), OfferNodeId(randomKey().publicKey))) + val offer = Offer(TlvStream(OfferAmount(100), OfferDescription("offer without chains"), OfferNodeId(randomKey().publicKey))) val payerKey = randomKey() val request = { val tlvs: Set[InvoiceRequestTlv] = offer.records.records ++ Set( @@ -152,7 +152,7 @@ class OfferTypesSpec extends AnyFunSuite { } { val (chain1, chain2) = (BlockHash(randomBytes32()), BlockHash(randomBytes32())) - val offer = Offer(TlvStream(OfferChains(Seq(chain1, chain2)), OfferAmount(100 msat), OfferDescription("offer with chains"), OfferNodeId(randomKey().publicKey))) + val offer = Offer(TlvStream(OfferChains(Seq(chain1, chain2)), OfferAmount(100), OfferDescription("offer with chains"), OfferNodeId(randomKey().publicKey))) val payerKey = randomKey() val request1 = InvoiceRequest(offer, 100 msat, 1, Features.empty, payerKey, chain1) assert(request1.isValid) @@ -169,7 +169,7 @@ class OfferTypesSpec extends AnyFunSuite { test("check that invoice request matches offer (multiple items)") { val offer = Offer(TlvStream( - OfferAmount(500 msat), + OfferAmount(500), OfferDescription("offer for multiple items"), OfferNodeId(randomKey().publicKey), OfferQuantityMax(10), @@ -314,13 +314,8 @@ class OfferTypesSpec extends AnyFunSuite { val testVectors = JsonMethods.parse(src.mkString).extract[Seq[TestVector]] src.close() for (vector <- testVectors) { - if (vector.description == "with currency") { - // We don't support currency conversion yet. - assert(Offer.decode(vector.bolt12).isFailure) - } else { - val offer = Offer.decode(vector.bolt12) - assert((offer.isSuccess && offer.get.features.unknown.forall(_.bitIndex % 2 == 1)) == vector.valid, vector.description) - } + val offer = Offer.decode(vector.bolt12) + assert((offer.isSuccess && offer.get.features.unknown.forall(_.bitIndex % 2 == 1)) == vector.valid, vector.description) } } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Offer.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Offer.scala index 96949c9d34..5d37d128e7 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Offer.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Offer.scala @@ -45,6 +45,12 @@ trait Offer { } } - val offerRoutes: Route = createOffer ~ disableOffer ~ listoffers + val parseOffer: Route = postRequest("parseoffer") { implicit t => + formFields(offerFormParam) { offer => + complete(offer) + } + } + + val offerRoutes: Route = createOffer ~ disableOffer ~ listoffers ~ parseOffer } From d9d6ea0df22dd12d0799ed0074f7df9b4e5f32a3 Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Fri, 6 Jun 2025 13:22:50 +0200 Subject: [PATCH 2/3] Better UX --- docs/release-notes/eclair-vnext.md | 1 + .../acinq/eclair/json/JsonSerializers.scala | 51 +++++++++++++------ .../eclair/wire/protocol/OfferCodecs.scala | 12 ++++- .../eclair/wire/protocol/OfferTypes.scala | 3 +- .../eclair/json/JsonSerializersSpec.scala | 13 +++-- 5 files changed, 56 insertions(+), 24 deletions(-) diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index a3f03628ac..167aee5ed7 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -26,6 +26,7 @@ It can be enabled by setting `eclair.features.option_attributable_failure = opti ### API changes - `listoffers` now returns more details about each offer. +- 'parseoffer' is added to display offer fields in a human-readable format. ### Configuration changes diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala index dd3c26e21e..3801d6998b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala @@ -477,32 +477,51 @@ object InvoiceSerializer extends MinimalSerializer({ JObject(fieldList) }) +private case class BlindedRouteJson(firstNodeId: EncodedNodeId, length: Int) private case class OfferJson(chains: Option[Seq[String]], + amount: Option[String], currency: Option[String], - amount: Option[Long], description: Option[String], expiry: Option[TimestampSecond], issuer: Option[String], nodeId: Option[PublicKey], - paths: Option[Seq[Sphinx.RouteBlinding.BlindedRoute]], + paths: Option[Seq[BlindedRouteJson]], quantityMax: Option[Long], features: Option[Features[Feature]], metadata: Option[String], unknownTlvs: Option[Map[String, String]]) -object OfferSerializer extends ConvertClassSerializer[Offer](o => OfferJson( - chains = o.records.get[OfferTypes.OfferChains].map(_.chains.map(_.toString())), - currency = o.records.get[OfferTypes.OfferCurrency].map(_.iso4217), - amount = o.records.get[OfferTypes.OfferAmount].map(_.amount), - description = o.records.get[OfferTypes.OfferDescription].map(_.description), - expiry = o.records.get[OfferTypes.OfferAbsoluteExpiry].map(_.absoluteExpiry), - issuer = o.records.get[OfferTypes.OfferIssuer].map(_.issuer), - nodeId = o.records.get[OfferTypes.OfferNodeId].map(_.publicKey), - paths = o.records.get[OfferTypes.OfferPaths].map(_.paths), - quantityMax = o.records.get[OfferTypes.OfferQuantityMax].map(_.max), - features = o.records.get[OfferTypes.OfferFeatures].map(_.features), - metadata = o.records.get[OfferTypes.OfferMetadata].map(_.data.toHex), - unknownTlvs = if (o.records.unknown.isEmpty) None else Some(o.records.unknown.map(tlv => tlv.tag.toString -> tlv.value.toHex).toMap) -)) +object OfferSerializer extends ConvertClassSerializer[Offer](o => { + val fractionDigits = o.records.get[OfferTypes.OfferCurrency].map(_.currency.getDefaultFractionDigits()).getOrElse(3) + OfferJson( + chains = o.records.get[OfferTypes.OfferChains].map(_.chains.map(_.toString())), + amount = o.records.get[OfferTypes.OfferAmount].map(a => + if (fractionDigits == 0) { + a.amount.toString + } else { + val one = scala.math.pow(10, fractionDigits).toInt + s"${a.amount / one}.%0${fractionDigits}d".format(a.amount % one) + } + ), + currency = if (o.records.get[OfferTypes.OfferAmount].isEmpty) { + None + } else { + Some(o.records.get[OfferTypes.OfferCurrency].map(_.currency.getCurrencyCode()).getOrElse("satoshi")) + }, + description = o.records.get[OfferTypes.OfferDescription].map(_.description), + expiry = o.records.get[OfferTypes.OfferAbsoluteExpiry].map(_.absoluteExpiry), + issuer = o.records.get[OfferTypes.OfferIssuer].map(_.issuer), + nodeId = o.records.get[OfferTypes.OfferNodeId].map(_.publicKey), + paths = o.records.get[OfferTypes.OfferPaths].map(_.paths.map(p => BlindedRouteJson(p.firstNodeId, p.blindedHops.length))), + quantityMax = o.records.get[OfferTypes.OfferQuantityMax].map(_.max), + features = o.records.get[OfferTypes.OfferFeatures].map(_.features), + metadata = o.records.get[OfferTypes.OfferMetadata].map(_.data.toHex), + unknownTlvs = if (o.records.unknown.isEmpty) { + None + } else { + Some(o.records.unknown.map(tlv => tlv.tag.toString -> tlv.value.toHex).toMap) + } + ) +}) private case class OfferDataJson(amountMsat: Option[MilliSatoshi], description: Option[String], diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala index 119722761b..f596fa715c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala @@ -23,15 +23,23 @@ import fr.acinq.eclair.wire.protocol.CommonCodecs._ import fr.acinq.eclair.wire.protocol.OfferTypes._ import fr.acinq.eclair.wire.protocol.TlvCodecs.{tlvField, tmillisatoshi, tu32, tu64overflow} import fr.acinq.eclair.{EncodedNodeId, TimestampSecond, UInt64} -import scodec.Codec +import scodec.{Attempt, Codec} import scodec.codecs._ +import java.util.Currency +import scala.util.Try + object OfferCodecs { private val offerChains: Codec[OfferChains] = tlvField(list(blockHash).xmap[Seq[BlockHash]](_.toSeq, _.toList)) private val offerMetadata: Codec[OfferMetadata] = tlvField(bytes) - private val offerCurrency: Codec[OfferCurrency] = tlvField(utf8) + private val offerCurrency: Codec[OfferCurrency] = + tlvField(utf8.narrow[Currency](s => Attempt.fromTry(Try{ + val c = Currency.getInstance(s) + require(c.getDefaultFractionDigits() >= 0) + c + }), _.getCurrencyCode())) private val offerAmount: Codec[OfferAmount] = tlvField(tu64overflow) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala index d0e8f3695d..30635a68e2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala @@ -28,6 +28,7 @@ import scodec.Codec import scodec.bits.ByteVector import scodec.codecs.vector +import java.util.Currency import scala.util.{Failure, Try} /** @@ -71,7 +72,7 @@ object OfferTypes { /** * Three-letter code of the currency the offer is denominated in. If empty, bitcoin is implied. */ - case class OfferCurrency(iso4217: String) extends OfferTlv + case class OfferCurrency(currency: Currency) extends OfferTlv /** * Amount to pay per item. diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala index 23917e2d4b..b82b213ddd 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala @@ -42,7 +42,7 @@ import org.scalatest.matchers.should.Matchers import scodec.bits._ import java.net.InetAddress -import java.util.UUID +import java.util.{Currency, UUID} class JsonSerializersSpec extends TestKitBaseClass with AnyFunSuiteLike with Matchers { @@ -335,15 +335,18 @@ class JsonSerializersSpec extends TestKitBaseClass with AnyFunSuiteLike with Mat } test("Bolt 12 offer") { + val minimalOffer = Offer(TlvStream[OfferTlv](OfferTypes.OfferNodeId(PublicKey(hex"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f")))) + JsonSerializers.serialization.write(minimalOffer)(JsonSerializers.formats) shouldBe """{"nodeId":"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f"}""" + val ref = "lno1pqzqzltcgq9q6urvv4shxefqv3hkuct5v58qxrp4qqfquctvd93k2srpvd5kuufwvdh3vggzg2hd49ueds8phzcahvh4p2m3pnen649dza2h3k6gxpaequr8fhtq" val offer = Offer.decode(ref).get - JsonSerializers.serialization.write(offer)(JsonSerializers.formats) shouldBe """{"amount":25000000,"description":"please donate","expiry":{"iso":"1970-01-10T06:13:20Z","unix":800000},"issuer":"alice@acinq.co","nodeId":"0242aeda97996c0e1b8b1dbb2f50ab710cf33d54ad175578db48307b9070674dd6"}""" + JsonSerializers.serialization.write(offer)(JsonSerializers.formats) shouldBe """{"amount":"25000.000","currency":"satoshi","description":"please donate","expiry":{"iso":"1970-01-10T06:13:20Z","unix":800000},"issuer":"alice@acinq.co","nodeId":"0242aeda97996c0e1b8b1dbb2f50ab710cf33d54ad175578db48307b9070674dd6"}""" val bigOffer = Offer(TlvStream(Set[OfferTlv]( OfferTypes.OfferChains(Seq(Block.Testnet4GenesisBlock.hash)), OfferTypes.OfferMetadata(hex"d5f4a6"), - OfferTypes.OfferCurrency("EUR"), - OfferTypes.OfferAmount(42), + OfferTypes.OfferCurrency(Currency.getInstance("EUR")), + OfferTypes.OfferAmount(86205), OfferTypes.OfferDescription("offer with a lot of fields in it"), OfferTypes.OfferFeatures(Features(Features.ProvideStorage -> FeatureSupport.Mandatory)), OfferTypes.OfferAbsoluteExpiry(TimestampSecond(3600)), @@ -352,7 +355,7 @@ class JsonSerializersSpec extends TestKitBaseClass with AnyFunSuiteLike with Mat OfferTypes.OfferQuantityMax(5), OfferTypes.OfferNodeId(PublicKey(hex"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f")), ), Set(GenericTlv(UInt64(71), hex"bd4e85ce")))) - JsonSerializers.serialization.write(bigOffer)(JsonSerializers.formats) shouldBe """{"chains":["43f08bdab050e35b567c864b91f47f50ae725ae2de53bcfbbaf284da00000000"],"currency":"EUR","amount":42,"description":"offer with a lot of fields in it","expiry":{"iso":"1970-01-01T01:00:00Z","unix":3600},"issuer":"bob@bobcorp.com","nodeId":"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f","paths":[{"firstNodeId":{"publicKey":"022812e3a3760ac989b8749ee9fc70fd12e4d7f3cad5e3e2bf572e9e4eaaa7b7d9"},"firstPathKey":"028a2b20b2debdfd97de08f6e2374f2946116492f358b78acf9eac05f6fdac632d","blindedHops":[{"blindedPublicKey":"031b27d9e97dbb0ef87c48bb0231c96c6bca1ee54b0e0cfe869ad2388ce247719f","encryptedPayload":"def5"}]}],"quantityMax":5,"features":{"activated":{"option_provide_storage":"mandatory"},"unknown":[]},"metadata":"d5f4a6","unknownTlvs":{"71":"bd4e85ce"}}""" + JsonSerializers.serialization.write(bigOffer)(JsonSerializers.formats) shouldBe """{"chains":["43f08bdab050e35b567c864b91f47f50ae725ae2de53bcfbbaf284da00000000"],"amount":"862.05","currency":"EUR","description":"offer with a lot of fields in it","expiry":{"iso":"1970-01-01T01:00:00Z","unix":3600},"issuer":"bob@bobcorp.com","nodeId":"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f","paths":[{"firstNodeId":{"publicKey":"022812e3a3760ac989b8749ee9fc70fd12e4d7f3cad5e3e2bf572e9e4eaaa7b7d9"},"length":1}],"quantityMax":5,"features":{"activated":{"option_provide_storage":"mandatory"},"unknown":[]},"metadata":"d5f4a6","unknownTlvs":{"71":"bd4e85ce"}}""" } test("Bolt 12 offer data") { From cca7d3b9649006c7fb7f9cd4d98fca62a57f6fcf Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Tue, 10 Jun 2025 10:10:53 +0200 Subject: [PATCH 3/3] Tests for java.util.Currency --- docs/release-notes/eclair-vnext.md | 2 +- .../eclair/wire/protocol/OfferCodecs.scala | 4 ++-- .../eclair/wire/protocol/OfferTypesSpec.scala | 21 ++++++++++++++++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 167aee5ed7..ced11215ce 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -26,7 +26,7 @@ It can be enabled by setting `eclair.features.option_attributable_failure = opti ### API changes - `listoffers` now returns more details about each offer. -- 'parseoffer' is added to display offer fields in a human-readable format. +- `parseoffer` is added to display offer fields in a human-readable format. ### Configuration changes diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala index f596fa715c..a0eda7ae3d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala @@ -34,10 +34,10 @@ object OfferCodecs { private val offerMetadata: Codec[OfferMetadata] = tlvField(bytes) - private val offerCurrency: Codec[OfferCurrency] = + val offerCurrency: Codec[OfferCurrency] = tlvField(utf8.narrow[Currency](s => Attempt.fromTry(Try{ val c = Currency.getInstance(s) - require(c.getDefaultFractionDigits() >= 0) + require(c.getDefaultFractionDigits() >= 0) // getDefaultFractionDigits may return -1 for things that are not currencies c }), _.getCurrencyCode())) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala index 16b132747c..b934377f20 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala @@ -22,13 +22,15 @@ import fr.acinq.bitcoin.scalacompat.{Block, BlockHash, ByteVector32} import fr.acinq.eclair.FeatureSupport.{Mandatory, Optional} import fr.acinq.eclair.Features.BasicMultiPartPayment import fr.acinq.eclair.crypto.Sphinx.RouteBlinding.{BlindedHop, BlindedRoute} +import fr.acinq.eclair.wire.protocol.CommonCodecs.varintoverflow import fr.acinq.eclair.wire.protocol.OfferCodecs.{invoiceRequestTlvCodec, offerTlvCodec} import fr.acinq.eclair.wire.protocol.OfferTypes._ import fr.acinq.eclair.{BlockHeight, EncodedNodeId, Features, MilliSatoshiLong, RealShortChannelId, randomBytes32, randomKey} import org.json4s.DefaultFormats import org.json4s.jackson.JsonMethods import org.scalatest.funsuite.AnyFunSuite -import scodec.bits.{ByteVector, HexStringSyntax} +import scodec.bits.{BitVector, ByteVector, HexStringSyntax} +import scodec.codecs.{utf8, variableSizeBytesLong} import java.io.File import scala.io.Source @@ -331,4 +333,21 @@ class OfferTypesSpec extends AnyFunSuite { assert(Offer.decode(vector.string).isSuccess == vector.valid, vector.comment) } } + + test("offer currency") { + def encode(s: String): BitVector = variableSizeBytesLong(varintoverflow, utf8).encode(s).require + + assert(OfferCodecs.offerCurrency.decode(encode("EUR")).isSuccessful) + assert(OfferCodecs.offerCurrency.decode(encode("USD")).isSuccessful) + assert(OfferCodecs.offerCurrency.decode(encode("CHF")).isSuccessful) + assert(OfferCodecs.offerCurrency.decode(encode("JOD")).isSuccessful) + assert(OfferCodecs.offerCurrency.decode(encode("CNY")).isSuccessful) + assert(OfferCodecs.offerCurrency.decode(encode("GBP")).isSuccessful) + assert(OfferCodecs.offerCurrency.decode(encode("JPY")).isSuccessful) + assert(OfferCodecs.offerCurrency.decode(encode("EURO")).isFailure) + assert(OfferCodecs.offerCurrency.decode(encode("eur")).isFailure) + assert(OfferCodecs.offerCurrency.decode(encode("BTC")).isFailure) + assert(OfferCodecs.offerCurrency.decode(encode("XAU")).isFailure) + assert(OfferCodecs.offerCurrency.decode(hex"ffffff".bits).isFailure) + } }