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/IncomingPaymentHandler.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index 26d96d0ef..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)) @@ -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..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 @@ -17,7 +17,7 @@ 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 kotlinx.coroutines.flow.MutableSharedFlow @@ -153,14 +153,19 @@ 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 { - 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 (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, + 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 +196,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 -> { @@ -233,7 +238,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) } } @@ -249,7 +254,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/payment/OfferPaymentMetadata.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt index cdf124b3d..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 @@ -6,8 +6,11 @@ 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 +import kotlin.experimental.or /** * The flow for Bolt 12 offer payments is the following: @@ -28,7 +31,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,6 +41,7 @@ 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() } @@ -48,6 +53,25 @@ sealed class OfferPaymentMetadata { 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. */ @@ -58,10 +82,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) @@ -86,6 +113,80 @@ 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, + 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) + // 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 + LightningCodecs.writeByte(flags.toInt(), out) + + relativeExpirySeconds?.let { LightningCodecs.writeBigSize(it, out) } + payerKey?.let { LightningCodecs.writeBytes(it.value, out) } + quantity?.let { LightningCodecs.writeBigSize(it, out) } + description?.let { + // 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 { + 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 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) + } + + 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) + } + } + } + companion object { /** * Decode an [OfferPaymentMetadata] encoded using [encode] (e.g. from our payments DB). @@ -95,6 +196,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") } } @@ -103,21 +205,59 @@ 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)) { + return when (LightningCodecs.byte(input)) { 1 -> { 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, 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)) + V1.read(ByteArrayInput(metadata)) } - else -> return null + 2 -> { + val metadataKey = V2.deriveKey(nodeKey, paymentHash) + val nonce = paymentHash.take(12).toByteArray() + val encryptedSize = input.availableBytes - 16 + 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 -> 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/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 3b0a4b0e4..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 @@ -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.nodePrivateKey, pathId, invoice.paymentHash) as OfferPaymentMetadata.V2 } @Test @@ -65,13 +65,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. @@ -100,12 +100,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) @@ -133,7 +133,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) @@ -155,7 +155,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) @@ -183,7 +183,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) @@ -200,7 +200,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) @@ -213,7 +213,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) @@ -231,7 +231,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) @@ -252,7 +252,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) @@ -273,13 +273,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. @@ -298,17 +298,17 @@ class OfferManagerTestsCommon : LightningTestSuite() { } @Test - fun `pay offer with long payer note`() = runSuspendTest { + 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, "tea").first + 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) - 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. @@ -323,8 +323,9 @@ class OfferManagerTestsCommon : LightningTestSuite() { // 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)) + 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 4ba59501b..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 @@ -7,8 +7,10 @@ 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 fun `encode - decode v1 metadata`() { val nodeKey = randomKey() @@ -23,7 +25,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 @@ -40,7 +42,135 @@ 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 + 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, pathId, metadata.paymentHash)) + } + + @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, pathId, metadata.paymentHash)) + } + + @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, pathId, metadata.paymentHash)) + } + + @Test + 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 = 60, + description = "法国很棒", + payerKey = randomKey().publicKey(), + payerNote = "雷击再次", + quantity = null, + ) + assertEquals(metadata, OfferPaymentMetadata.decode(metadata.encode())) + val pathId = metadata.toPathId(nodeKey) + assertEquals(metadata, OfferPaymentMetadata.fromPathId(nodeKey, pathId, metadata.paymentHash)) + } + + @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, pathId, metadata.paymentHash)) + } + + @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 @@ -63,7 +193,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