Skip to content

Commit e7b9b89

Browse files
Parse offers and pay offers with currency (#3101)
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).
1 parent 345aef0 commit e7b9b89

9 files changed

Lines changed: 134 additions & 34 deletions

File tree

docs/release-notes/eclair-vnext.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ It can be enabled by setting `eclair.features.option_attributable_failure = opti
2626
### API changes
2727

2828
- `listoffers` now returns more details about each offer.
29+
- `parseoffer` is added to display offer fields in a human-readable format.
2930

3031

3132
### Configuration changes

eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ import fr.acinq.eclair.payment._
3434
import fr.acinq.eclair.router.Router._
3535
import fr.acinq.eclair.transactions.DirectedHtlc
3636
import fr.acinq.eclair.transactions.Transactions._
37+
import fr.acinq.eclair.wire.protocol.OfferTypes.Offer
3738
import fr.acinq.eclair.wire.protocol._
38-
import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, EncodedNodeId, Feature, FeatureSupport, MilliSatoshi, RealShortChannelId, ShortChannelId, TimestampMilli, TimestampSecond, UInt64, UnknownFeature}
39+
import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, EncodedNodeId, Feature, FeatureSupport, Features, MilliSatoshi, RealShortChannelId, ShortChannelId, TimestampMilli, TimestampSecond, UInt64, UnknownFeature}
3940
import org.json4s
4041
import org.json4s.JsonAST._
4142
import org.json4s.jackson.Serialization
@@ -476,6 +477,52 @@ object InvoiceSerializer extends MinimalSerializer({
476477
JObject(fieldList)
477478
})
478479

480+
private case class BlindedRouteJson(firstNodeId: EncodedNodeId, length: Int)
481+
private case class OfferJson(chains: Option[Seq[String]],
482+
amount: Option[String],
483+
currency: Option[String],
484+
description: Option[String],
485+
expiry: Option[TimestampSecond],
486+
issuer: Option[String],
487+
nodeId: Option[PublicKey],
488+
paths: Option[Seq[BlindedRouteJson]],
489+
quantityMax: Option[Long],
490+
features: Option[Features[Feature]],
491+
metadata: Option[String],
492+
unknownTlvs: Option[Map[String, String]])
493+
object OfferSerializer extends ConvertClassSerializer[Offer](o => {
494+
val fractionDigits = o.records.get[OfferTypes.OfferCurrency].map(_.currency.getDefaultFractionDigits()).getOrElse(3)
495+
OfferJson(
496+
chains = o.records.get[OfferTypes.OfferChains].map(_.chains.map(_.toString())),
497+
amount = o.records.get[OfferTypes.OfferAmount].map(a =>
498+
if (fractionDigits == 0) {
499+
a.amount.toString
500+
} else {
501+
val one = scala.math.pow(10, fractionDigits).toInt
502+
s"${a.amount / one}.%0${fractionDigits}d".format(a.amount % one)
503+
}
504+
),
505+
currency = if (o.records.get[OfferTypes.OfferAmount].isEmpty) {
506+
None
507+
} else {
508+
Some(o.records.get[OfferTypes.OfferCurrency].map(_.currency.getCurrencyCode()).getOrElse("satoshi"))
509+
},
510+
description = o.records.get[OfferTypes.OfferDescription].map(_.description),
511+
expiry = o.records.get[OfferTypes.OfferAbsoluteExpiry].map(_.absoluteExpiry),
512+
issuer = o.records.get[OfferTypes.OfferIssuer].map(_.issuer),
513+
nodeId = o.records.get[OfferTypes.OfferNodeId].map(_.publicKey),
514+
paths = o.records.get[OfferTypes.OfferPaths].map(_.paths.map(p => BlindedRouteJson(p.firstNodeId, p.blindedHops.length))),
515+
quantityMax = o.records.get[OfferTypes.OfferQuantityMax].map(_.max),
516+
features = o.records.get[OfferTypes.OfferFeatures].map(_.features),
517+
metadata = o.records.get[OfferTypes.OfferMetadata].map(_.data.toHex),
518+
unknownTlvs = if (o.records.unknown.isEmpty) {
519+
None
520+
} else {
521+
Some(o.records.unknown.map(tlv => tlv.tag.toString -> tlv.value.toHex).toMap)
522+
}
523+
)
524+
})
525+
479526
private case class OfferDataJson(amountMsat: Option[MilliSatoshi],
480527
description: Option[String],
481528
issuer: Option[String],
@@ -733,6 +780,7 @@ object JsonSerializers {
733780
NodeAddressSerializer +
734781
DirectedHtlcSerializer +
735782
InvoiceSerializer +
783+
OfferSerializer +
736784
OfferDataSerializer +
737785
JavaUUIDSerializer +
738786
OriginSerializer +

eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferCreator.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ private class OfferCreator(context: ActorContext[OfferCreator.Command],
8383
} else {
8484
val tlvs: Set[OfferTlv] = Set(
8585
if (nodeParams.chainHash != Block.LivenetGenesisBlock.hash) Some(OfferChains(Seq(nodeParams.chainHash))) else None,
86-
amount_opt.map(OfferAmount),
86+
amount_opt.map(_.toLong).map(OfferAmount),
8787
description_opt.map(OfferDescription),
8888
expiry_opt.map(OfferAbsoluteExpiry),
8989
issuer_opt.map(OfferIssuer),

eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferCodecs.scala

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,25 @@ import fr.acinq.eclair.wire.protocol.CommonCodecs._
2323
import fr.acinq.eclair.wire.protocol.OfferTypes._
2424
import fr.acinq.eclair.wire.protocol.TlvCodecs.{tlvField, tmillisatoshi, tu32, tu64overflow}
2525
import fr.acinq.eclair.{EncodedNodeId, TimestampSecond, UInt64}
26-
import scodec.Codec
26+
import scodec.{Attempt, Codec}
2727
import scodec.codecs._
2828

29+
import java.util.Currency
30+
import scala.util.Try
31+
2932
object OfferCodecs {
3033
private val offerChains: Codec[OfferChains] = tlvField(list(blockHash).xmap[Seq[BlockHash]](_.toSeq, _.toList))
3134

3235
private val offerMetadata: Codec[OfferMetadata] = tlvField(bytes)
3336

34-
private val offerCurrency: Codec[OfferCurrency] = tlvField(utf8)
37+
val offerCurrency: Codec[OfferCurrency] =
38+
tlvField(utf8.narrow[Currency](s => Attempt.fromTry(Try{
39+
val c = Currency.getInstance(s)
40+
require(c.getDefaultFractionDigits() >= 0) // getDefaultFractionDigits may return -1 for things that are not currencies
41+
c
42+
}), _.getCurrencyCode()))
3543

36-
private val offerAmount: Codec[OfferAmount] = tlvField(tmillisatoshi)
44+
private val offerAmount: Codec[OfferAmount] = tlvField(tu64overflow)
3745

3846
private val offerDescription: Codec[OfferDescription] = tlvField(utf8)
3947

eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@ import fr.acinq.eclair.crypto.Sphinx.RouteBlinding.BlindedRoute
2323
import fr.acinq.eclair.wire.protocol.CommonCodecs.varint
2424
import fr.acinq.eclair.wire.protocol.OnionRoutingCodecs.{ForbiddenTlv, InvalidTlvPayload, MissingRequiredTlv}
2525
import fr.acinq.eclair.wire.protocol.TlvCodecs.genericTlv
26-
import fr.acinq.eclair.{Bolt12Feature, CltvExpiryDelta, Feature, Features, MilliSatoshi, TimestampSecond, UInt64, nodeFee, randomBytes32}
26+
import fr.acinq.eclair.{Bolt12Feature, CltvExpiryDelta, Feature, Features, MilliSatoshi, MilliSatoshiLong, TimestampSecond, UInt64, nodeFee, randomBytes32}
2727
import scodec.Codec
2828
import scodec.bits.ByteVector
2929
import scodec.codecs.vector
3030

31+
import java.util.Currency
3132
import scala.util.{Failure, Try}
3233

3334
/**
@@ -71,12 +72,12 @@ object OfferTypes {
7172
/**
7273
* Three-letter code of the currency the offer is denominated in. If empty, bitcoin is implied.
7374
*/
74-
case class OfferCurrency(iso4217: String) extends OfferTlv
75+
case class OfferCurrency(currency: Currency) extends OfferTlv
7576

7677
/**
77-
* Amount to pay per item. As we only support bitcoin, the amount is in msat.
78+
* Amount to pay per item.
7879
*/
79-
case class OfferAmount(amount: MilliSatoshi) extends OfferTlv
80+
case class OfferAmount(amount: Long) extends OfferTlv
8081

8182
/**
8283
* Description of the purpose of the payment.
@@ -238,7 +239,7 @@ object OfferTypes {
238239
case class Offer(records: TlvStream[OfferTlv]) {
239240
val chains: Seq[BlockHash] = records.get[OfferChains].map(_.chains).getOrElse(Seq(Block.LivenetGenesisBlock.hash))
240241
val metadata: Option[ByteVector] = records.get[OfferMetadata].map(_.data)
241-
val amount: Option[MilliSatoshi] = records.get[OfferAmount].map(_.amount)
242+
val amount: Option[MilliSatoshi] = if (records.get[OfferCurrency].isEmpty) records.get[OfferAmount].map(_.amount.msat) else None
242243
val description: Option[String] = records.get[OfferDescription].map(_.description)
243244
val features: Features[Bolt12Feature] = records.get[OfferFeatures].map(_.features.bolt12Features()).getOrElse(Features.empty)
244245
val expiry: Option[TimestampSecond] = records.get[OfferAbsoluteExpiry].map(_.absoluteExpiry)
@@ -279,7 +280,7 @@ object OfferTypes {
279280
require(amount_opt.isEmpty || description_opt.nonEmpty)
280281
val tlvs: Set[OfferTlv] = Set(
281282
if (chain != Block.LivenetGenesisBlock.hash) Some(OfferChains(Seq(chain))) else None,
282-
amount_opt.map(OfferAmount),
283+
amount_opt.map(_.toLong).map(OfferAmount),
283284
description_opt.map(OfferDescription),
284285
if (!features.isEmpty) Some(OfferFeatures(features.unscoped())) else None,
285286
Some(OfferNodeId(nodeId)),
@@ -297,7 +298,7 @@ object OfferTypes {
297298
require(amount_opt.isEmpty || description_opt.nonEmpty)
298299
val tlvs: Set[OfferTlv] = Set(
299300
if (chain != Block.LivenetGenesisBlock.hash) Some(OfferChains(Seq(chain))) else None,
300-
amount_opt.map(OfferAmount),
301+
amount_opt.map(_.toLong).map(OfferAmount),
301302
description_opt.map(OfferDescription),
302303
if (!features.isEmpty) Some(OfferFeatures(features.unscoped())) else None,
303304
Some(OfferPaths(paths))
@@ -308,8 +309,6 @@ object OfferTypes {
308309
def validate(records: TlvStream[OfferTlv]): Either[InvalidTlvPayload, Offer] = {
309310
if (records.get[OfferDescription].isEmpty && records.get[OfferAmount].nonEmpty) return Left(MissingRequiredTlv(UInt64(10)))
310311
if (records.get[OfferNodeId].isEmpty && records.get[OfferPaths].forall(_.paths.isEmpty)) return Left(MissingRequiredTlv(UInt64(22)))
311-
// Currency conversion isn't supported yet.
312-
if (records.get[OfferCurrency].nonEmpty) return Left(ForbiddenTlv(UInt64(6)))
313312
if (records.unknown.exists(!isOfferTlv(_))) return Left(ForbiddenTlv(records.unknown.find(!isOfferTlv(_)).get.tag))
314313
Right(Offer(records))
315314
}

eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,22 @@ import fr.acinq.eclair.blockchain.fee.FeeratePerKw
2727
import fr.acinq.eclair.channel.ChannelSpendSignature.IndividualSignature
2828
import fr.acinq.eclair.channel.Helpers.Funding
2929
import fr.acinq.eclair.channel._
30-
import fr.acinq.eclair.crypto.ShaChain
30+
import fr.acinq.eclair.crypto.{ShaChain, Sphinx}
3131
import fr.acinq.eclair.db.OfferData
3232
import fr.acinq.eclair.io.Peer
3333
import fr.acinq.eclair.io.Peer.PeerInfo
3434
import fr.acinq.eclair.payment.{Invoice, PaymentSettlingOnChain}
3535
import fr.acinq.eclair.transactions.Transactions._
3636
import fr.acinq.eclair.transactions.{CommitmentSpec, IncomingHtlc, OutgoingHtlc}
3737
import fr.acinq.eclair.wire.internal.channel.ChannelCodecs
38-
import fr.acinq.eclair.wire.protocol.OfferTypes.Offer
38+
import fr.acinq.eclair.wire.protocol.OfferTypes.{Offer, OfferTlv}
3939
import fr.acinq.eclair.wire.protocol._
4040
import org.scalatest.funsuite.AnyFunSuiteLike
4141
import org.scalatest.matchers.should.Matchers
4242
import scodec.bits._
4343

4444
import java.net.InetAddress
45-
import java.util.UUID
45+
import java.util.{Currency, UUID}
4646

4747
class JsonSerializersSpec extends TestKitBaseClass with AnyFunSuiteLike with Matchers {
4848

@@ -334,6 +334,30 @@ class JsonSerializersSpec extends TestKitBaseClass with AnyFunSuiteLike with Mat
334334
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"}"""
335335
}
336336

337+
test("Bolt 12 offer") {
338+
val minimalOffer = Offer(TlvStream[OfferTlv](OfferTypes.OfferNodeId(PublicKey(hex"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f"))))
339+
JsonSerializers.serialization.write(minimalOffer)(JsonSerializers.formats) shouldBe """{"nodeId":"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f"}"""
340+
341+
val ref = "lno1pqzqzltcgq9q6urvv4shxefqv3hkuct5v58qxrp4qqfquctvd93k2srpvd5kuufwvdh3vggzg2hd49ueds8phzcahvh4p2m3pnen649dza2h3k6gxpaequr8fhtq"
342+
val offer = Offer.decode(ref).get
343+
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"}"""
344+
345+
val bigOffer = Offer(TlvStream(Set[OfferTlv](
346+
OfferTypes.OfferChains(Seq(Block.Testnet4GenesisBlock.hash)),
347+
OfferTypes.OfferMetadata(hex"d5f4a6"),
348+
OfferTypes.OfferCurrency(Currency.getInstance("EUR")),
349+
OfferTypes.OfferAmount(86205),
350+
OfferTypes.OfferDescription("offer with a lot of fields in it"),
351+
OfferTypes.OfferFeatures(Features(Features.ProvideStorage -> FeatureSupport.Mandatory)),
352+
OfferTypes.OfferAbsoluteExpiry(TimestampSecond(3600)),
353+
OfferTypes.OfferPaths(Seq(Sphinx.RouteBlinding.BlindedRoute(EncodedNodeId.WithPublicKey.Plain(PublicKey(hex"022812e3a3760ac989b8749ee9fc70fd12e4d7f3cad5e3e2bf572e9e4eaaa7b7d9")), PublicKey(hex"028a2b20b2debdfd97de08f6e2374f2946116492f358b78acf9eac05f6fdac632d"), Seq(Sphinx.RouteBlinding.BlindedHop(PublicKey(hex"031b27d9e97dbb0ef87c48bb0231c96c6bca1ee54b0e0cfe869ad2388ce247719f"), hex"def5"))))),
354+
OfferTypes.OfferIssuer("bob@bobcorp.com"),
355+
OfferTypes.OfferQuantityMax(5),
356+
OfferTypes.OfferNodeId(PublicKey(hex"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f")),
357+
), Set(GenericTlv(UInt64(71), hex"bd4e85ce"))))
358+
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"}}"""
359+
}
360+
337361
test("Bolt 12 offer data") {
338362
val ref = "lno1pqzqzltcgq9q6urvv4shxefqv3hkuct5v58qxrp4qqfquctvd93k2srpvd5kuufwvdh3vggzg2hd49ueds8phzcahvh4p2m3pnen649dza2h3k6gxpaequr8fhtq"
339363
val offer = OfferData(Offer.decode(ref).get, None, createdAt = TimestampMilli(100), disabledAt_opt = None)

eclair-core/src/test/scala/fr/acinq/eclair/payment/Bolt12InvoiceSpec.scala

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ class Bolt12InvoiceSpec extends AnyFunSuite {
7070
// changing fields makes the signature invalid
7171
val withModifiedUnknownTlv = Bolt12Invoice(invoice.records.copy(unknown = Set(GenericTlv(UInt64(7), hex"ade4"))))
7272
assert(!withModifiedUnknownTlv.checkSignature())
73-
val withModifiedAmount = Bolt12Invoice(TlvStream(invoice.records.records.map { case OfferAmount(amount) => OfferAmount(amount + 100.msat) case x => x }, invoice.records.unknown))
73+
val withModifiedAmount = Bolt12Invoice(TlvStream(invoice.records.records.map { case OfferAmount(amount) => OfferAmount(amount + 100) case x => x }, invoice.records.unknown))
7474
assert(!withModifiedAmount.checkSignature())
7575
}
7676

@@ -92,7 +92,7 @@ class Bolt12InvoiceSpec extends AnyFunSuite {
9292
val invoice = Bolt12Invoice(request, randomBytes32(), nodeKey, 300 seconds, Features.empty, Seq(createPaymentBlindedRoute(nodeKey.publicKey)))
9393
assert(invoice.validateFor(request, nodeKey.publicKey).isRight)
9494
// amount must match the request
95-
val withOtherAmount = signInvoice(Bolt12Invoice(TlvStream(invoice.records.records.map { case OfferAmount(_) => OfferAmount(9000 msat) case x => x })), nodeKey)
95+
val withOtherAmount = signInvoice(Bolt12Invoice(TlvStream(invoice.records.records.map { case OfferAmount(_) => OfferAmount(9000) case x => x })), nodeKey)
9696
assert(withOtherAmount.validateFor(request, nodeKey.publicKey).isLeft)
9797
// description must match the offer
9898
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 {
229229
val tlvs = TlvStream[InvoiceTlv](Set[InvoiceTlv](
230230
InvoiceRequestMetadata(payerInfo),
231231
OfferChains(Seq(chain)),
232-
OfferAmount(amount),
232+
OfferAmount(amount.toLong),
233233
OfferDescription(description),
234234
OfferFeatures(Features.empty),
235235
OfferIssuer(issuer),
@@ -339,7 +339,7 @@ class Bolt12InvoiceSpec extends AnyFunSuite {
339339
val preimage = ByteVector32(hex"99221825b86576e94391b179902be8b22c7cfa7c3d14aec6ae86657dfd9bd2a8")
340340
val offer = Offer(TlvStream[OfferTlv](
341341
OfferChains(Seq(Block.Testnet3GenesisBlock.hash)),
342-
OfferAmount(100000 msat),
342+
OfferAmount(100000),
343343
OfferDescription("offer with quantity"),
344344
OfferIssuer("alice@bigshop.com"),
345345
OfferQuantityMax(1000),

0 commit comments

Comments
 (0)