From 5d4b3ba2c740cfbd9fcc505e45466004e29d0234 Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Tue, 18 Nov 2025 09:41:01 +0100 Subject: [PATCH 1/4] Only allow compact blinded routes for `CardPaymentRequest` --- .../fr/acinq/lightning/crypto/RouteBlinding.kt | 13 ++++++++----- .../fr/acinq/lightning/message/OnionMessages.kt | 8 ++++---- .../lightning/payment/IncomingPaymentPacket.kt | 2 +- .../fr/acinq/lightning/payment/OfferManager.kt | 7 ++++--- .../lightning/message/OnionMessagesTestsCommon.kt | 6 +++--- .../lightning/payment/PaymentPacketTestsCommon.kt | 2 +- 6 files changed, 21 insertions(+), 17 deletions(-) diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/crypto/RouteBlinding.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/crypto/RouteBlinding.kt index a813b60a4..b3b73f138 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/crypto/RouteBlinding.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/crypto/RouteBlinding.kt @@ -108,6 +108,8 @@ object RouteBlinding { return privateKey * PrivateKey(Sphinx.generateKey("blinded_node_id", sharedSecret)) } + data class DecryptedPayload(val data: ByteVector, val nextPathKey: PublicKey, val useCompactRoute: Boolean) + /** * Decrypt the encrypted payload (usually found in the onion) that contains instructions to locate the next node. * @@ -119,12 +121,13 @@ object RouteBlinding { fun decryptPayload( privateKey: PrivateKey, pathKey: PublicKey, - encryptedPayload: ByteVector - ): Either> { + encryptedPayload: ByteVector, + allowCompactFormat: Boolean = false + ): Either { val sharedSecret = Sphinx.computeSharedSecret(pathKey, privateKey) val nextPathKey = Sphinx.blind(pathKey, Sphinx.computeBlindingFactor(pathKey, sharedSecret)) - if (encryptedPayload.isEmpty()) { - return Either.Right(Pair(encryptedPayload, nextPathKey)) + if (encryptedPayload.isEmpty() && allowCompactFormat) { + return Either.Right(DecryptedPayload(encryptedPayload, nextPathKey, useCompactRoute = true)) } else { return try { val rho = Sphinx.generateKey("rho", sharedSecret) @@ -135,7 +138,7 @@ object RouteBlinding { byteArrayOf(), encryptedPayload.takeRight(16).toByteArray() ) - Either.Right(Pair(ByteVector(decrypted), nextPathKey)) + Either.Right(DecryptedPayload(ByteVector(decrypted), nextPathKey, useCompactRoute = false)) } catch (_: Throwable) { Either.Left(CannotDecodeTlv(OnionPaymentPayloadTlv.EncryptedRecipientData.tag)) } diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/message/OnionMessages.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/message/OnionMessages.kt index e4a905e6d..3f975a576 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/message/OnionMessages.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/message/OnionMessages.kt @@ -154,7 +154,7 @@ object OnionMessages { * @param blindedPrivateKey private key of the blinded node id used in our blinded path. * @param pathId path_id that we included in our blinded path for ourselves. */ - data class DecryptedMessage(val content: MessageOnion, val blindedPrivateKey: PrivateKey, val pathId: ByteVector?) + data class DecryptedMessage(val content: MessageOnion, val blindedPrivateKey: PrivateKey, val pathId: ByteVector?, val useCompactRoute: Boolean) fun decryptMessage(privateKey: PrivateKey, msg: OnionMessage, logger: MDCLogger): DecryptedMessage? { val blindedPrivateKey = RouteBlinding.derivePrivateKey(privateKey, msg.pathKey) @@ -166,13 +166,13 @@ object OnionMessages { logger.warning { "ignoring onion message that couldn't be decoded: ${e.message}" } return null } - when (val payload = RouteBlinding.decryptPayload(privateKey, msg.pathKey, message.encryptedData)) { + when (val payload = RouteBlinding.decryptPayload(privateKey, msg.pathKey, message.encryptedData, allowCompactFormat = true)) { is Either.Left -> { logger.warning { "ignoring onion message that couldn't be decrypted: ${payload.value}" } null } is Either.Right -> { - val (decryptedPayload, nextPathKey) = payload.value + val (decryptedPayload, nextPathKey, useCompactRoute) = payload.value when (val relayInfo = RouteBlindingEncryptedData.read(decryptedPayload.toByteArray())) { is Either.Left -> { logger.warning { "ignoring onion message with invalid relay info: ${relayInfo.value}" } @@ -184,7 +184,7 @@ object OnionMessages { val nextMessage = OnionMessage(relayInfo.value.nextPathKeyOverride ?: nextPathKey, decrypted.value.nextPacket) decryptMessage(privateKey, nextMessage, logger) } - decrypted.value.isLastPacket -> DecryptedMessage(message, blindedPrivateKey, relayInfo.value.pathId) + decrypted.value.isLastPacket -> DecryptedMessage(message, blindedPrivateKey, relayInfo.value.pathId, useCompactRoute) else -> { logger.warning { "ignoring onion message for which we're not the destination (next_node_id=${relayInfo.value.nextNodeId}, path_id=${relayInfo.value.pathId?.toHex()})" } null diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt index 63e1d99ec..eba3678b0 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt @@ -92,7 +92,7 @@ object IncomingPaymentPacket { } private fun decryptRecipientData(privateKey: PrivateKey, pathKey: PublicKey, data: ByteVector, tlvs: TlvStream): Either { - return RouteBlinding.decryptPayload(privateKey, pathKey, data) + return RouteBlinding.decryptPayload(privateKey, pathKey, data, allowCompactFormat = false) .flatMap { (decryptedRecipientData, _) -> RouteBlindingEncryptedData.read(decryptedRecipientData.toByteArray()) } .flatMap { blindedTlvs -> PaymentOnion.FinalPayload.Blinded.validate(tlvs, blindedTlvs) } } 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 3efcaa6b8..694dd17f3 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 @@ -81,6 +81,10 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v return OnionMessages.decryptMessage(nodeParams.nodePrivateKey, msg, logger)?.let { decrypted -> val invoiceRequestTlvs = decrypted.content.records.get()?.tlvs when { + decrypted.useCompactRoute -> { + logger.warning { "unhandled compact route" } + null + } invoiceRequestTlvs != null -> when (val invoiceRequest = OfferTypes.InvoiceRequest.validate(invoiceRequestTlvs)) { is Left -> { logger.warning { "received invalid invoice_request: ${invoiceRequest.value}" } @@ -236,9 +240,6 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v /** This function verifies that the offer provided was generated by us. */ private fun isOurOffer(offer: OfferTypes.Offer, pathId: ByteVector?, blindedPrivateKey: PrivateKey): Boolean = when { pathId != null && pathId.size() != 32 -> false - // Compact offers are randomly generated, but they don't store the nonce in the path_id to save space. - // It is instead the wallet's responsibility to store the corresponding blinded public key(s). - pathId == null && nodeParams.compactOfferKeys.value.contains(blindedPrivateKey.publicKey()) -> true else -> { val expected = deterministicOffer(nodeParams.chainHash, nodeParams.nodePrivateKey, walletParams.trampolineNode.id, offer.amount, offer.description, pathId?.let { ByteVector32(it) }) expected == OfferTypes.OfferAndKey(offer, blindedPrivateKey) diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/message/OnionMessagesTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/message/OnionMessagesTestsCommon.kt index bf58a3186..f71ea0ca9 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/message/OnionMessagesTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/message/OnionMessagesTestsCommon.kt @@ -152,7 +152,7 @@ class OnionMessagesTestsCommon { assertEquals(blindedAlice, blindedRoute.blindedHops.first().blindedPublicKey) assertEquals("bae3d9ea2b06efd1b7b9b49b6cdcaad0e789474a6939ffa54ff5ec9224d5b76c", Crypto.sha256(blindingKey.value + sharedSecret).toHexString()) assertEquals("6970e870b473ddbc27e3098bfa45bb1aa54f1f637f803d957e6271d8ffeba89da2665d62123763d9b634e30714144a1c165ac9", blindedRoute.blindedHops.first().encryptedPayload.toHex()) - val decryptedPayload = RouteBlindingEncryptedData.read(RouteBlinding.decryptPayload(alice, blindingKey, blindedRoute.blindedHops.first().encryptedPayload).right!!.first.toByteArray()).right!! + val decryptedPayload = RouteBlindingEncryptedData.read(RouteBlinding.decryptPayload(alice, blindingKey, blindedRoute.blindedHops.first().encryptedPayload).right!!.data.toByteArray()).right!! assertEquals(blindedPayload, decryptedPayload) } @@ -176,7 +176,7 @@ class OnionMessagesTestsCommon { assertEquals(blindedBob, blindedRoute.blindedHops.first().blindedPublicKey) assertEquals("9afb8b2ebc174dcf9e270be24771da7796542398d29d4ff6a4e7b6b4b9205cfe", Crypto.sha256(blindingKey.value + sharedSecret).toHexString()) assertEquals("1630da85e8759b8f3b94d74a539c6f0d870a87cf03d4986175865a2985553c997b560c32613bd9184c1a6d41a37027aabdab5433009d8409a1b638eb90373778a05716af2c2140b3196dca23997cdad4cfa7a7adc8d4", blindedRoute.blindedHops.first().encryptedPayload.toHex()) - val decryptedPayload = RouteBlindingEncryptedData.read(RouteBlinding.decryptPayload(bob, blindingKey, blindedRoute.blindedHops.first().encryptedPayload).right!!.first.toByteArray()).right!! + val decryptedPayload = RouteBlindingEncryptedData.read(RouteBlinding.decryptPayload(bob, blindingKey, blindedRoute.blindedHops.first().encryptedPayload).right!!.data.toByteArray()).right!! assertEquals(blindedPayload, decryptedPayload) assertEquals(blindingOverride, decryptedPayload.nextPathKeyOverride) } @@ -200,7 +200,7 @@ class OnionMessagesTestsCommon { assertEquals(blindedCarol, blindedRoute.blindedHops.first().blindedPublicKey) assertEquals("cc3b918cda6b1b049bdbe469c4dd952935e7c1518dd9c7ed0cd2cd5bc2742b82", Crypto.sha256(blindingKey.value + sharedSecret).toHexString()) assertEquals("8285acbceb37dfb38b877a888900539be656233cd74a55c55344fb068f9d8da365340d21db96fb41b76123207daeafdfb1f571e3fea07a22e10da35f03109a0380b3c69fcbed9c698086671809658761cf65ecbc3c07a2e5", blindedRoute.blindedHops.first().encryptedPayload.toHex()) - val decryptedPayload = RouteBlindingEncryptedData.read(RouteBlinding.decryptPayload(carol, blindingKey, blindedRoute.blindedHops.first().encryptedPayload).right!!.first.toByteArray()).right!! + val decryptedPayload = RouteBlindingEncryptedData.read(RouteBlinding.decryptPayload(carol, blindingKey, blindedRoute.blindedHops.first().encryptedPayload).right!!.data.toByteArray()).right!! assertEquals(blindedPayload, decryptedPayload) } diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt index 43a30f4fb..35fee4ee3 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt @@ -133,7 +133,7 @@ class PaymentPacketTestsCommon : LightningTestSuite() { val pathKey = payload.get()?.publicKey ?: add.pathKey assertNotNull(pathKey) val encryptedData = payload.get()!!.data - val nextPathKey = RouteBlinding.decryptPayload(privateKey, pathKey, encryptedData).map { it.second }.right + val nextPathKey = RouteBlinding.decryptPayload(privateKey, pathKey, encryptedData).map { it.nextPathKey }.right assertNotNull(nextPathKey) return Pair(decrypted.nextPacket, nextPathKey) } From 2cedfb8f679f87d493cbd26e99b5fdcb3dd5176d Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Tue, 18 Nov 2025 09:59:03 +0100 Subject: [PATCH 2/4] Add `CardPaymentRequest` --- .../acinq/lightning/payment/OfferManager.kt | 21 +++++++++++++++++-- .../fr/acinq/lightning/wire/MessageOnion.kt | 12 +++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) 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 694dd17f3..93737abf6 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 @@ -82,8 +82,13 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v val invoiceRequestTlvs = decrypted.content.records.get()?.tlvs when { decrypted.useCompactRoute -> { - logger.warning { "unhandled compact route" } - null + val cardPaymentRequestTlvs = decrypted.content.records.get()?.tlvs + if (cardPaymentRequestTlvs != null && nodeParams.compactOfferKeys.value.contains(decrypted.blindedPrivateKey.publicKey())) { + receiveCardPaymentRequest(cardPaymentRequestTlvs) + } else { + logger.warning { "ignoring unexpected message to compact route" } + null + } } invoiceRequestTlvs != null -> when (val invoiceRequest = OfferTypes.InvoiceRequest.validate(invoiceRequestTlvs)) { is Left -> { @@ -105,6 +110,18 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v } } + private fun receiveCardPaymentRequest(offerTlvs: TlvStream): OnionMessageAction? { + return when (val offer = OfferTypes.Offer.validate(offerTlvs)) { + is Left -> { + logger.warning { "received invalid card payment request: ${offer.value}" } + null + } + is Right -> { + TODO("check authentication from the card and pay offer if we didn't exceed the daily limit") + } + } + } + private suspend fun receiveInvoiceResponse(content: MessageOnion, payOffer: PayOffer, request: OfferTypes.InvoiceRequest): OnionMessageAction.PayInvoice? { return when (val res = Bolt12Invoice.extract(content.records)) { is Bolt12Invoice.Companion.Bolt12ParsingResult.Failure.Malformed -> { diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/MessageOnion.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/MessageOnion.kt index e188fbd24..40fe210af 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/MessageOnion.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/MessageOnion.kt @@ -113,6 +113,18 @@ sealed class OnionMessagePayloadTlv : Tlv { InvoiceError(tlvSerializer.read(input)) } } + + data class CardPaymentRequest(val tlvs: TlvStream) : OnionMessagePayloadTlv() { + override val tag: Long get() = CardPaymentRequest.tag + override fun write(out: Output) = OfferTypes.Offer.tlvSerializer.write(tlvs, out) + + companion object : TlvValueReader { + const val tag: Long = 70 + + override fun read(input: Input): CardPaymentRequest = + CardPaymentRequest(OfferTypes.Offer.tlvSerializer.read(input)) + } + } } data class MessageOnion(val records: TlvStream) { From 3f095908436fbcf80c57a5f43e131de3d1091033 Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Wed, 19 Nov 2025 16:02:57 +0100 Subject: [PATCH 3/4] Revert "Add `CardPaymentRequest`" This reverts commit 2cedfb8f679f87d493cbd26e99b5fdcb3dd5176d. --- .../acinq/lightning/payment/OfferManager.kt | 21 ++----------------- .../fr/acinq/lightning/wire/MessageOnion.kt | 12 ----------- 2 files changed, 2 insertions(+), 31 deletions(-) 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 93737abf6..694dd17f3 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 @@ -82,13 +82,8 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v val invoiceRequestTlvs = decrypted.content.records.get()?.tlvs when { decrypted.useCompactRoute -> { - val cardPaymentRequestTlvs = decrypted.content.records.get()?.tlvs - if (cardPaymentRequestTlvs != null && nodeParams.compactOfferKeys.value.contains(decrypted.blindedPrivateKey.publicKey())) { - receiveCardPaymentRequest(cardPaymentRequestTlvs) - } else { - logger.warning { "ignoring unexpected message to compact route" } - null - } + logger.warning { "unhandled compact route" } + null } invoiceRequestTlvs != null -> when (val invoiceRequest = OfferTypes.InvoiceRequest.validate(invoiceRequestTlvs)) { is Left -> { @@ -110,18 +105,6 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v } } - private fun receiveCardPaymentRequest(offerTlvs: TlvStream): OnionMessageAction? { - return when (val offer = OfferTypes.Offer.validate(offerTlvs)) { - is Left -> { - logger.warning { "received invalid card payment request: ${offer.value}" } - null - } - is Right -> { - TODO("check authentication from the card and pay offer if we didn't exceed the daily limit") - } - } - } - private suspend fun receiveInvoiceResponse(content: MessageOnion, payOffer: PayOffer, request: OfferTypes.InvoiceRequest): OnionMessageAction.PayInvoice? { return when (val res = Bolt12Invoice.extract(content.records)) { is Bolt12Invoice.Companion.Bolt12ParsingResult.Failure.Malformed -> { diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/MessageOnion.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/MessageOnion.kt index 40fe210af..e188fbd24 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/MessageOnion.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/MessageOnion.kt @@ -113,18 +113,6 @@ sealed class OnionMessagePayloadTlv : Tlv { InvoiceError(tlvSerializer.read(input)) } } - - data class CardPaymentRequest(val tlvs: TlvStream) : OnionMessagePayloadTlv() { - override val tag: Long get() = CardPaymentRequest.tag - override fun write(out: Output) = OfferTypes.Offer.tlvSerializer.write(tlvs, out) - - companion object : TlvValueReader { - const val tag: Long = 70 - - override fun read(input: Input): CardPaymentRequest = - CardPaymentRequest(OfferTypes.Offer.tlvSerializer.read(input)) - } - } } data class MessageOnion(val records: TlvStream) { From cfc69d3b4395bc98447fc44822bb6443f5feeb02 Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Wed, 19 Nov 2025 16:26:25 +0100 Subject: [PATCH 4/4] Validate compact offer --- .../kotlin/fr/acinq/lightning/payment/OfferManager.kt | 7 +++---- .../kotlin/fr/acinq/lightning/wire/OfferTypes.kt | 2 ++ 2 files changed, 5 insertions(+), 4 deletions(-) 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 694dd17f3..4522a2ab0 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 @@ -81,10 +81,6 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v return OnionMessages.decryptMessage(nodeParams.nodePrivateKey, msg, logger)?.let { decrypted -> val invoiceRequestTlvs = decrypted.content.records.get()?.tlvs when { - decrypted.useCompactRoute -> { - logger.warning { "unhandled compact route" } - null - } invoiceRequestTlvs != null -> when (val invoiceRequest = OfferTypes.InvoiceRequest.validate(invoiceRequestTlvs)) { is Left -> { logger.warning { "received invalid invoice_request: ${invoiceRequest.value}" } @@ -240,6 +236,9 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v /** This function verifies that the offer provided was generated by us. */ private fun isOurOffer(offer: OfferTypes.Offer, pathId: ByteVector?, blindedPrivateKey: PrivateKey): Boolean = when { pathId != null && pathId.size() != 32 -> false + // Compact offers are randomly generated, but they don't store the nonce in the path_id to save space. + // It is instead the wallet's responsibility to store the corresponding blinded public key(s). + pathId == null && nodeParams.compactOfferKeys.value.contains(blindedPrivateKey.publicKey()) -> offer.isMinimal() else -> { val expected = deterministicOffer(nodeParams.chainHash, nodeParams.nodePrivateKey, walletParams.trampolineNode.id, offer.amount, offer.description, pathId?.let { ByteVector32(it) }) expected == OfferTypes.OfferAndKey(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 78ff36f49..b0c426465 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 @@ -758,6 +758,8 @@ object OfferTypes { val offerId: ByteVector32 = rootHash(records) + fun isMinimal(): Boolean = records.records.filterNot { it is OfferChains || it is OfferPaths || it is OfferIssuerId}.isEmpty() && records.unknown.isEmpty() + companion object { val hrp = "lno"