From 3430052071f4441f13523deeb444fa76f61c6bad Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Wed, 27 Aug 2025 18:10:27 +0200 Subject: [PATCH 1/2] Basic support for offers in fiat We add support for offers denominated in fiat currencies. Users of the library are responsible for computing the amount in fiat using the ISO 4217 exponent and for converting that amount to millisatoshis. --- .../acinq/lightning/json/JsonSerializers.kt | 4 ++-- .../acinq/lightning/payment/Bolt12Invoice.kt | 4 ++-- .../acinq/lightning/payment/OfferManager.kt | 2 +- .../fr/acinq/lightning/wire/OfferTypes.kt | 23 ++++++++++--------- .../payment/Bolt12InvoiceTestsCommon.kt | 8 +++---- .../lightning/wire/OfferTypesTestsCommon.kt | 11 +++++---- 6 files changed, 27 insertions(+), 25 deletions(-) diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt index 7cff2d348..599870cd1 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt @@ -673,7 +673,7 @@ object JsonSerializers { data class OfferSurrogate( val chain: String, val chainHashes: List?, - val amount: MilliSatoshi?, + val amount: Long?, val currency: String?, val issuer: String?, val quantityMax: Long?, @@ -698,7 +698,7 @@ object JsonSerializers { else -> "unknown" }.lowercase(), chainHashes = o.records.get()?.chains, - amount = o.amount, + amount = o.records.get()?.amount, currency = o.currency, issuer = o.issuer, quantityMax = o.quantityMax, diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/Bolt12Invoice.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/Bolt12Invoice.kt index 9affd2ae3..7fb426b6b 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/Bolt12Invoice.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/Bolt12Invoice.kt @@ -108,8 +108,8 @@ data class Bolt12Invoice(val records: TlvStream) : PaymentRequest() additionalTlvs: Set = setOf(), customTlvs: Set = setOf() ): Bolt12Invoice { - require(request.amount != null || request.offer.amount != null) - val amount = request.amount ?: (request.offer.amount!! * request.quantity) + require(request.amount != null || request.offer.amountMsat != null) + val amount = request.amount ?: (request.offer.amountMsat!! * request.quantity) val tlvs: Set = removeSignature(request.records).records + setOfNotNull( InvoicePaths(paths.map { it.route }), InvoiceBlindedPay(paths.map { it.paymentInfo }), diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt index 8e38b2390..9760ebbde 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt @@ -232,7 +232,7 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v private fun isOurOffer(offer: OfferTypes.Offer, pathId: ByteVector?, blindedPrivateKey: PrivateKey): Boolean = when { pathId != null && pathId.size() != 32 -> false else -> { - val expected = deterministicOffer(nodeParams.chainHash, nodeParams.nodePrivateKey, walletParams.trampolineNode.id, offer.amount, offer.description, pathId?.let { ByteVector32(it) }) + val expected = deterministicOffer(nodeParams.chainHash, nodeParams.nodePrivateKey, walletParams.trampolineNode.id, offer.amountMsat, offer.description, pathId?.let { ByteVector32(it) }) expected == Pair(offer, blindedPrivateKey) } } diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt index 807ca43b1..7b11a5d73 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt @@ -133,17 +133,17 @@ object OfferTypes { /** * Amount to pay per item. As we only support bitcoin, the amount is in msat. */ - data class OfferAmount(val amount: MilliSatoshi) : OfferTlv() { + data class OfferAmount(val amount: Long) : OfferTlv() { override val tag: Long get() = OfferAmount.tag override fun write(out: Output) { - LightningCodecs.writeTU64(amount.toLong(), out) + LightningCodecs.writeTU64(amount, out) } companion object : TlvValueReader { const val tag: Long = 8 override fun read(input: Input): OfferAmount { - return OfferAmount(MilliSatoshi(LightningCodecs.tu64(input))) + return OfferAmount(LightningCodecs.tu64(input)) } } } @@ -725,11 +725,12 @@ object OfferTypes { val chains: List = records.get()?.chains ?: listOf(Block.LivenetGenesisBlock.hash) val metadata: ByteVector? = records.get()?.data val currency: String? = records.get()?.iso4217 - val amount: MilliSatoshi? = if (currency == null) { - records.get()?.amount + val amount: Either? = if (currency == null) { + records.get()?.amount?.let { Left(MilliSatoshi(it)) } } else { - null // TODO: add exchange rates + records.get()?.amount?.let { Right(it) } } + val amountMsat: MilliSatoshi? = amount?.left val description: String? = records.get()?.description val features: Features = records.get()?.features?.let { Features(it) } ?: Features.empty val expirySeconds: Long? = records.get()?.absoluteExpirySeconds @@ -774,7 +775,7 @@ object OfferTypes { if (description == null) require(amount == null) { "an offer description must be provided if the amount isn't null" } val tlvs: Set = setOfNotNull( if (chain != Block.LivenetGenesisBlock.hash) OfferChains(listOf(chain)) else null, - amount?.let { OfferAmount(it) }, + amount?.let { OfferAmount(it.toLong()) }, description?.let { OfferDescription(it) }, features.bolt12Features().let { if (it != Features.empty) OfferFeatures(it.toByteArray().toByteVector()) else null }, OfferIssuerId(nodeId) @@ -810,7 +811,7 @@ object OfferTypes { ) val tlvs = setOfNotNull( if (chainHash != Block.LivenetGenesisBlock.hash) OfferChains(listOf(chainHash)) else null, - amount?.let { OfferAmount(it) }, + amount?.let { OfferAmount(it.toLong()) }, description?.let { OfferDescription(it) }, features.bolt12Features().let { if (it != Features.empty) OfferFeatures(it.toByteArray().toByteVector()) else null }, // Note that we don't include an offer_node_id since we're using a blinded path. @@ -878,14 +879,14 @@ object OfferTypes { val quantity_opt: Long? = records.get()?.quantity val quantity: Long = quantity_opt ?: 1 // A valid invoice_request must either specify an amount, or the offer itself must specify an amount. - val requestedAmount: MilliSatoshi = amount ?: (offer.amount!! * quantity) + val requestedAmount: MilliSatoshi = amount ?: (offer.amountMsat!! * quantity) val payerId: PublicKey = records.get()!!.publicKey val payerNote: String? = records.get()?.note private val signature: ByteVector64 = records.get()!!.signature fun isValid(): Boolean = - (offer.amount == null || amount == null || offer.amount * quantity <= amount) && - (offer.amount != null || amount != null) && + (offer.amountMsat == null || amount == null || offer.amountMsat * quantity <= amount) && + (offer.amountMsat != null || amount != null) && offer.chains.contains(chain) && ((offer.quantityMax == null && quantity_opt == null) || (offer.quantityMax != null && quantity_opt != null && quantity <= offer.quantityMax)) && Features.areCompatible(offer.features, features) && diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt12InvoiceTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt12InvoiceTestsCommon.kt index 6ce95cf75..8c57445c4 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt12InvoiceTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/Bolt12InvoiceTestsCommon.kt @@ -109,7 +109,7 @@ class Bolt12InvoiceTestsCommon : LightningTestSuite() { assertFalse(withModifiedUnknownTlv.checkSignature()) val withModifiedAmount = Bolt12Invoice(TlvStream(invoice.records.records.map { when (it) { - is OfferAmount -> OfferAmount(it.amount + 100.msat) + is OfferAmount -> OfferAmount(it.amount + 100) else -> it } }.toSet(), invoice.records.unknown)) @@ -156,7 +156,7 @@ class Bolt12InvoiceTestsCommon : LightningTestSuite() { // amount must match the request val withOtherAmount = signInvoice(Bolt12Invoice(TlvStream(invoice.records.records.map { when (it) { - is OfferAmount -> OfferAmount(9000.msat) + is OfferAmount -> OfferAmount(9000) else -> it } }.toSet())), nodeKey) @@ -356,7 +356,7 @@ class Bolt12InvoiceTestsCommon : LightningTestSuite() { setOf( InvoiceRequestMetadata(payerInfo), OfferChains(listOf(chain)), - OfferAmount(amount), + OfferAmount(amount.toLong()), OfferDescription(description), OfferFeatures(ByteVector.empty), OfferIssuer(issuer), @@ -485,7 +485,7 @@ class Bolt12InvoiceTestsCommon : LightningTestSuite() { val offer = Offer( TlvStream( OfferChains(listOf(Block.Testnet3GenesisBlock.hash)), - OfferAmount(100000.msat), + OfferAmount(100000), OfferDescription("offer with quantity"), OfferIssuer("alice@bigshop.com"), OfferQuantityMax(1000), diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt index b596c89b3..a6adb70d3 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt @@ -5,6 +5,7 @@ import fr.acinq.bitcoin.io.ByteArrayInput import fr.acinq.bitcoin.io.ByteArrayOutput import fr.acinq.bitcoin.io.Input import fr.acinq.bitcoin.io.Output +import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey @@ -71,7 +72,7 @@ class OfferTypesTestsCommon : LightningTestSuite() { val offer = Offer( TlvStream( OfferChains(listOf(Block.Testnet3GenesisBlock.hash)), - OfferAmount(50.msat), + OfferAmount(50), OfferDescription("offer with quantity"), OfferIssuer("alice@bigshop.com"), OfferQuantityMax(0), @@ -80,7 +81,7 @@ class OfferTypesTestsCommon : LightningTestSuite() { ) val encoded = "lno1qgsyxjtl6luzd9t3pr62xr7eemp6awnejusgf6gw45q75vcfqqqqqqqgqyeq5ym0venx2u3qwa5hg6pqw96kzmn5d968jys3v9kxjcm9gp3xjemndphhqtnrdak3gqqkyypsmuhrtwfzm85mht4a3vcp0yrlgua3u3m5uqpc6kf7nqjz6v70qwg" assertEquals(offer, Offer.decode(encoded).get()) - assertEquals(50.msat, offer.amount) + assertEquals(Either.Left(50.msat), offer.amount) assertEquals("offer with quantity", offer.description) assertEquals(nodeId, offer.issuerId) assertEquals("alice@bigshop.com", offer.issuer) @@ -147,7 +148,7 @@ class OfferTypesTestsCommon : LightningTestSuite() { @Test fun `check that invoice request matches offer - without chain`() { - val offer = Offer(TlvStream(OfferAmount(100.msat), OfferDescription("offer without chains"), OfferIssuerId(randomKey().publicKey()))) + val offer = Offer(TlvStream(OfferAmount(100), OfferDescription("offer without chains"), OfferIssuerId(randomKey().publicKey()))) val payerKey = randomKey() val tlvs: Set = offer.records.records + setOf( InvoiceRequestMetadata(ByteVector.fromHex("012345")), @@ -169,7 +170,7 @@ class OfferTypesTestsCommon : LightningTestSuite() { fun `check that invoice request matches offer - with chains`() { val chain1 = BlockHash(randomBytes32()) val chain2 = BlockHash(randomBytes32()) - val offer = Offer(TlvStream(OfferChains(listOf(chain1, chain2)), OfferAmount(100.msat), OfferDescription("offer with chains"), OfferIssuerId(randomKey().publicKey()))) + val offer = Offer(TlvStream(OfferChains(listOf(chain1, chain2)), OfferAmount(100), OfferDescription("offer with chains"), OfferIssuerId(randomKey().publicKey()))) val payerKey = randomKey() val request1 = InvoiceRequest(offer, 100.msat, 1, Features.empty, payerKey, null, chain1) assertTrue(request1.isValid()) @@ -192,7 +193,7 @@ class OfferTypesTestsCommon : LightningTestSuite() { fun `check that invoice request matches offer - multiple items`() { val offer = Offer( TlvStream( - OfferAmount(500.msat), + OfferAmount(500), OfferDescription("offer for multiple items"), OfferIssuerId(randomKey().publicKey()), OfferQuantityMax(10), From c19cd57c9ca4f0e0518daa4fb5eb6f6de3b82536 Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Mon, 1 Sep 2025 09:54:25 +0200 Subject: [PATCH 2/2] test --- .../fr/acinq/lightning/wire/OfferTypesTestsCommon.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt index a6adb70d3..05c6d79e6 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt @@ -28,6 +28,7 @@ import fr.acinq.lightning.wire.OfferTypes.InvoiceRequestTlv import fr.acinq.lightning.wire.OfferTypes.Offer import fr.acinq.lightning.wire.OfferTypes.OfferAmount import fr.acinq.lightning.wire.OfferTypes.OfferChains +import fr.acinq.lightning.wire.OfferTypes.OfferCurrency import fr.acinq.lightning.wire.OfferTypes.OfferDescription import fr.acinq.lightning.wire.OfferTypes.OfferIssuer import fr.acinq.lightning.wire.OfferTypes.OfferIssuerId @@ -615,4 +616,15 @@ class OfferTypesTestsCommon : LightningTestSuite() { assertTrue(Offer.decode(it).isFailure) } } + + @Test + fun `offer with fiat currency`(){ + val offer = Offer(TlvStream( + OfferAmount(123), + OfferCurrency("EUR"), + OfferDescription("offer for 1.23€"), + OfferIssuerId(randomKey().publicKey()))) + val invoiceRequest = InvoiceRequest(offer, 1269486.msat, 1, Features.empty, randomKey(), null, Block.LivenetGenesisBlock.hash) + assertTrue(invoiceRequest.isValid()) + } } \ No newline at end of file