From 02738747ae9904a35b69e465518822889ea39197 Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Mon, 13 Oct 2025 09:24:38 -0500 Subject: [PATCH 1/7] Fixing bugs in OfferPaymentMetadata.V1 by adding V2 --- .../payment/IncomingPaymentHandler.kt | 2 +- .../acinq/lightning/payment/OfferManager.kt | 25 +++- .../lightning/payment/OfferPaymentMetadata.kt | 112 +++++++++++++-- .../payment/OfferManagerTestsCommon.kt | 4 +- .../OfferPaymentMetadataTestsCommon.kt | 129 ++++++++++++++++++ 5 files changed, 257 insertions(+), 15 deletions(-) diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index 26d96d0ef..0089f96c2 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -503,7 +503,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) { logger.warning { "payment with expiry too small: ${paymentPart.htlc.cltvExpiry}, min is ${minFinalCltvExpiry(nodeParams, paymentPart, incomingPayment, currentBlockHeight)}" } Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight)) } - metadata.createdAtMillis + nodeParams.bolt12InvoiceExpiry.inWholeMilliseconds < currentTimestampMillis() && incomingPayment.parts.isEmpty() && !paysPreviousOnTheFlyFunding -> { + metadata.createdAtSeconds + (metadata.relativeExpirySeconds ?: nodeParams.bolt12InvoiceExpiry.inWholeSeconds) < currentTimestampSeconds() && incomingPayment.parts.isEmpty() && !paysPreviousOnTheFlyFunding -> { logger.warning { "the invoice is expired" } Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight)) } 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..8961067c7 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 @@ -18,6 +18,7 @@ import fr.acinq.lightning.message.OnionMessages.Destination import fr.acinq.lightning.message.OnionMessages.IntermediateNode import fr.acinq.lightning.message.OnionMessages.buildMessage import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.lightning.utils.currentTimestampSeconds import fr.acinq.lightning.utils.toByteVector import fr.acinq.lightning.wire.* import kotlinx.coroutines.flow.MutableSharedFlow @@ -153,14 +154,32 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v else -> { val amount = request.requestedAmount val preimage = randomBytes32() - val truncatedPayerNote = (request.payerNote ?: request.offer.description)?.let { + val truncatedPayerNote = request.payerNote?.let { if (it.length <= 64) { it } else { it.take(63) + "…" } } - val metadata = OfferPaymentMetadata.V1(request.offer.offerId, amount, preimage, request.payerId, truncatedPayerNote, request.quantity, currentTimestampMillis()).toPathId(nodeParams.nodePrivateKey) + val truncatedDescription = request.offer.description?.let { + if (it.length <= 64) { + it + } else { + it.take(63) + "…" + } + } + val expirySeconds = request.offer.expirySeconds ?: nodeParams.bolt12InvoiceExpiry.inWholeSeconds + val metadata = OfferPaymentMetadata.V2( + offerId = request.offer.offerId, + amount = amount, + preimage = preimage, + createdAtSeconds = currentTimestampSeconds(), + relativeExpirySeconds = expirySeconds, + description = truncatedDescription, + payerKey = request.payerId, + payerNote = truncatedPayerNote, + quantity = request.quantity_opt + ).toPathId(nodeParams.nodePrivateKey) val recipientPayload = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(metadata))).write().toByteVector() val cltvExpiryDelta = remoteChannelUpdates.maxOfOrNull { it.cltvExpiryDelta } ?: walletParams.invoiceDefaultRoutingFees.cltvExpiryDelta val paymentInfo = OfferTypes.PaymentInfo( @@ -191,7 +210,7 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v ).write().toByteVector() val blindedRoute = RouteBlinding.create(randomKey(), listOf(remoteNodeId, nodeParams.nodeId), listOf(remoteNodePayload, recipientPayload)).route val path = Bolt12Invoice.Companion.PaymentBlindedContactInfo(OfferTypes.ContactInfo.BlindedPath(blindedRoute), paymentInfo) - val invoice = Bolt12Invoice(request, preimage, blindedPrivateKey, nodeParams.bolt12InvoiceExpiry.inWholeSeconds, nodeParams.features.bolt12Features(), listOf(path)) + val invoice = Bolt12Invoice(request, preimage, blindedPrivateKey, expirySeconds, nodeParams.features.bolt12Features(), listOf(path)) val destination = Destination.BlindedPath(replyPath) when (val invoiceMessage = buildMessage(randomKey(), randomKey(), intermediateNodes(destination), destination, TlvStream(OnionMessagePayloadTlv.Invoice(invoice.records)))) { is Left -> { diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt index cdf124b3d..21fbaf5cf 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt @@ -1,6 +1,7 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.* +import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.io.ByteArrayInput import fr.acinq.bitcoin.io.ByteArrayOutput import fr.acinq.bitcoin.io.Input @@ -8,6 +9,9 @@ import fr.acinq.bitcoin.io.Output import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.utils.msat import fr.acinq.lightning.wire.LightningCodecs +import kotlin.experimental.and +import kotlin.experimental.or +import kotlin.math.min /** * The flow for Bolt 12 offer payments is the following: @@ -28,7 +32,8 @@ sealed class OfferPaymentMetadata { abstract val offerId: ByteVector32 abstract val amount: MilliSatoshi abstract val preimage: ByteVector32 - abstract val createdAtMillis: Long + abstract val createdAtSeconds: Long + abstract val relativeExpirySeconds: Long? val paymentHash: ByteVector32 get() = preimage.sha256() /** Encode into a format that can be stored in the payments DB. */ @@ -37,17 +42,16 @@ sealed class OfferPaymentMetadata { LightningCodecs.writeByte(this.version.toInt(), out) when (this) { is V1 -> this.write(out) + is V2 -> this.write(out) } return out.toByteArray().byteVector() } /** Encode into a path_id that must be included in the [Bolt12Invoice]'s blinded path. */ - fun toPathId(nodeKey: PrivateKey): ByteVector = when (this) { - is V1 -> { - val encoded = this.encode() - val signature = Crypto.sign(Crypto.sha256(encoded), nodeKey) - encoded + signature - } + fun toPathId(nodeKey: PrivateKey): ByteVector { + val encoded = this.encode() + val signature = Crypto.sign(Crypto.sha256(encoded), nodeKey) + return encoded + signature } /** In this first version, we simply sign the payment metadata to verify its authenticity when receiving the payment. */ @@ -58,10 +62,13 @@ sealed class OfferPaymentMetadata { val payerKey: PublicKey, val payerNote: String?, val quantity: Long, - override val createdAtMillis: Long + val createdAtMillis: Long ) : OfferPaymentMetadata() { override val version: Byte get() = 1 + override val createdAtSeconds: Long get() = createdAtMillis / 1000 + override val relativeExpirySeconds: Long? get() = null + fun write(out: Output) { LightningCodecs.writeBytes(offerId, out) LightningCodecs.writeU64(amount.toLong(), out) @@ -73,6 +80,8 @@ sealed class OfferPaymentMetadata { } companion object { + val minLength: Int get() = 121 + fun read(input: Input): V1 { val offerId = LightningCodecs.bytes(input, 32).byteVector32() val amount = LightningCodecs.u64(input).msat @@ -86,6 +95,78 @@ sealed class OfferPaymentMetadata { } } + data class V2( + override val offerId: ByteVector32, + override val amount: MilliSatoshi, + override val preimage: ByteVector32, + override val createdAtSeconds: Long, + override val relativeExpirySeconds: Long?, + val description: String?, + val payerKey: PublicKey?, + val payerNote: String?, + val quantity: Long?, + + ) : OfferPaymentMetadata() { + override val version: Byte get() = 2 + + fun write(out: Output) { + LightningCodecs.writeBytes(offerId, out) + LightningCodecs.writeBigSize(amount.toLong(), out) + LightningCodecs.writeBytes(preimage, out) + LightningCodecs.writeBigSize(createdAtSeconds, out) + + var flags: Byte = 0 + if (relativeExpirySeconds != null) { flags = flags or 0b00001 } + if (payerKey != null) { flags = flags or 0b00010 } + if (quantity != null) { flags = flags or 0b00100 } + if (description != null) { flags = flags or 0b01000 } + if (payerNote != null) { flags = flags or 0b10000 } + LightningCodecs.writeByte(flags.toInt(), out) + + relativeExpirySeconds?.let { LightningCodecs.writeBigSize(it, out) } + payerKey?.let { LightningCodecs.writeBytes(it.value, out) } + quantity?.let { LightningCodecs.writeU64(it, out) } + description?.let { + if (payerNote != null) { LightningCodecs.writeBigSize(it.length.toLong(), out) } + LightningCodecs.writeBytes(it.encodeToByteArray(), out) + } + payerNote?.let { + LightningCodecs.writeBytes(it.encodeToByteArray(), out) + } + } + + companion object { + val minLength: Int get() = 67 + + fun read(input: Input): V2 { + val offerId = LightningCodecs.bytes(input, 32).byteVector32() + val amount = LightningCodecs.bigSize(input).msat + val preimage = LightningCodecs.bytes(input, 32).byteVector32() + val createdAtSeconds = LightningCodecs.bigSize(input) + val flags = LightningCodecs.byte(input).toByte() + + val hasExp = (flags and 0b00001) != 0.toByte() + val hasPKey = (flags and 0b00010) != 0.toByte() + val hasQnty = (flags and 0b00100) != 0.toByte() + val hasDesc = (flags and 0b01000) != 0.toByte() + val hasPNote = (flags and 0b10000) != 0.toByte() + + val relativeExpirySeconds = if (hasExp) { LightningCodecs.bigSize(input) } else { null } + val payerKey = if (hasPKey) { PublicKey(LightningCodecs.bytes(input, 33)) } else { null } + val quantity = if (hasQnty) { LightningCodecs.u64(input) } else { null } + val description = if (hasDesc) { + val strLen = if (hasPNote) { LightningCodecs.bigSize(input).toInt() } else { input.availableBytes } + LightningCodecs.bytes(input, strLen).decodeToString() + } else { null } + val payerNote = if (hasPNote) { + if (input.availableBytes > 0) { LightningCodecs.bytes(input, input.availableBytes).decodeToString() } else { "" } + } else { null } + + return V2(offerId, amount, preimage, createdAtSeconds, relativeExpirySeconds, description, payerKey, payerNote, quantity) + } + } + } + companion object { /** * Decode an [OfferPaymentMetadata] encoded using [encode] (e.g. from our payments DB). @@ -95,6 +176,7 @@ sealed class OfferPaymentMetadata { val input = ByteArrayInput(encoded.toByteArray()) return when (val version = LightningCodecs.byte(input)) { 1 -> V1.read(input) + 2 -> V2.read(input) else -> throw IllegalArgumentException("unknown offer payment metadata version: $version") } } @@ -108,7 +190,8 @@ sealed class OfferPaymentMetadata { val input = ByteArrayInput(pathId.toByteArray()) when (LightningCodecs.byte(input)) { 1 -> { - if (input.availableBytes < 185) return null + val minimum = V1.minLength + 64 + if (input.availableBytes < minimum) return null val metadataSize = input.availableBytes - 64 val metadata = LightningCodecs.bytes(input, metadataSize) val signature = LightningCodecs.bytes(input, 64).byteVector64() @@ -117,6 +200,17 @@ sealed class OfferPaymentMetadata { // This call is safe since we verified that we have the right number of bytes and the signature was valid. return V1.read(ByteArrayInput(metadata)) } + 2 -> { + val minimum = V2.minLength + 64 + if (input.availableBytes < minimum) return null + val metadataSize = input.availableBytes - 64 + val metadata = LightningCodecs.bytes(input, metadataSize) + val signature = LightningCodecs.bytes(input, 64).byteVector64() + // Note that the signature includes the version byte. + if (!Crypto.verifySignature(Crypto.sha256(pathId.take(1 + metadataSize)), signature, nodeId)) return null + // This call is safe since we verified that we have the right number of bytes and the signature was valid. + return V2.read(ByteArrayInput(metadata)) + } else -> return null } } diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt index 3b0a4b0e4..e510727aa 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt @@ -51,13 +51,13 @@ class OfferManagerTestsCommon : LightningTestSuite() { return Pair(OnionMessage(relayInfo.nextPathKeyOverride ?: nextBlinding, decrypted.value.nextPacket), nextNode) } - private fun decryptPathId(invoice: Bolt12Invoice, trampolineKey: PrivateKey): OfferPaymentMetadata.V1 { + private fun decryptPathId(invoice: Bolt12Invoice, trampolineKey: PrivateKey): OfferPaymentMetadata.V2 { val blindedRoute = invoice.blindedPaths.first().route.route assertEquals(2, blindedRoute.encryptedPayloads.size) val (_, nextBlinding) = RouteBlinding.decryptPayload(trampolineKey, blindedRoute.firstPathKey, blindedRoute.encryptedPayloads.first()).right!! val (lastPayload, _) = RouteBlinding.decryptPayload(TestConstants.Alice.nodeParams.nodePrivateKey, nextBlinding, blindedRoute.encryptedPayloads.last()).right!! val pathId = RouteBlindingEncryptedData.read(lastPayload.toByteArray()).right!!.pathId!! - return OfferPaymentMetadata.fromPathId(TestConstants.Alice.nodeParams.nodeId, pathId) as OfferPaymentMetadata.V1 + return OfferPaymentMetadata.fromPathId(TestConstants.Alice.nodeParams.nodeId, pathId) as OfferPaymentMetadata.V2 } @Test diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt index 4ba59501b..afdb724e8 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt @@ -3,10 +3,13 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.ByteVector import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey +import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.lightning.utils.currentTimestampSeconds import fr.acinq.lightning.utils.msat import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue class OfferPaymentMetadataTestsCommon { @Test @@ -43,6 +46,132 @@ class OfferPaymentMetadataTestsCommon { assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey.publicKey(), pathId)) } + @Test + fun `encode - decode v2 metadata`() { + val nodeKey = randomKey() + val metadata = OfferPaymentMetadata.V2( + offerId = randomBytes32(), + amount = 50_000_000.msat, + preimage = randomBytes32(), + createdAtSeconds = 0, + relativeExpirySeconds = null, + description = null, + payerKey = null, + payerNote = null, + quantity = null + ) + assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) + val pathId = metadata.toPathId(nodeKey) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey.publicKey(), pathId)) + } + + @Test + fun `encode - decode v2 metadata with description`() { + val nodeKey = randomKey() + val metadata = OfferPaymentMetadata.V2( + offerId = randomBytes32(), + amount = 100_000_000.msat, + preimage = randomBytes32(), + createdAtSeconds = 0, + relativeExpirySeconds = null, + description = "Invoice #: 152043", + payerKey = randomKey().publicKey(), + payerNote = null, + quantity = null, + ) + assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) + val pathId = metadata.toPathId(nodeKey) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey.publicKey(), pathId)) + } + + @Test + fun `encode - decode v2 metadata with payer note`() { + val nodeKey = randomKey() + val metadata = OfferPaymentMetadata.V2( + offerId = randomBytes32(), + amount = 100_000_000.msat, + preimage = randomBytes32(), + createdAtSeconds = 0, + relativeExpirySeconds = null, + description = null, + payerKey = randomKey().publicKey(), + payerNote = "Thanks for all the fish", + quantity = 42, + ) + assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) + val pathId = metadata.toPathId(nodeKey) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey.publicKey(), pathId)) + } + + @Test + fun `encode - decode v2 metadata with all fields`() { + val nodeKey = randomKey() + val metadata = OfferPaymentMetadata.V2( + offerId = randomBytes32(), + amount = 100_000_000.msat, + preimage = randomBytes32(), + createdAtSeconds = 0, + relativeExpirySeconds = 30, + description = "Invoice #: 152043", + payerKey = randomKey().publicKey(), + payerNote = "Thanks for all the fish", + quantity = 2, + ) + assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) + val pathId = metadata.toPathId(nodeKey) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey.publicKey(), pathId)) + } + + @Test + fun `v2 is smaller than v1 - common case`() { + val metadata1 = OfferPaymentMetadata.V1( + offerId = randomBytes32(), + amount = 50_000_000.msat, + preimage = randomBytes32(), + payerKey = randomKey().publicKey(), + payerNote = null, + quantity = 1, + createdAtMillis = currentTimestampMillis() + ) + val metadata2 = OfferPaymentMetadata.V2( + offerId = randomBytes32(), + amount = 50_000_000.msat, + preimage = randomBytes32(), + payerKey = randomKey().publicKey(), + payerNote = null, + quantity = 1, // actually this would be null, but it's still smaller + createdAtSeconds = currentTimestampSeconds(), + relativeExpirySeconds = null, + description = null, + ) + assertTrue { metadata2.encode().size() < metadata1.encode().size() } + } + + @Test + fun `v2 is smaller than v1 - with payer note`() { + val metadata1 = OfferPaymentMetadata.V1( + offerId = randomBytes32(), + amount = 50_000_000.msat, + preimage = randomBytes32(), + payerKey = randomKey().publicKey(), + payerNote = "Invoice #: 152043", // V1 bug: this should be the description + quantity = 1, + createdAtMillis = currentTimestampMillis() + ) + val metadata2 = OfferPaymentMetadata.V2( + offerId = randomBytes32(), + amount = 50_000_000.msat, + preimage = randomBytes32(), + payerKey = randomKey().publicKey(), + payerNote = null, + quantity = 1, // actually this would be null, but it's still smaller + createdAtSeconds = currentTimestampSeconds(), + relativeExpirySeconds = null, + description = "Invoice #: 152043", + ) + assertTrue { metadata2.encode().size() < metadata1.encode().size() } + } + @Test fun `decode invalid path_id`() { val nodeKey = randomKey() From 2946b6c7e7950ed4a7a1ddd8cdc577f946bb3a73 Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:09:35 -0500 Subject: [PATCH 2/7] Guarantee payerNote + description are not over length 64 --- .../acinq/lightning/payment/OfferManager.kt | 48 ++++++++++----- .../payment/OfferManagerTestsCommon.kt | 60 ++++++++++--------- 2 files changed, 66 insertions(+), 42 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 8961067c7..32b44f4d7 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 @@ -22,6 +22,7 @@ import fr.acinq.lightning.utils.currentTimestampSeconds import fr.acinq.lightning.utils.toByteVector import fr.acinq.lightning.wire.* import kotlinx.coroutines.flow.MutableSharedFlow +import kotlin.math.min sealed class OnionMessageAction { /** Send an outgoing onion message (invoice or invoice_request). */ @@ -154,20 +155,10 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v else -> { val amount = request.requestedAmount val preimage = randomBytes32() - val truncatedPayerNote = request.payerNote?.let { - if (it.length <= 64) { - it - } else { - it.take(63) + "…" - } - } - val truncatedDescription = request.offer.description?.let { - if (it.length <= 64) { - it - } else { - it.take(63) + "…" - } - } + val (truncatedPayerNote, truncatedDescription) = truncateStrings( + request.payerNote, + request.offer.description + ) val expirySeconds = request.offer.expirySeconds ?: nodeParams.bolt12InvoiceExpiry.inWholeSeconds val metadata = OfferPaymentMetadata.V2( offerId = request.offer.offerId, @@ -286,5 +277,34 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v val sessionKey = PrivateKey(Crypto.sha256(tweak + trampolineNodeId.value + nodePrivateKey.value).byteVector32()) return OfferTypes.Offer.createBlindedOffer(chainHash, nodePrivateKey, trampolineNodeId, amount, description, Features.empty, sessionKey, pathId) } + + /** + * Returns a pair of strings, where the combined length is guaranteed to be <= 64. + */ + fun truncateStrings(strA: String?, strB: String?): Pair { + var lenA = min(64, (strA?.length ?: 0)) + var lenB = min(64, (strB?.length ?: 0)) + if (lenA + lenB > 64) { + when { + lenA > 32 && lenB > 32 -> { + lenA = 32 + lenB = 32 + } + lenA > 32 -> { + lenA = 64 - lenB + } + lenB > 32 -> { + lenB = 64 - lenA + } + } + } + val truncatedA = strA?.let { + if (it.length <= lenA) { it } else { it.take(lenA-1) + "…" } + } + val truncatedB = strB?.let { + if (it.length <= lenB) { it } else { it.take(lenB-1) + "…" } + } + return Pair(truncatedA, truncatedB) + } } } diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt index e510727aa..2a7db0199 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt @@ -298,33 +298,37 @@ class OfferManagerTestsCommon : LightningTestSuite() { } @Test - fun `pay offer with long payer note`() = runSuspendTest { - // Alice and Bob use the same trampoline node. - val aliceOfferManager = OfferManager(TestConstants.Alice.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) - val bobOfferManager = OfferManager(TestConstants.Bob.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) - val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, "tea").first - - // Bob sends an invoice request to Alice. - val payerNote = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." - val payOffer = PayOffer(UUID.randomUUID(), randomKey(), payerNote, 5500.msat, offer, 20.seconds) - val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) - assertTrue(invoiceRequests.size == 1) - val (messageForAlice, nextNodeAlice) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) - assertEquals(Either.Right(EncodedNodeId.WithPublicKey.Wallet(TestConstants.Alice.nodeParams.nodeId)), nextNodeAlice) - // Alice sends an invoice back to Bob. - val invoiceResponse = aliceOfferManager.receiveMessage(messageForAlice, listOf(), 0) - assertIs(invoiceResponse) - val (messageForBob, nextNodeBob) = trampolineRelay(invoiceResponse.message, aliceTrampolineKey) - assertEquals(Either.Right(EncodedNodeId.WithPublicKey.Wallet(TestConstants.Bob.nodeParams.nodeId)), nextNodeBob) - val payInvoice = bobOfferManager.receiveMessage(messageForBob, listOf(), 0) - assertIs(payInvoice) - assertEquals(OfferInvoiceReceived(payOffer, payInvoice.invoice), bobOfferManager.eventsFlow.first()) - assertEquals(payOffer, payInvoice.payOffer) - - // The payer note is truncated in the payment metadata. - val metadata = decryptPathId(payInvoice.invoice, aliceTrampolineKey) - assertEquals(64, metadata.payerNote!!.length) - assertEquals(payerNote.take(63), metadata.payerNote.take(63)) + fun `OfferPaymentMetadata with long description or payerNote`() = runSuspendTest { + val longString = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + // Long description + Null payerNote + val (desc1, _) = OfferManager.truncateStrings(longString, null) + assertEquals(64, desc1!!.length) + assertEquals(longString.take(63), desc1.take(63)) + // Null description + Long payerNote + val (_, payerNote2) = OfferManager.truncateStrings(null, longString) + assertEquals(64, payerNote2!!.length) + assertEquals(longString.take(63), payerNote2.take(63)) + // Long description + Long payerNote + val (desc3, payerNote3) = OfferManager.truncateStrings(longString, longString) + assertEquals(32, desc3!!.length) + assertEquals(32, payerNote3!!.length) + assertEquals(longString.take(31), desc3.take(31)) + assertEquals(longString.take(31), payerNote3.take(31)) + // Long description + Short payerNote + val (desc4, payerNote4) = OfferManager.truncateStrings(longString, "tea") + assertEquals(61, desc4!!.length) + assertEquals(3, payerNote4!!.length) + assertEquals(longString.take(60), desc4.take(60)) + assertEquals("tea", payerNote4) + // Short description + Long payerNote + val (desc5, payerNote5) = OfferManager.truncateStrings("tea", longString) + assertEquals(3, desc5!!.length) + assertEquals(61, payerNote5!!.length) + assertEquals("tea", desc5) + assertEquals(longString.take(60), payerNote5.take(60)) + // Short description + Short payerNote + val (desc6, payerNote6) = OfferManager.truncateStrings("tea", "coffee") + assertEquals("tea", desc6) + assertEquals("coffee", payerNote6) } - } \ No newline at end of file From e81d0f925b37f44232013fd2ea7b115004d7f413 Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:42:33 -0500 Subject: [PATCH 3/7] Switching from using a signature to encrypting the data. Copies what t-bast did in another branch. --- .../payment/IncomingPaymentHandler.kt | 2 +- .../lightning/payment/OfferPaymentMetadata.kt | 59 ++++++++++++++----- .../payment/OfferManagerTestsCommon.kt | 2 +- .../OfferPaymentMetadataTestsCommon.kt | 22 ++++--- .../payment/PaymentPacketTestsCommon.kt | 4 +- 5 files changed, 64 insertions(+), 25 deletions(-) diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index 0089f96c2..263f8c919 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -479,7 +479,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) { } is PaymentOnion.FinalPayload.Blinded -> { // We encrypted the payment metadata for ourselves in the blinded path we included in the invoice. - return when (val metadata = OfferPaymentMetadata.fromPathId(nodeParams.nodeId, finalPayload.pathId)) { + return when (val metadata = OfferPaymentMetadata.fromPathId(nodeParams.nodePrivateKey, finalPayload.pathId, paymentPart.paymentHash)) { null -> { logger.warning { "invalid path_id: ${finalPayload.pathId.toHex()}" } Either.Left(rejectPaymentPart(privateKey, paymentPart, null, currentBlockHeight)) diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt index 21fbaf5cf..3305fc9f9 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt @@ -7,6 +7,7 @@ import fr.acinq.bitcoin.io.ByteArrayOutput import fr.acinq.bitcoin.io.Input import fr.acinq.bitcoin.io.Output import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.crypto.ChaCha20Poly1305 import fr.acinq.lightning.utils.msat import fr.acinq.lightning.wire.LightningCodecs import kotlin.experimental.and @@ -48,10 +49,31 @@ sealed class OfferPaymentMetadata { } /** Encode into a path_id that must be included in the [Bolt12Invoice]'s blinded path. */ - fun toPathId(nodeKey: PrivateKey): ByteVector { - val encoded = this.encode() - val signature = Crypto.sign(Crypto.sha256(encoded), nodeKey) - return encoded + signature + fun toPathId(nodeKey: PrivateKey): ByteVector = when (this) { + is V1 -> { + val encoded = this.encode() + val signature = Crypto.sign(Crypto.sha256(encoded), nodeKey) + encoded + signature + } + is V2 -> { + // We only encrypt what comes after the version byte. + val encoded = run { + val out = ByteArrayOutput() + this.write(out) + out.toByteArray() + } + val (encrypted, mac) = run { + val paymentHash = Crypto.sha256(this.preimage).byteVector32() + val metadataKey = V2.deriveKey(nodeKey, paymentHash) + val nonce = paymentHash.take(12).toByteArray() + ChaCha20Poly1305.encrypt(metadataKey.value.toByteArray(), nonce, encoded, paymentHash.toByteArray()) + } + val out = ByteArrayOutput() + out.write(2) // version + out.write(encrypted) + out.write(mac) + out.toByteArray().byteVector() + } } /** In this first version, we simply sign the payment metadata to verify its authenticity when receiving the payment. */ @@ -164,6 +186,11 @@ sealed class OfferPaymentMetadata { return V2(offerId, amount, preimage, createdAtSeconds, relativeExpirySeconds, description, payerKey, payerNote, quantity) } + + fun deriveKey(nodeKey: PrivateKey, paymentHash: ByteVector32): PrivateKey { + val tweak = Crypto.sha256("offer_payment_metadata_v2".encodeToByteArray() + paymentHash.toByteArray() + nodeKey.value.toByteArray()) + return nodeKey * PrivateKey(tweak) + } } } @@ -185,7 +212,7 @@ sealed class OfferPaymentMetadata { * Decode an [OfferPaymentMetadata] stored in a blinded path's path_id field. * @return null if the path_id doesn't contain valid data created by us. */ - fun fromPathId(nodeId: PublicKey, pathId: ByteVector): OfferPaymentMetadata? { + fun fromPathId(nodeKey: PrivateKey, pathId: ByteVector, paymentHash: ByteVector32): OfferPaymentMetadata? { if (pathId.isEmpty()) return null val input = ByteArrayInput(pathId.toByteArray()) when (LightningCodecs.byte(input)) { @@ -196,20 +223,24 @@ sealed class OfferPaymentMetadata { val metadata = LightningCodecs.bytes(input, metadataSize) val signature = LightningCodecs.bytes(input, 64).byteVector64() // Note that the signature includes the version byte. - if (!Crypto.verifySignature(Crypto.sha256(pathId.take(1 + metadataSize)), signature, nodeId)) return null + if (!Crypto.verifySignature(Crypto.sha256(pathId.take(1 + metadataSize)), signature, nodeKey.publicKey())) return null // This call is safe since we verified that we have the right number of bytes and the signature was valid. return V1.read(ByteArrayInput(metadata)) } 2 -> { - val minimum = V2.minLength + 64 + val minimum = V2.minLength + 16 if (input.availableBytes < minimum) return null - val metadataSize = input.availableBytes - 64 - val metadata = LightningCodecs.bytes(input, metadataSize) - val signature = LightningCodecs.bytes(input, 64).byteVector64() - // Note that the signature includes the version byte. - if (!Crypto.verifySignature(Crypto.sha256(pathId.take(1 + metadataSize)), signature, nodeId)) return null - // This call is safe since we verified that we have the right number of bytes and the signature was valid. - return V2.read(ByteArrayInput(metadata)) + val metadataKey = V2.deriveKey(nodeKey, paymentHash) + val nonce = paymentHash.take(12).toByteArray() + val encryptedSize = input.availableBytes - 16 + return try { + val encrypted = LightningCodecs.bytes(input, encryptedSize) + val mac = LightningCodecs.bytes(input, 16) + val decrypted = ChaCha20Poly1305.decrypt(metadataKey.value.toByteArray(), nonce, encrypted, paymentHash.toByteArray(), mac) + V2.read(ByteArrayInput(decrypted)) + } catch (_: Throwable) { + null + } } else -> return null } diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt index 2a7db0199..9b26b052d 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt @@ -57,7 +57,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { val (_, nextBlinding) = RouteBlinding.decryptPayload(trampolineKey, blindedRoute.firstPathKey, blindedRoute.encryptedPayloads.first()).right!! val (lastPayload, _) = RouteBlinding.decryptPayload(TestConstants.Alice.nodeParams.nodePrivateKey, nextBlinding, blindedRoute.encryptedPayloads.last()).right!! val pathId = RouteBlindingEncryptedData.read(lastPayload.toByteArray()).right!!.pathId!! - return OfferPaymentMetadata.fromPathId(TestConstants.Alice.nodeParams.nodeId, pathId) as OfferPaymentMetadata.V2 + return OfferPaymentMetadata.fromPathId(TestConstants.Alice.nodeParams.nodePrivateKey, pathId, invoice.paymentHash) as OfferPaymentMetadata.V2 } @Test diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt index afdb724e8..1677d356e 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt @@ -1,6 +1,9 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.byteVector32 import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.utils.currentTimestampMillis @@ -12,6 +15,11 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class OfferPaymentMetadataTestsCommon { + + private fun paymentHash(preimage: ByteVector32): ByteVector32 = Crypto.sha256(preimage).byteVector32() + private fun OfferPaymentMetadata.V1.paymentHash(): ByteVector32 = paymentHash(preimage) + private fun OfferPaymentMetadata.V2.paymentHash(): ByteVector32 = paymentHash(preimage) + @Test fun `encode - decode v1 metadata`() { val nodeKey = randomKey() @@ -26,7 +34,7 @@ class OfferPaymentMetadataTestsCommon { ) assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) val pathId = metadata.toPathId(nodeKey) - assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey.publicKey(), pathId)) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash())) } @Test @@ -43,7 +51,7 @@ class OfferPaymentMetadataTestsCommon { ) assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) val pathId = metadata.toPathId(nodeKey) - assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey.publicKey(), pathId)) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash())) } @Test @@ -62,7 +70,7 @@ class OfferPaymentMetadataTestsCommon { ) assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) val pathId = metadata.toPathId(nodeKey) - assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey.publicKey(), pathId)) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash())) } @Test @@ -81,7 +89,7 @@ class OfferPaymentMetadataTestsCommon { ) assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) val pathId = metadata.toPathId(nodeKey) - assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey.publicKey(), pathId)) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash())) } @Test @@ -100,7 +108,7 @@ class OfferPaymentMetadataTestsCommon { ) assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) val pathId = metadata.toPathId(nodeKey) - assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey.publicKey(), pathId)) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash())) } @Test @@ -119,7 +127,7 @@ class OfferPaymentMetadataTestsCommon { ) assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) val pathId = metadata.toPathId(nodeKey) - assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey.publicKey(), pathId)) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash())) } @Test @@ -192,7 +200,7 @@ class OfferPaymentMetadataTestsCommon { metadata.toPathId(randomKey()), // signed with different key ) testCases.forEach { - assertNull(OfferPaymentMetadata.fromPathId(nodeKey.publicKey(), it)) + assertNull(OfferPaymentMetadata.fromPathId(nodeKey, it, metadata.paymentHash())) } } } \ No newline at end of file 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 537de1a99..43a30f4fb 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 @@ -380,7 +380,7 @@ class PaymentPacketTestsCommon : LightningTestSuite() { assertIs(payloadD) assertEquals(finalAmount, payloadD.amount) assertEquals(finalExpiry, payloadD.expiry) - val paymentMetadata = OfferPaymentMetadata.fromPathId(d, payloadD.pathId) + val paymentMetadata = OfferPaymentMetadata.fromPathId(privD, payloadD.pathId, addD.paymentHash) assertNotNull(paymentMetadata) assertEquals(offer.offerId, paymentMetadata.offerId) assertEquals(paymentMetadata.paymentHash, invoice.paymentHash) @@ -405,7 +405,7 @@ class PaymentPacketTestsCommon : LightningTestSuite() { assertEquals(finalAmount, payloadD.amount) assertEquals(finalAmount, payloadD.totalAmount) assertEquals(finalExpiry, payloadD.expiry) - assertEquals(paymentMetadata, OfferPaymentMetadata.fromPathId(d, payloadD.pathId)) + assertEquals(paymentMetadata, OfferPaymentMetadata.fromPathId(privD, payloadD.pathId, addD.paymentHash)) } @Test From 8c13942fdeddc66d89981607f9ba348f7629177e Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Mon, 13 Oct 2025 15:27:52 -0500 Subject: [PATCH 4/7] Fixing UTF-8 issues (str.length != UTF-8.length) --- .../acinq/lightning/payment/OfferManager.kt | 33 +++++++++++++------ .../payment/OfferManagerTestsCommon.kt | 29 ++++++++-------- 2 files changed, 38 insertions(+), 24 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 32b44f4d7..edb7884e6 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 @@ -21,6 +21,7 @@ import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.lightning.utils.currentTimestampSeconds import fr.acinq.lightning.utils.toByteVector import fr.acinq.lightning.wire.* +import io.ktor.utils.io.core.toByteArray import kotlinx.coroutines.flow.MutableSharedFlow import kotlin.math.min @@ -279,11 +280,27 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v } /** - * Returns a pair of strings, where the combined length is guaranteed to be <= 64. + * Returns a pair of strings, where the combined size (in UTF-8) is guaranteed to be <= 64 bytes. */ fun truncateStrings(strA: String?, strB: String?): Pair { - var lenA = min(64, (strA?.length ?: 0)) - var lenB = min(64, (strB?.length ?: 0)) + // NB: a string's length != it's UTF-8 encoding size, + // since a single character could be encoded with multiple bytes. + // Also, the ellipsis character (…) is encoded as 3 bytes in UTF-8. + val truncateLength = { str: String, len: Int -> + if (str.length <= len) { str } else { str.take(len-1) + "…" } + } + val truncateBytes = { str: String, len: Int -> + var targetLen = len + var truncated = truncateLength(str, targetLen) + while (truncated.toByteArray().size > len) { + truncated = truncateLength(str, --targetLen) + } + truncated + } + var truncatedA = strA?.let { truncateLength(it, 64) } + var truncatedB = strB?.let { truncateLength(it, 64) } + var lenA = truncatedA?.toByteArray()?.size ?: 0 + var lenB = truncatedB?.toByteArray()?.size ?: 0 if (lenA + lenB > 64) { when { lenA > 32 && lenB > 32 -> { @@ -292,17 +309,13 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v } lenA > 32 -> { lenA = 64 - lenB - } + } lenB > 32 -> { lenB = 64 - lenA } } - } - val truncatedA = strA?.let { - if (it.length <= lenA) { it } else { it.take(lenA-1) + "…" } - } - val truncatedB = strB?.let { - if (it.length <= lenB) { it } else { it.take(lenB-1) + "…" } + truncatedA = strA?.let { truncateBytes(it, lenA) } + truncatedB = strB?.let { truncateBytes(it, lenB) } } return Pair(truncatedA, truncatedB) } diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt index 9b26b052d..0353d9f48 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt @@ -24,6 +24,7 @@ import fr.acinq.lightning.wire.MessageOnion import fr.acinq.lightning.wire.OfferTypes import fr.acinq.lightning.wire.OnionMessage import fr.acinq.lightning.wire.RouteBlindingEncryptedData +import io.ktor.utils.io.core.toByteArray import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlin.test.* @@ -297,38 +298,38 @@ class OfferManagerTestsCommon : LightningTestSuite() { assertEquals(payerNote, metadata.payerNote) } + fun String.byteLength(): Int = this.toByteArray().size + @Test fun `OfferPaymentMetadata with long description or payerNote`() = runSuspendTest { val longString = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." // Long description + Null payerNote val (desc1, _) = OfferManager.truncateStrings(longString, null) - assertEquals(64, desc1!!.length) - assertEquals(longString.take(63), desc1.take(63)) + assertEquals(64, desc1!!.byteLength()) // Null description + Long payerNote val (_, payerNote2) = OfferManager.truncateStrings(null, longString) - assertEquals(64, payerNote2!!.length) - assertEquals(longString.take(63), payerNote2.take(63)) + assertEquals(64, payerNote2!!.byteLength()) // Long description + Long payerNote val (desc3, payerNote3) = OfferManager.truncateStrings(longString, longString) - assertEquals(32, desc3!!.length) - assertEquals(32, payerNote3!!.length) - assertEquals(longString.take(31), desc3.take(31)) - assertEquals(longString.take(31), payerNote3.take(31)) + assertEquals(32, desc3!!.byteLength()) + assertEquals(32, payerNote3!!.byteLength()) // Long description + Short payerNote val (desc4, payerNote4) = OfferManager.truncateStrings(longString, "tea") - assertEquals(61, desc4!!.length) - assertEquals(3, payerNote4!!.length) - assertEquals(longString.take(60), desc4.take(60)) + assertEquals(61, desc4!!.byteLength()) + assertEquals(3, payerNote4!!.byteLength()) assertEquals("tea", payerNote4) // Short description + Long payerNote val (desc5, payerNote5) = OfferManager.truncateStrings("tea", longString) - assertEquals(3, desc5!!.length) - assertEquals(61, payerNote5!!.length) + assertEquals(3, desc5!!.byteLength()) + assertEquals(61, payerNote5!!.byteLength()) assertEquals("tea", desc5) - assertEquals(longString.take(60), payerNote5.take(60)) // Short description + Short payerNote val (desc6, payerNote6) = OfferManager.truncateStrings("tea", "coffee") assertEquals("tea", desc6) assertEquals("coffee", payerNote6) + // String where UTF-8 representation is different than string length. + val trickyLongString = "Â🏀cdefghijklmnopqrstuvwxyz1234567890abcdefghijklmnopqrstuvwxyz中" // str.length = 63 + val (desc7, _) = OfferManager.truncateStrings(trickyLongString, null) + assertTrue(desc7!!.byteLength() <= 64) } } \ No newline at end of file From 7283b17128142449832f1aa12f9798d734f95341 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 14 Oct 2025 13:49:50 +0200 Subject: [PATCH 5/7] Refactor: group offer and signing key in dedicated class This was part of #719 but can be refactored now since #719 has been waiting for review for a long time. --- .../kotlin/fr/acinq/lightning/NodeParams.kt | 5 ++-- .../kotlin/fr/acinq/lightning/io/Peer.kt | 2 +- .../acinq/lightning/payment/OfferManager.kt | 6 ++--- .../fr/acinq/lightning/wire/OfferTypes.kt | 18 +++++-------- .../payment/OfferManagerTestsCommon.kt | 26 +++++++++---------- 5 files changed, 25 insertions(+), 32 deletions(-) diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index 4005edaee..bdef3e95a 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -2,7 +2,6 @@ package fr.acinq.lightning import co.touchlab.kermit.Logger import fr.acinq.bitcoin.Chain -import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.Lightning.nodeFee @@ -267,7 +266,7 @@ data class NodeParams( * * @return the default offer and the private key that will sign invoices for this offer. */ - fun defaultOffer(trampolineNodeId: PublicKey): Pair = OfferManager.deterministicOffer(chainHash, nodePrivateKey, trampolineNodeId, null, null, null) + fun defaultOffer(trampolineNodeId: PublicKey): OfferTypes.OfferAndKey = OfferManager.deterministicOffer(chainHash, nodePrivateKey, trampolineNodeId, null, null, null) /** * Generate a random Bolt 12 offer based on the node's seed and its trampoline node. @@ -276,7 +275,7 @@ data class NodeParams( * * @return a random offer and the private key that will sign invoices for this offer. */ - fun randomOffer(trampolineNodeId: PublicKey, amount: MilliSatoshi?, description: String?): Pair { + fun randomOffer(trampolineNodeId: PublicKey, amount: MilliSatoshi?, description: String?): OfferTypes.OfferAndKey { // We generate a random nonce to ensure that this offer is unique. val nonce = randomBytes32() return OfferManager.deterministicOffer(chainHash, nodePrivateKey, trampolineNodeId, amount, description, nonce) diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index 8f1833cd1..58bfeecb8 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -860,7 +860,7 @@ class Peer( .first() .let { event -> replyTo.complete(event.address) } } - peerConnection?.send(DNSAddressRequest(nodeParams.chainHash, nodeParams.defaultOffer(walletParams.trampolineNode.id).first, languageSubtag)) + peerConnection?.send(DNSAddressRequest(nodeParams.chainHash, nodeParams.defaultOffer(walletParams.trampolineNode.id).offer, languageSubtag)) return replyTo.await() } 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 edb7884e6..bc3262712 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 @@ -17,13 +17,11 @@ import fr.acinq.lightning.message.OnionMessages import fr.acinq.lightning.message.OnionMessages.Destination import fr.acinq.lightning.message.OnionMessages.IntermediateNode import fr.acinq.lightning.message.OnionMessages.buildMessage -import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.lightning.utils.currentTimestampSeconds import fr.acinq.lightning.utils.toByteVector import fr.acinq.lightning.wire.* import io.ktor.utils.io.core.toByteArray import kotlinx.coroutines.flow.MutableSharedFlow -import kotlin.math.min sealed class OnionMessageAction { /** Send an outgoing onion message (invoice or invoice_request). */ @@ -244,7 +242,7 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v 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) }) - expected == Pair(offer, blindedPrivateKey) + expected == OfferTypes.OfferAndKey(offer, blindedPrivateKey) } } @@ -260,7 +258,7 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v amount: MilliSatoshi?, description: String?, pathId: ByteVector32?, - ): Pair { + ): OfferTypes.OfferAndKey { // We generate a deterministic session key based on: // - a custom tag indicating that this is used in the Bolt 12 context // - the offer parameters (amount, description and pathId) 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 bec637b86..b98ea5188 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 @@ -726,6 +726,9 @@ object OfferTypes { } } + /** A bolt 12 offer and the private key used to sign invoices for that offer. */ + data class OfferAndKey(val offer: Offer, val privateKey: PrivateKey) + data class Offer(val records: TlvStream) { val chains: List = records.get()?.chains ?: listOf(Block.LivenetGenesisBlock.hash) val metadata: ByteVector? = records.get()?.data @@ -806,7 +809,7 @@ object OfferTypes { features: Features, blindedPathSessionKey: PrivateKey, pathId: ByteVector? = null, - ): Pair { + ): OfferAndKey { require(amount == null || description != null) { "an offer description must be provided if the amount isn't null" } val blindedRouteDetails = OnionMessages.buildRouteToRecipient( blindedPathSessionKey, @@ -821,7 +824,7 @@ object OfferTypes { // Note that we don't include an offer_node_id since we're using a blinded path. OfferPaths(listOf(ContactInfo.BlindedPath(blindedRouteDetails.route))), ) - return Pair(Offer(TlvStream(tlvs)), blindedRouteDetails.blindedPrivateKey(nodePrivateKey)) + return OfferAndKey(Offer(TlvStream(tlvs)), blindedRouteDetails.blindedPrivateKey(nodePrivateKey)) } fun validate(records: TlvStream): Either { @@ -896,13 +899,7 @@ object OfferTypes { Features.areCompatible(offer.features, features) && checkSignature() - fun checkSignature(): Boolean = - verifySchnorr( - signatureTag, - rootHash(removeSignature(records)), - signature, - payerId - ) + fun checkSignature(): Boolean = verifySchnorr(signatureTag, rootHash(removeSignature(records)), signature, payerId) fun encode(): String { val data = tlvSerializer.write(records) @@ -915,8 +912,7 @@ object OfferTypes { companion object { val hrp = "lnr" - val signatureTag: ByteVector = - ByteVector(("lightning" + "invoice_request" + "signature").encodeToByteArray()) + val signatureTag: ByteVector = ByteVector(("lightning" + "invoice_request" + "signature").encodeToByteArray()) /** * Create a request to fetch an invoice for a given offer. diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt index 0353d9f48..f39d58d60 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt @@ -66,13 +66,13 @@ class OfferManagerTestsCommon : LightningTestSuite() { // Alice and Bob use the same trampoline node. val aliceOfferManager = OfferManager(TestConstants.Alice.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) val bobOfferManager = OfferManager(TestConstants.Bob.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) - val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), 1000.msat, "test offer").first + val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), 1000.msat, "test offer").offer // Bob sends an invoice request to Alice. val currentBlockHeight = 0 val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, 20.seconds) val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) - assertTrue(invoiceRequests.size == 1) + assertEquals(invoiceRequests.size, 1) val (messageForAlice, nextNodeAlice) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) assertEquals(Either.Right(EncodedNodeId.WithPublicKey.Wallet(TestConstants.Alice.nodeParams.nodeId)), nextNodeAlice) // Alice sends an invoice back to Bob. @@ -101,12 +101,12 @@ class OfferManagerTestsCommon : LightningTestSuite() { // Alice and Bob use different trampoline nodes. val aliceOfferManager = OfferManager(TestConstants.Alice.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) val bobOfferManager = OfferManager(TestConstants.Bob.nodeParams, bobWalletParams, MutableSharedFlow(replay = 10), logger) - val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, null).first + val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, null).offer // Bob sends an invoice request to Alice. val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, 20.seconds) val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) - assertTrue(invoiceRequests.size == 1) + assertEquals(invoiceRequests.size, 1) val (messageForAliceTrampoline, nextNodeAliceTrampoline) = trampolineRelay(invoiceRequests.first(), bobTrampolineKey) assertEquals(Either.Right(EncodedNodeId(aliceTrampolineKey.publicKey())), nextNodeAliceTrampoline) val (messageForAlice, nextNodeAlice) = trampolineRelay(messageForAliceTrampoline, aliceTrampolineKey) @@ -134,7 +134,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { fun `invoice request timed out`() = runSuspendTest { val aliceOfferManager = OfferManager(TestConstants.Alice.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) val bobOfferManager = OfferManager(TestConstants.Bob.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) - val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, "amountless offer").first + val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, "amountless offer").offer // Bob sends an invoice request to Alice. val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, 20.seconds) @@ -156,7 +156,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { fun `duplicate invoice request`() = runSuspendTest { val aliceOfferManager = OfferManager(TestConstants.Alice.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) val bobOfferManager = OfferManager(TestConstants.Bob.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) - val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, "deterministic amountless offer").first + val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, "deterministic amountless offer").offer // Bob sends two invoice requests to Alice. val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, 20.seconds) @@ -184,7 +184,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { run { // Bob sends an invalid invoice request to Alice. - val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, null).first + val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, null).offer val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, 20.seconds) val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) val (messageForAlice, _) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) @@ -201,7 +201,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { Features.empty, blindedPathSessionKey = randomKey(), pathId = randomBytes32() - ).first + ).offer val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, 20.seconds) val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) val (messageForAlice, _) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) @@ -214,7 +214,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { // Alice and Bob use the same trampoline node. val aliceOfferManager = OfferManager(TestConstants.Alice.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) val bobOfferManager = OfferManager(TestConstants.Bob.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) - val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, "tip").first + val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, "tip").offer // Bob sends an invoice request to Alice. val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, 20.seconds) @@ -232,7 +232,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { // Alice and Bob use the same trampoline node. val aliceOfferManager = OfferManager(TestConstants.Alice.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) val bobOfferManager = OfferManager(TestConstants.Bob.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) - val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), 50_000.msat, "coffee").first + val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), 50_000.msat, "coffee").offer // Bob sends an invoice request to Alice that pays less than the offer amount. val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 40_000.msat, offer, 20.seconds) @@ -253,7 +253,7 @@ class OfferManagerTestsCommon : LightningTestSuite() { // Alice and Bob use the same trampoline node. val aliceOfferManager = OfferManager(TestConstants.Alice.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) val bobOfferManager = OfferManager(TestConstants.Bob.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) - val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, null).first + val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, null).offer // Bob sends an invoice request to Alice that pays less than the minimum htlc amount. val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 10.msat, offer, 20.seconds) @@ -274,13 +274,13 @@ class OfferManagerTestsCommon : LightningTestSuite() { // Alice and Bob use the same trampoline node. val aliceOfferManager = OfferManager(TestConstants.Alice.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) val bobOfferManager = OfferManager(TestConstants.Bob.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) - val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), 1000.msat, "tea").first + val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), 1000.msat, "tea").offer // Bob sends an invoice request to Alice. val payerNote = "Thanks for all the fish" val payOffer = PayOffer(UUID.randomUUID(), randomKey(), payerNote, 5500.msat, offer, 20.seconds) val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) - assertTrue(invoiceRequests.size == 1) + assertEquals(invoiceRequests.size, 1) val (messageForAlice, nextNodeAlice) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) assertEquals(Either.Right(EncodedNodeId.WithPublicKey.Wallet(TestConstants.Alice.nodeParams.nodeId)), nextNodeAlice) // Alice sends an invoice back to Bob. From 8f5cbfcb034e46f391f4ae146d919ca1c1b0d66c Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 14 Oct 2025 15:00:23 +0200 Subject: [PATCH 6/7] Refactor string truncation We move the string truncation helper to `OfferPaymentMetadata` and refactor it to a simpler algorithm. We also make the following nits: - use Kotlin's built-in UTF-8 encoding instead of `ktor` - restore the previous `OfferManager` test that was unnecessarily removed - move truncation unit test to `OfferPaymentMetadataTestsCommon` --- .../acinq/lightning/payment/OfferManager.kt | 47 +------------- .../lightning/payment/OfferPaymentMetadata.kt | 27 +++++++- .../payment/OfferManagerTestsCommon.kt | 62 +++++++++---------- .../OfferPaymentMetadataTestsCommon.kt | 33 ++++++++++ 4 files changed, 88 insertions(+), 81 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 bc3262712..f04d01a12 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 @@ -20,7 +20,6 @@ import fr.acinq.lightning.message.OnionMessages.buildMessage import fr.acinq.lightning.utils.currentTimestampSeconds import fr.acinq.lightning.utils.toByteVector import fr.acinq.lightning.wire.* -import io.ktor.utils.io.core.toByteArray import kotlinx.coroutines.flow.MutableSharedFlow sealed class OnionMessageAction { @@ -154,10 +153,7 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v else -> { val amount = request.requestedAmount val preimage = randomBytes32() - val (truncatedPayerNote, truncatedDescription) = truncateStrings( - request.payerNote, - request.offer.description - ) + val (truncatedPayerNote, truncatedDescription) = OfferPaymentMetadata.truncateNotes(request.payerNote, request.offer.description) val expirySeconds = request.offer.expirySeconds ?: nodeParams.bolt12InvoiceExpiry.inWholeSeconds val metadata = OfferPaymentMetadata.V2( offerId = request.offer.offerId, @@ -276,46 +272,5 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v val sessionKey = PrivateKey(Crypto.sha256(tweak + trampolineNodeId.value + nodePrivateKey.value).byteVector32()) return OfferTypes.Offer.createBlindedOffer(chainHash, nodePrivateKey, trampolineNodeId, amount, description, Features.empty, sessionKey, pathId) } - - /** - * Returns a pair of strings, where the combined size (in UTF-8) is guaranteed to be <= 64 bytes. - */ - fun truncateStrings(strA: String?, strB: String?): Pair { - // NB: a string's length != it's UTF-8 encoding size, - // since a single character could be encoded with multiple bytes. - // Also, the ellipsis character (…) is encoded as 3 bytes in UTF-8. - val truncateLength = { str: String, len: Int -> - if (str.length <= len) { str } else { str.take(len-1) + "…" } - } - val truncateBytes = { str: String, len: Int -> - var targetLen = len - var truncated = truncateLength(str, targetLen) - while (truncated.toByteArray().size > len) { - truncated = truncateLength(str, --targetLen) - } - truncated - } - var truncatedA = strA?.let { truncateLength(it, 64) } - var truncatedB = strB?.let { truncateLength(it, 64) } - var lenA = truncatedA?.toByteArray()?.size ?: 0 - var lenB = truncatedB?.toByteArray()?.size ?: 0 - if (lenA + lenB > 64) { - when { - lenA > 32 && lenB > 32 -> { - lenA = 32 - lenB = 32 - } - lenA > 32 -> { - lenA = 64 - lenB - } - lenB > 32 -> { - lenB = 64 - lenA - } - } - truncatedA = strA?.let { truncateBytes(it, lenA) } - truncatedB = strB?.let { truncateBytes(it, lenB) } - } - return Pair(truncatedA, truncatedB) - } } } diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt index 3305fc9f9..849c24b65 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt @@ -1,7 +1,6 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.* -import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.io.ByteArrayInput import fr.acinq.bitcoin.io.ByteArrayOutput import fr.acinq.bitcoin.io.Input @@ -12,7 +11,6 @@ import fr.acinq.lightning.utils.msat import fr.acinq.lightning.wire.LightningCodecs import kotlin.experimental.and import kotlin.experimental.or -import kotlin.math.min /** * The flow for Bolt 12 offer payments is the following: @@ -245,5 +243,30 @@ sealed class OfferPaymentMetadata { else -> return null } } + + /** Truncate a string to at most the provided [len], including an additional "…" character. */ + private fun truncateNote(str: String, len: Int): String = when { + // The ellipsis "…" character uses 3-bytes when encoded as UTF-8. + str.encodeToByteArray().size <= len - 3 -> "$str…" + // We don't know how many bytes the last characters use in UTF-8, so we simply recursively remove characters. + else -> truncateNote(str.dropLast(1), len) + } + + /** Truncates the offer description and payer note, where the combined size (in UTF-8) is guaranteed to be <= 64 bytes. */ + fun truncateNotes(payerNote: String?, description: String?): Pair { + val payerNoteLen = payerNote?.encodeToByteArray()?.size ?: 0 + val descriptionLen = description?.encodeToByteArray()?.size ?: 0 + return when { + // If strings are below the size limit, we don't need to do anything. + payerNoteLen + descriptionLen <= 64 -> Pair(payerNote, description) + // If only one string is provided, this is simple, we directly truncate it to at most 64 bytes. + payerNote == null || description == null -> Pair(payerNote?.let { truncateNote(it, 64) }, description?.let { truncateNote(it, 64) }) + // If both strings exceed 32 bytes, we truncate both to at most 32 bytes each. + payerNoteLen > 32 && descriptionLen > 32 -> Pair(truncateNote(payerNote, 32), truncateNote(description, 32)) + // Otherwise, we only need to truncate the largest one. + payerNoteLen < descriptionLen -> Pair(payerNote, truncateNote(description, 64 - payerNoteLen)) + else -> Pair(truncateNote(payerNote, 64 - descriptionLen), description) + } + } } } diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt index f39d58d60..fe66f7e53 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt @@ -24,7 +24,6 @@ import fr.acinq.lightning.wire.MessageOnion import fr.acinq.lightning.wire.OfferTypes import fr.acinq.lightning.wire.OnionMessage import fr.acinq.lightning.wire.RouteBlindingEncryptedData -import io.ktor.utils.io.core.toByteArray import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlin.test.* @@ -298,38 +297,35 @@ class OfferManagerTestsCommon : LightningTestSuite() { assertEquals(payerNote, metadata.payerNote) } - fun String.byteLength(): Int = this.toByteArray().size - @Test - fun `OfferPaymentMetadata with long description or payerNote`() = runSuspendTest { - val longString = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." - // Long description + Null payerNote - val (desc1, _) = OfferManager.truncateStrings(longString, null) - assertEquals(64, desc1!!.byteLength()) - // Null description + Long payerNote - val (_, payerNote2) = OfferManager.truncateStrings(null, longString) - assertEquals(64, payerNote2!!.byteLength()) - // Long description + Long payerNote - val (desc3, payerNote3) = OfferManager.truncateStrings(longString, longString) - assertEquals(32, desc3!!.byteLength()) - assertEquals(32, payerNote3!!.byteLength()) - // Long description + Short payerNote - val (desc4, payerNote4) = OfferManager.truncateStrings(longString, "tea") - assertEquals(61, desc4!!.byteLength()) - assertEquals(3, payerNote4!!.byteLength()) - assertEquals("tea", payerNote4) - // Short description + Long payerNote - val (desc5, payerNote5) = OfferManager.truncateStrings("tea", longString) - assertEquals(3, desc5!!.byteLength()) - assertEquals(61, payerNote5!!.byteLength()) - assertEquals("tea", desc5) - // Short description + Short payerNote - val (desc6, payerNote6) = OfferManager.truncateStrings("tea", "coffee") - assertEquals("tea", desc6) - assertEquals("coffee", payerNote6) - // String where UTF-8 representation is different than string length. - val trickyLongString = "Â🏀cdefghijklmnopqrstuvwxyz1234567890abcdefghijklmnopqrstuvwxyz中" // str.length = 63 - val (desc7, _) = OfferManager.truncateStrings(trickyLongString, null) - assertTrue(desc7!!.byteLength() <= 64) + fun `pay offer with long payer note and small description`() = runSuspendTest { + // Alice and Bob use the same trampoline node. + val aliceOfferManager = OfferManager(TestConstants.Alice.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) + val bobOfferManager = OfferManager(TestConstants.Bob.nodeParams, aliceWalletParams, MutableSharedFlow(replay = 10), logger) + val offer = TestConstants.Alice.nodeParams.randomOffer(aliceTrampolineKey.publicKey(), null, "this is just tea").offer + + // Bob sends an invoice request to Alice. + val payerNote = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + val payOffer = PayOffer(UUID.randomUUID(), randomKey(), payerNote, 5500.msat, offer, 20.seconds) + val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer) + assertEquals(invoiceRequests.size, 1) + val (messageForAlice, nextNodeAlice) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey) + assertEquals(Either.Right(EncodedNodeId.WithPublicKey.Wallet(TestConstants.Alice.nodeParams.nodeId)), nextNodeAlice) + // Alice sends an invoice back to Bob. + val invoiceResponse = aliceOfferManager.receiveMessage(messageForAlice, listOf(), 0) + assertIs(invoiceResponse) + val (messageForBob, nextNodeBob) = trampolineRelay(invoiceResponse.message, aliceTrampolineKey) + assertEquals(Either.Right(EncodedNodeId.WithPublicKey.Wallet(TestConstants.Bob.nodeParams.nodeId)), nextNodeBob) + val payInvoice = bobOfferManager.receiveMessage(messageForBob, listOf(), 0) + assertIs(payInvoice) + assertEquals(OfferInvoiceReceived(payOffer, payInvoice.invoice), bobOfferManager.eventsFlow.first()) + assertEquals(payOffer, payInvoice.payOffer) + + // The payer note is truncated in the payment metadata. + val metadata = decryptPathId(payInvoice.invoice, aliceTrampolineKey) + assertEquals(metadata.description, "this is just tea") + assertEquals(46, metadata.payerNote!!.length) + assertEquals(48, metadata.payerNote.encodeToByteArray().size) + assertEquals(metadata.payerNote, payerNote.take(45) + "…") } } \ No newline at end of file diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt index 1677d356e..4fc72293c 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt @@ -180,6 +180,39 @@ class OfferPaymentMetadataTestsCommon { assertTrue { metadata2.encode().size() < metadata1.encode().size() } } + @Test + fun `truncate long description or payerNote`() { + val longString = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + // Long description + Null payerNote + val (_, desc1) = OfferPaymentMetadata.truncateNotes(null, longString) + assertEquals(64, desc1!!.encodeToByteArray().size) + // Null description + Long payerNote + val (payerNote2, _) = OfferPaymentMetadata.truncateNotes(longString, null) + assertEquals(64, payerNote2!!.encodeToByteArray().size) + // Long description + Long payerNote + val (payerNote3, desc3) = OfferPaymentMetadata.truncateNotes(longString, longString) + assertEquals(32, desc3!!.encodeToByteArray().size) + assertEquals(32, payerNote3!!.encodeToByteArray().size) + // Long description + Short payerNote + val (payerNote4, desc4) = OfferPaymentMetadata.truncateNotes("tea", longString) + assertEquals(61, desc4!!.encodeToByteArray().size) + assertEquals(3, payerNote4!!.encodeToByteArray().size) + assertEquals("tea", payerNote4) + // Short description + Long payerNote + val (payerNote5, desc5) = OfferPaymentMetadata.truncateNotes(longString, "tea") + assertEquals(3, desc5!!.encodeToByteArray().size) + assertEquals(61, payerNote5!!.encodeToByteArray().size) + assertEquals("tea", desc5) + // Short description + Short payerNote + val (payerNote6, desc6) = OfferPaymentMetadata.truncateNotes("tea", "coffee") + assertEquals("coffee", desc6) + assertEquals("tea", payerNote6) + // String where UTF-8 representation is different than string length. + val trickyLongString = "Â🏀cdefghijklmnopqrstuvwxyz1234567890abcdefghijklmnopqrstuvwxyz中" // str.length = 63 + val (payerNote7, _) = OfferPaymentMetadata.truncateNotes(trickyLongString, null) + assertTrue(payerNote7!!.encodeToByteArray().size <= 64) + } + @Test fun `decode invalid path_id`() { val nodeKey = randomKey() From 4f56364fe3c7864d3724d9eae5cbe6126b59cb24 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 14 Oct 2025 15:34:03 +0200 Subject: [PATCH 7/7] Refactor `OfferPaymentMetadata.V2` We refactor `OfferPaymentMetadata.V2` to use idiomatic Kotlin code and simplify, with the following small changes: - fix a bug in description size encoding (we must use the UTF-8 size, not the string size) and add corresponding unit test - remove unused `minLength`, this only makes sense for v1 where we don't use a `try/catch` - use `bigSize` for quantity (1 byte in almost all cases) - remove unit tests comparing v1 and v2 sizes (they were useful during prototyping to ensure that we didn't introduce a large increase, but they're not useful to continuously run) --- .../lightning/payment/OfferPaymentMetadata.kt | 78 ++++++++-------- .../OfferPaymentMetadataTestsCommon.kt | 88 +++++-------------- 2 files changed, 59 insertions(+), 107 deletions(-) diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt index 849c24b65..4066f2a47 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt @@ -100,8 +100,6 @@ sealed class OfferPaymentMetadata { } companion object { - val minLength: Int get() = 121 - fun read(input: Input): V1 { val offerId = LightningCodecs.bytes(input, 32).byteVector32() val amount = LightningCodecs.u64(input).msat @@ -115,6 +113,7 @@ sealed class OfferPaymentMetadata { } } + /** In this version, we encrypt the payment metadata with a key derived from our seed. */ data class V2( override val offerId: ByteVector32, override val amount: MilliSatoshi, @@ -125,7 +124,6 @@ sealed class OfferPaymentMetadata { val payerKey: PublicKey?, val payerNote: String?, val quantity: Long?, - ) : OfferPaymentMetadata() { override val version: Byte get() = 2 @@ -134,54 +132,51 @@ sealed class OfferPaymentMetadata { LightningCodecs.writeBigSize(amount.toLong(), out) LightningCodecs.writeBytes(preimage, out) LightningCodecs.writeBigSize(createdAtSeconds, out) - + // We use bit flags to track which nullable fields are provided. var flags: Byte = 0 - if (relativeExpirySeconds != null) { flags = flags or 0b00001 } - if (payerKey != null) { flags = flags or 0b00010 } - if (quantity != null) { flags = flags or 0b00100 } - if (description != null) { flags = flags or 0b01000 } - if (payerNote != null) { flags = flags or 0b10000 } + if (relativeExpirySeconds != null) flags = flags or 0b00001 + if (payerKey != null) flags = flags or 0b00010 + if (quantity != null) flags = flags or 0b00100 + if (description != null) flags = flags or 0b01000 + if (payerNote != null) flags = flags or 0b10000 LightningCodecs.writeByte(flags.toInt(), out) relativeExpirySeconds?.let { LightningCodecs.writeBigSize(it, out) } payerKey?.let { LightningCodecs.writeBytes(it.value, out) } - quantity?.let { LightningCodecs.writeU64(it, out) } + quantity?.let { LightningCodecs.writeBigSize(it, out) } description?.let { - if (payerNote != null) { LightningCodecs.writeBigSize(it.length.toLong(), out) } - LightningCodecs.writeBytes(it.encodeToByteArray(), out) - } - payerNote?.let { - LightningCodecs.writeBytes(it.encodeToByteArray(), out) + // If we have both a description and a payer note, we need to encode the size + // to know when the payer note starts. + val encodedDescription = it.encodeToByteArray() + if (payerNote != null) LightningCodecs.writeBigSize(encodedDescription.size.toLong(), out) + LightningCodecs.writeBytes(encodedDescription, out) } + payerNote?.let { LightningCodecs.writeBytes(it.encodeToByteArray(), out) } } companion object { - val minLength: Int get() = 67 - fun read(input: Input): V2 { val offerId = LightningCodecs.bytes(input, 32).byteVector32() val amount = LightningCodecs.bigSize(input).msat val preimage = LightningCodecs.bytes(input, 32).byteVector32() val createdAtSeconds = LightningCodecs.bigSize(input) + // Bit flags indicate which fields are provided. val flags = LightningCodecs.byte(input).toByte() - - val hasExp = (flags and 0b00001) != 0.toByte() - val hasPKey = (flags and 0b00010) != 0.toByte() - val hasQnty = (flags and 0b00100) != 0.toByte() - val hasDesc = (flags and 0b01000) != 0.toByte() - val hasPNote = (flags and 0b10000) != 0.toByte() - - val relativeExpirySeconds = if (hasExp) { LightningCodecs.bigSize(input) } else { null } - val payerKey = if (hasPKey) { PublicKey(LightningCodecs.bytes(input, 33)) } else { null } - val quantity = if (hasQnty) { LightningCodecs.u64(input) } else { null } - val description = if (hasDesc) { - val strLen = if (hasPNote) { LightningCodecs.bigSize(input).toInt() } else { input.availableBytes } - LightningCodecs.bytes(input, strLen).decodeToString() - } else { null } - val payerNote = if (hasPNote) { - if (input.availableBytes > 0) { LightningCodecs.bytes(input, input.availableBytes).decodeToString() } else { "" } - } else { null } - + val hasRelativeExpiry = (flags and 0b00001) != 0.toByte() + val hasPayerKey = (flags and 0b00010) != 0.toByte() + val hasQuantity = (flags and 0b00100) != 0.toByte() + val hasDescription = (flags and 0b01000) != 0.toByte() + val hasPayerNote = (flags and 0b10000) != 0.toByte() + // We can now read nullable fields. + val relativeExpirySeconds = if (hasRelativeExpiry) LightningCodecs.bigSize(input) else null + val payerKey = if (hasPayerKey) PublicKey(LightningCodecs.bytes(input, 33)) else null + val quantity = if (hasQuantity) LightningCodecs.bigSize(input) else null + val description = when { + hasDescription && hasPayerNote -> LightningCodecs.bytes(input, LightningCodecs.bigSize(input).toInt()).decodeToString() + hasDescription -> LightningCodecs.bytes(input, input.availableBytes).decodeToString() + else -> null + } + val payerNote = if (hasPayerNote) LightningCodecs.bytes(input, input.availableBytes).decodeToString() else null return V2(offerId, amount, preimage, createdAtSeconds, relativeExpirySeconds, description, payerKey, payerNote, quantity) } @@ -213,25 +208,22 @@ sealed class OfferPaymentMetadata { fun fromPathId(nodeKey: PrivateKey, pathId: ByteVector, paymentHash: ByteVector32): OfferPaymentMetadata? { if (pathId.isEmpty()) return null val input = ByteArrayInput(pathId.toByteArray()) - when (LightningCodecs.byte(input)) { + return when (LightningCodecs.byte(input)) { 1 -> { - val minimum = V1.minLength + 64 - if (input.availableBytes < minimum) return null + if (input.availableBytes < 185) return null val metadataSize = input.availableBytes - 64 val metadata = LightningCodecs.bytes(input, metadataSize) val signature = LightningCodecs.bytes(input, 64).byteVector64() // Note that the signature includes the version byte. if (!Crypto.verifySignature(Crypto.sha256(pathId.take(1 + metadataSize)), signature, nodeKey.publicKey())) return null // This call is safe since we verified that we have the right number of bytes and the signature was valid. - return V1.read(ByteArrayInput(metadata)) + V1.read(ByteArrayInput(metadata)) } 2 -> { - val minimum = V2.minLength + 16 - if (input.availableBytes < minimum) return null val metadataKey = V2.deriveKey(nodeKey, paymentHash) val nonce = paymentHash.take(12).toByteArray() val encryptedSize = input.availableBytes - 16 - return try { + try { val encrypted = LightningCodecs.bytes(input, encryptedSize) val mac = LightningCodecs.bytes(input, 16) val decrypted = ChaCha20Poly1305.decrypt(metadataKey.value.toByteArray(), nonce, encrypted, paymentHash.toByteArray(), mac) @@ -240,7 +232,7 @@ sealed class OfferPaymentMetadata { null } } - else -> return null + else -> null } } diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt index 4fc72293c..2ea73e115 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadataTestsCommon.kt @@ -1,13 +1,8 @@ package fr.acinq.lightning.payment import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.Crypto -import fr.acinq.bitcoin.byteVector32 import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey -import fr.acinq.lightning.utils.currentTimestampMillis -import fr.acinq.lightning.utils.currentTimestampSeconds import fr.acinq.lightning.utils.msat import kotlin.test.Test import kotlin.test.assertEquals @@ -16,10 +11,6 @@ import kotlin.test.assertTrue class OfferPaymentMetadataTestsCommon { - private fun paymentHash(preimage: ByteVector32): ByteVector32 = Crypto.sha256(preimage).byteVector32() - private fun OfferPaymentMetadata.V1.paymentHash(): ByteVector32 = paymentHash(preimage) - private fun OfferPaymentMetadata.V2.paymentHash(): ByteVector32 = paymentHash(preimage) - @Test fun `encode - decode v1 metadata`() { val nodeKey = randomKey() @@ -34,7 +25,7 @@ class OfferPaymentMetadataTestsCommon { ) assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) val pathId = metadata.toPathId(nodeKey) - assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash())) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash)) } @Test @@ -51,7 +42,7 @@ class OfferPaymentMetadataTestsCommon { ) assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) val pathId = metadata.toPathId(nodeKey) - assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash())) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash)) } @Test @@ -70,7 +61,7 @@ class OfferPaymentMetadataTestsCommon { ) assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) val pathId = metadata.toPathId(nodeKey) - assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash())) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash)) } @Test @@ -89,7 +80,7 @@ class OfferPaymentMetadataTestsCommon { ) assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) val pathId = metadata.toPathId(nodeKey) - assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash())) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash)) } @Test @@ -108,76 +99,45 @@ class OfferPaymentMetadataTestsCommon { ) assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) val pathId = metadata.toPathId(nodeKey) - assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash())) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash)) } @Test - fun `encode - decode v2 metadata with all fields`() { + fun `encode - decode v2 metadata with UTF-8 description and payer note`() { val nodeKey = randomKey() val metadata = OfferPaymentMetadata.V2( offerId = randomBytes32(), amount = 100_000_000.msat, preimage = randomBytes32(), createdAtSeconds = 0, - relativeExpirySeconds = 30, - description = "Invoice #: 152043", + relativeExpirySeconds = 60, + description = "法国很棒", payerKey = randomKey().publicKey(), - payerNote = "Thanks for all the fish", - quantity = 2, + payerNote = "雷击再次", + quantity = null, ) assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) val pathId = metadata.toPathId(nodeKey) - assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash())) - } - - @Test - fun `v2 is smaller than v1 - common case`() { - val metadata1 = OfferPaymentMetadata.V1( - offerId = randomBytes32(), - amount = 50_000_000.msat, - preimage = randomBytes32(), - payerKey = randomKey().publicKey(), - payerNote = null, - quantity = 1, - createdAtMillis = currentTimestampMillis() - ) - val metadata2 = OfferPaymentMetadata.V2( - offerId = randomBytes32(), - amount = 50_000_000.msat, - preimage = randomBytes32(), - payerKey = randomKey().publicKey(), - payerNote = null, - quantity = 1, // actually this would be null, but it's still smaller - createdAtSeconds = currentTimestampSeconds(), - relativeExpirySeconds = null, - description = null, - ) - assertTrue { metadata2.encode().size() < metadata1.encode().size() } + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash)) } @Test - fun `v2 is smaller than v1 - with payer note`() { - val metadata1 = OfferPaymentMetadata.V1( - offerId = randomBytes32(), - amount = 50_000_000.msat, - preimage = randomBytes32(), - payerKey = randomKey().publicKey(), - payerNote = "Invoice #: 152043", // V1 bug: this should be the description - quantity = 1, - createdAtMillis = currentTimestampMillis() - ) - val metadata2 = OfferPaymentMetadata.V2( + fun `encode - decode v2 metadata with all fields`() { + val nodeKey = randomKey() + val metadata = OfferPaymentMetadata.V2( offerId = randomBytes32(), - amount = 50_000_000.msat, + amount = 100_000_000.msat, preimage = randomBytes32(), - payerKey = randomKey().publicKey(), - payerNote = null, - quantity = 1, // actually this would be null, but it's still smaller - createdAtSeconds = currentTimestampSeconds(), - relativeExpirySeconds = null, + createdAtSeconds = 0, + relativeExpirySeconds = 30, description = "Invoice #: 152043", + payerKey = randomKey().publicKey(), + payerNote = "Thanks for all the fish", + quantity = 2, ) - assertTrue { metadata2.encode().size() < metadata1.encode().size() } + assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) + val pathId = metadata.toPathId(nodeKey) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash)) } @Test @@ -233,7 +193,7 @@ class OfferPaymentMetadataTestsCommon { metadata.toPathId(randomKey()), // signed with different key ) testCases.forEach { - assertNull(OfferPaymentMetadata.fromPathId(nodeKey, it, metadata.paymentHash())) + assertNull(OfferPaymentMetadata.fromPathId(nodeKey, it, metadata.paymentHash)) } } } \ No newline at end of file