From a6fce1898edd03929333ed9e21b2e758766f408e Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:48:25 -0500 Subject: [PATCH 1/3] Adding support for compact offers --- .../kotlin/fr/acinq/lightning/NodeParams.kt | 25 +++++++++ .../acinq/lightning/crypto/RouteBlinding.kt | 53 +++++++++++-------- .../acinq/lightning/message/OnionMessages.kt | 6 ++- .../acinq/lightning/payment/OfferManager.kt | 24 ++++++++- .../fr/acinq/lightning/wire/OfferTypes.kt | 4 +- .../lightning/wire/OfferTypesTestsCommon.kt | 6 +++ 6 files changed, 91 insertions(+), 27 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 4daae7404..1a5e7afdf 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.update import kotlin.time.Duration import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes @@ -157,6 +158,7 @@ data class NodeParams( val paymentRecipientExpiryParams: RecipientCltvExpiryParams, val zeroConfPeers: Set, val liquidityPolicy: MutableStateFlow, + val compactOfferKeys: MutableStateFlow>, val usePeerStorage: Boolean, ) { val nodePrivateKey get() = keyManager.nodeKeys.nodeKey.privateKey @@ -247,6 +249,7 @@ data class NodeParams( maxAllowedFeeCredit = 0.msat ) ), + compactOfferKeys = MutableStateFlow(emptySet()), usePeerStorage = true, ) @@ -272,4 +275,26 @@ data class NodeParams( return OfferManager.deterministicOffer(chainHash, nodePrivateKey, trampolineNodeId, amount, description, nonce) } + /** + * Generate a compact Bolt 12 offer based on the node's seed and its trampoline node. + * A compact offer is smaller since it allows the encryptedData payload to be empty for + * the final recipient (us) within the blinded path. This allows it to fit in restricted + * spaces (e.g. bolt card) + * + * Each generated offer is unique, since a random nonce is used to generate the blindedPathSessionKey. + * This offer will stay valid after restoring the seed on a different device: we will + * automatically keep accepting payments for this offer. + * + * The caller must store the returned `OfferAndKey.privateKey.publicKey`, + * and set/update the NodeParams.compactOfferKeys with the value. + * + * @return a compact offer and the private key that will sign invoices for this offer. + */ + fun compactOffer(trampolineNodeId: PublicKey): OfferTypes.OfferAndKey { + // We generate a random nonce to ensure that this offer is unique. + val nonce = randomBytes32() + val result = OfferManager.deterministicCompactOffer(chainHash, nodePrivateKey, trampolineNodeId, nonce) + compactOfferKeys.update { it.plus(result.privateKey.publicKey()) } + return result + } } diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/crypto/RouteBlinding.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/crypto/RouteBlinding.kt index 8fdf9dae7..a813b60a4 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/crypto/RouteBlinding.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/crypto/RouteBlinding.kt @@ -68,9 +68,10 @@ object RouteBlinding { * @param sessionKey session key of the blinded path, the corresponding public key will be the first path key. * @param publicKeys public keys of each node on the route, starting from the introduction point. * @param payloads payloads that should be encrypted for each node on the route. + * @param allowCompactFormat when true, allows the encryptedData to be empty for the final recipient * @return a blinded route. */ - fun create(sessionKey: PrivateKey, publicKeys: List, payloads: List): BlindedRouteDetails { + fun create(sessionKey: PrivateKey, publicKeys: List, payloads: List, allowCompactFormat: Boolean = false): BlindedRouteDetails { require(publicKeys.size == payloads.size) { "a payload must be provided for each node in the blinded path" } var e = sessionKey val (blindedHops, pathKeys) = publicKeys.zip(payloads).map { pair -> @@ -78,15 +79,19 @@ object RouteBlinding { val pathKey = e.publicKey() val sharedSecret = Sphinx.computeSharedSecret(publicKey, e) val blindedPublicKey = Sphinx.blind(publicKey, Sphinx.generateKey("blinded_node_id", sharedSecret)) - val rho = Sphinx.generateKey("rho", sharedSecret) - val (encryptedPayload, mac) = ChaCha20Poly1305.encrypt( - rho.toByteArray(), - Sphinx.zeroes(12), - payload.toByteArray(), - byteArrayOf() - ) e *= PrivateKey(Crypto.sha256(pathKey.value.toByteArray() + sharedSecret.toByteArray())) - Pair(BlindedHop(blindedPublicKey, ByteVector(encryptedPayload + mac)), pathKey) + if (payload.isEmpty() && allowCompactFormat) { + Pair(BlindedHop(blindedPublicKey, payload), pathKey) + } else { + val rho = Sphinx.generateKey("rho", sharedSecret) + val (encryptedPayload, mac) = ChaCha20Poly1305.encrypt( + rho.toByteArray(), + Sphinx.zeroes(12), + payload.toByteArray(), + byteArrayOf() + ) + Pair(BlindedHop(blindedPublicKey, ByteVector(encryptedPayload + mac)), pathKey) + } }.unzip() return BlindedRouteDetails(BlindedRoute(EncodedNodeId(publicKeys.first()), pathKeys.first(), blindedHops), pathKeys.last()) } @@ -117,19 +122,23 @@ object RouteBlinding { encryptedPayload: ByteVector ): Either> { val sharedSecret = Sphinx.computeSharedSecret(pathKey, privateKey) - val rho = Sphinx.generateKey("rho", sharedSecret) - return try { - val decrypted = ChaCha20Poly1305.decrypt( - rho.toByteArray(), - Sphinx.zeroes(12), - encryptedPayload.dropRight(16).toByteArray(), - byteArrayOf(), - encryptedPayload.takeRight(16).toByteArray() - ) - val nextPathKey = Sphinx.blind(pathKey, Sphinx.computeBlindingFactor(pathKey, sharedSecret)) - Either.Right(Pair(ByteVector(decrypted), nextPathKey)) - } catch (_: Throwable) { - Either.Left(CannotDecodeTlv(OnionPaymentPayloadTlv.EncryptedRecipientData.tag)) + val nextPathKey = Sphinx.blind(pathKey, Sphinx.computeBlindingFactor(pathKey, sharedSecret)) + if (encryptedPayload.isEmpty()) { + return Either.Right(Pair(encryptedPayload, nextPathKey)) + } else { + return try { + val rho = Sphinx.generateKey("rho", sharedSecret) + val decrypted = ChaCha20Poly1305.decrypt( + rho.toByteArray(), + Sphinx.zeroes(12), + encryptedPayload.dropRight(16).toByteArray(), + byteArrayOf(), + encryptedPayload.takeRight(16).toByteArray() + ) + Either.Right(Pair(ByteVector(decrypted), nextPathKey)) + } catch (_: Throwable) { + Either.Left(CannotDecodeTlv(OnionPaymentPayloadTlv.EncryptedRecipientData.tag)) + } } } } \ No newline at end of file diff --git a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/message/OnionMessages.kt b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/message/OnionMessages.kt index 716e881a2..e4a905e6d 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/message/OnionMessages.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/message/OnionMessages.kt @@ -55,7 +55,8 @@ object OnionMessages { fun buildRouteToRecipient( sessionKey: PrivateKey, intermediateNodes: List, - recipient: Destination.Recipient + recipient: Destination.Recipient, + allowCompactFormat: Boolean = false ): RouteBlinding. BlindedRouteDetails { val intermediatePayloads = buildIntermediatePayloads(intermediateNodes, recipient.nodeId) val tlvs = setOfNotNull( @@ -66,7 +67,8 @@ object OnionMessages { return RouteBlinding.create( sessionKey, intermediateNodes.map { it.nodeId.publicKey } + recipient.nodeId.publicKey, - intermediatePayloads + lastPayload + intermediatePayloads + lastPayload, + allowCompactFormat ) } 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 f04d01a12..87a659e62 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 @@ -235,8 +235,11 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v /** This function verifies that the offer provided was generated by us. */ private fun isOurOffer(offer: OfferTypes.Offer, pathId: ByteVector?, blindedPrivateKey: PrivateKey): Boolean = when { - pathId != null && pathId.size() != 32 -> false - else -> { + pathId == null -> { // Compact offer + nodeParams.compactOfferKeys.value.contains(blindedPrivateKey.publicKey()) + } + pathId.size() != 32 -> false + else -> { // Deterministic offer val expected = deterministicOffer(nodeParams.chainHash, nodeParams.nodePrivateKey, walletParams.trampolineNode.id, offer.amount, offer.description, pathId?.let { ByteVector32(it) }) expected == OfferTypes.OfferAndKey(offer, blindedPrivateKey) } @@ -272,5 +275,22 @@ 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) } + + fun deterministicCompactOffer( + chainHash: BlockHash, + nodePrivateKey: PrivateKey, + trampolineNodeId: PublicKey, + nonce: ByteVector32 + ): OfferTypes.OfferAndKey { + // We generate a deterministic session key based on: + // - a custom tag indicating that this is used in the Bolt 12 context + // - the compact offer parameters (nonce) + // - our trampoline node, which is used as an introduction node for the offer's blinded path + // - our private key, which ensures that nobody else can generate the same path key secret + val tweak = "bolt 12 compact offer".encodeToByteArray().byteVector() + + Crypto.sha256("offer nonce".encodeToByteArray().byteVector() + nonce) + val sessionKey = PrivateKey(Crypto.sha256(tweak + trampolineNodeId.value + nodePrivateKey.value).byteVector32()) + return OfferTypes.Offer.createBlindedOffer(chainHash, nodePrivateKey, trampolineNodeId, null, null, Features.empty, sessionKey, null, true) + } } } 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 b98ea5188..78ff36f49 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 @@ -809,12 +809,14 @@ object OfferTypes { features: Features, blindedPathSessionKey: PrivateKey, pathId: ByteVector? = null, + allowCompactFormat: Boolean = false ): OfferAndKey { require(amount == null || description != null) { "an offer description must be provided if the amount isn't null" } val blindedRouteDetails = OnionMessages.buildRouteToRecipient( blindedPathSessionKey, listOf(OnionMessages.IntermediateNode(EncodedNodeId.WithPublicKey.Plain(trampolineNodeId))), - OnionMessages.Destination.Recipient(EncodedNodeId.WithPublicKey.Wallet(nodePrivateKey.publicKey()), pathId) + OnionMessages.Destination.Recipient(EncodedNodeId.WithPublicKey.Wallet(nodePrivateKey.publicKey()), pathId), + allowCompactFormat ) val tlvs = setOfNotNull( if (chainHash != Block.LivenetGenesisBlock.hash) OfferChains(listOf(chainHash)) else null, diff --git a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt index 7775a4f11..4fef8e262 100644 --- a/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt +++ b/modules/core/src/commonTest/kotlin/fr/acinq/lightning/wire/OfferTypesTestsCommon.kt @@ -522,6 +522,12 @@ class OfferTypesTestsCommon : LightningTestSuite() { assertEquals(key.publicKey(), path.route.blindedNodeIds.last()) val expectedOffer = Offer.decode("lno1zrxq8pjw7qjlm68mtp7e3yvxee4y5xrgjhhyf2fxhlphpckrvevh50u0qf70a6j2x2akrhazctejaaqr8y4qtzjtjzmfesay6mzr3s789uryuqsr8dpgfgxuk56vh7cl89769zdpdrkqwtypzhu2t8ehp73dqeeq65lsqvlx5pj8mw2kz54p4f6ct66stdfxz0df8nqq7svjjdjn2dv8sz28y7z07yg3vqyfyy8ywevqc8kzp36lhd5cqwlpkg8vdcqsfvz89axkmv5sgdysmwn95tpsct6mdercmz8jh2r82qqscrf6uc3tse5gw5sv5xjdfw8f6c").get() assertEquals(expectedOffer, offer) + val (compactOffer, _) = nodeParams.compactOffer(trampolineNode) + val defaultOfferData = Offer.tlvSerializer.write(offer.records) + val compactOfferData = Offer.tlvSerializer.write(compactOffer.records) + assertTrue { compactOfferData.size < defaultOfferData.size } + assertTrue { defaultOfferData.size == 206 } + assertTrue { compactOfferData.size == 190 } } @Test From eafc91f1be0f76398b58a9b600e58ef6f5f512cf Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 17 Nov 2025 11:01:06 +0100 Subject: [PATCH 2/3] Ensure that `isOurOffer` works for the default offer The default offer also uses an empty `path_id` in the blinded path (because it doesn't need to store any randomness), so an empty `path_id` doesn't necessarily means that we're using a compact offer. --- .../fr/acinq/lightning/payment/OfferManager.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 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 87a659e62..3efcaa6b8 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 @@ -235,11 +235,11 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v /** This function verifies that the offer provided was generated by us. */ private fun isOurOffer(offer: OfferTypes.Offer, pathId: ByteVector?, blindedPrivateKey: PrivateKey): Boolean = when { - pathId == null -> { // Compact offer - nodeParams.compactOfferKeys.value.contains(blindedPrivateKey.publicKey()) - } - pathId.size() != 32 -> false - else -> { // Deterministic offer + pathId != null && pathId.size() != 32 -> false + // Compact offers are randomly generated, but they don't store the nonce in the path_id to save space. + // It is instead the wallet's responsibility to store the corresponding blinded public key(s). + pathId == null && nodeParams.compactOfferKeys.value.contains(blindedPrivateKey.publicKey()) -> true + else -> { val expected = deterministicOffer(nodeParams.chainHash, nodeParams.nodePrivateKey, walletParams.trampolineNode.id, offer.amount, offer.description, pathId?.let { ByteVector32(it) }) expected == OfferTypes.OfferAndKey(offer, blindedPrivateKey) } @@ -290,7 +290,7 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v val tweak = "bolt 12 compact offer".encodeToByteArray().byteVector() + Crypto.sha256("offer nonce".encodeToByteArray().byteVector() + nonce) val sessionKey = PrivateKey(Crypto.sha256(tweak + trampolineNodeId.value + nodePrivateKey.value).byteVector32()) - return OfferTypes.Offer.createBlindedOffer(chainHash, nodePrivateKey, trampolineNodeId, null, null, Features.empty, sessionKey, null, true) + return OfferTypes.Offer.createBlindedOffer(chainHash, nodePrivateKey, trampolineNodeId, null, null, Features.empty, sessionKey, null, allowCompactFormat = true) } } } From 1226eccabd9b74a527bd7613db5479716ba26fa8 Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Mon, 17 Nov 2025 20:52:21 -0500 Subject: [PATCH 3/3] Fixing documentation --- .../src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 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 1a5e7afdf..6374990a4 100644 --- a/modules/core/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/modules/core/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -282,11 +282,10 @@ data class NodeParams( * spaces (e.g. bolt card) * * Each generated offer is unique, since a random nonce is used to generate the blindedPathSessionKey. - * This offer will stay valid after restoring the seed on a different device: we will - * automatically keep accepting payments for this offer. * - * The caller must store the returned `OfferAndKey.privateKey.publicKey`, - * and set/update the NodeParams.compactOfferKeys with the value. + * The caller is responsible for storing the returned `OfferAndKey.privateKey.publicKey`. + * Next time you start lightning-kmp, you must set the NodeParams.compactOfferKeys with this value. + * It has been added to the set before returning, but lightning-kmp doesn't persist it. * * @return a compact offer and the private key that will sign invoices for this offer. */