diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt index 44fc5f894..88bf50421 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt @@ -4,12 +4,9 @@ import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.utils.Either.Left import fr.acinq.bitcoin.utils.Either.Right -import fr.acinq.lightning.EncodedNodeId -import fr.acinq.lightning.Features +import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey -import fr.acinq.lightning.NodeParams -import fr.acinq.lightning.WalletParams import fr.acinq.lightning.crypto.RouteBlinding import fr.acinq.lightning.io.OfferInvoiceReceived import fr.acinq.lightning.io.OfferNotPaid @@ -181,8 +178,12 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v maxHtlc = amount * 2, allowedFeatures = Features.empty ) - // We assume 10 minutes between each block to convert the invoice expiry to a CLTV expiry for the blinded path. - val pathExpiry = (paymentInfo.cltvExpiryDelta + (nodeParams.bolt12InvoiceExpiry.inWholeMinutes.toInt() / 10)).toCltvExpiry(currentBlockHeight.toLong()) + // Once the invoice expires, the blinded path shouldn't be usable anymore. + // We assume 10 minutes between each block to convert the invoice expiry to a cltv_expiry_delta. + // When paying the invoice, payers may add any number of blocks to the current block height to protect recipient privacy. + // We assume that they won't add more than 720 blocks, which is reasonable because adding a large delta increases the risk + // that intermediate nodes reject the payment because they don't want their funds potentially locked for a long duration. + val pathExpiry = (paymentInfo.cltvExpiryDelta + CltvExpiryDelta(720) + (nodeParams.bolt12InvoiceExpiry.inWholeMinutes.toInt() / 10)).toCltvExpiry(currentBlockHeight.toLong()) val remoteNodePayload = RouteBlindingEncryptedData( TlvStream( RouteBlindingEncryptedDataTlv.OutgoingNodeId(EncodedNodeId.WithPublicKey.Wallet(nodeParams.nodeId)), diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt index 5e2db8d8b..8d1c55f0e 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt @@ -63,6 +63,15 @@ class OfferManagerTestsCommon : LightningTestSuite() { return offer } + private fun decryptPathId(invoice: Bolt12Invoice, trampolineKey: PrivateKey): OfferPaymentMetadata.V1 { + val blindedRoute = invoice.blindedPaths.first().route.route + assertEquals(2, blindedRoute.encryptedPayloads.size) + val (_, nextBlinding) = RouteBlinding.decryptPayload(trampolineKey, blindedRoute.blindingKey, 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 + } + @Test fun `pay offer through the same trampoline node`() = runSuspendTest { // Alice and Bob use the same trampoline node. @@ -71,17 +80,18 @@ class OfferManagerTestsCommon : LightningTestSuite() { val offer = createOffer(aliceOfferManager, amount = 1000.msat) // 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) 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) + val invoiceResponse = aliceOfferManager.receiveMessage(messageForAlice, listOf(), currentBlockHeight) 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) + val payInvoice = bobOfferManager.receiveMessage(messageForBob, listOf(), currentBlockHeight) assertIs(payInvoice) assertEquals(OfferInvoiceReceived(payOffer, payInvoice.invoice), bobOfferManager.eventsFlow.first()) assertEquals(payOffer, payInvoice.payOffer) @@ -91,6 +101,10 @@ class OfferManagerTestsCommon : LightningTestSuite() { assertEquals(aliceOfferManager.nodeParams.expiryDeltaBlocks + aliceOfferManager.nodeParams.minFinalCltvExpiryDelta, path.paymentInfo.cltvExpiryDelta) assertEquals(TestConstants.Alice.nodeParams.htlcMinimum, path.paymentInfo.minHtlc) assertEquals(payOffer.amount * 2, path.paymentInfo.maxHtlc) + // The blinded path expires long after the invoice expiry to allow senders to add their own expiry delta. + val (alicePayload, _) = RouteBlinding.decryptPayload(aliceTrampolineKey, path.route.route.blindingKey, path.route.route.encryptedPayloads.first()).right!! + val paymentConstraints = RouteBlindingEncryptedData.read(alicePayload.toByteArray()).right!!.paymentConstraints!! + assertTrue(paymentConstraints.maxCltvExpiry > CltvExpiryDelta(720).toCltvExpiry(currentBlockHeight.toLong())) } @Test @@ -271,17 +285,8 @@ class OfferManagerTestsCommon : LightningTestSuite() { assertEquals(OfferInvoiceReceived(payOffer, payInvoice.invoice), bobOfferManager.eventsFlow.first()) assertEquals(payOffer, payInvoice.payOffer) - val blindedRoute = payInvoice.invoice.blindedPaths.first().route.route - val (firstPayload, secondBlinding) = RouteBlinding.decryptPayload(aliceTrampolineKey, blindedRoute.blindingKey, blindedRoute.encryptedPayloads.first()).right!! - var blinding = secondBlinding - var lastPayload = firstPayload - for (encryptedPayload in blindedRoute.encryptedPayloads.drop(1)) { - val (payload, nextBlinding) = RouteBlinding.decryptPayload(TestConstants.Alice.nodeParams.nodePrivateKey, blinding, encryptedPayload).right!! - blinding = nextBlinding - lastPayload = payload - } - val pathId = RouteBlindingEncryptedData.read(lastPayload.toByteArray()).right!!.pathId!! - val metadata = OfferPaymentMetadata.fromPathId(TestConstants.Alice.nodeParams.nodeId, pathId) as OfferPaymentMetadata.V1 + // The payer note is correctly included in the payment metadata. + val metadata = decryptPathId(payInvoice.invoice, aliceTrampolineKey) assertEquals(payerNote, metadata.payerNote) } @@ -309,18 +314,10 @@ class OfferManagerTestsCommon : LightningTestSuite() { assertEquals(OfferInvoiceReceived(payOffer, payInvoice.invoice), bobOfferManager.eventsFlow.first()) assertEquals(payOffer, payInvoice.payOffer) - val blindedRoute = payInvoice.invoice.blindedPaths.first().route.route - val (firstPayload, secondBlinding) = RouteBlinding.decryptPayload(aliceTrampolineKey, blindedRoute.blindingKey, blindedRoute.encryptedPayloads.first()).right!! - var blinding = secondBlinding - var lastPayload = firstPayload - for (encryptedPayload in blindedRoute.encryptedPayloads.drop(1)) { - val (payload, nextBlinding) = RouteBlinding.decryptPayload(TestConstants.Alice.nodeParams.nodePrivateKey, blinding, encryptedPayload).right!! - blinding = nextBlinding - lastPayload = payload - } - val pathId = RouteBlindingEncryptedData.read(lastPayload.toByteArray()).right!!.pathId!! - val metadata = OfferPaymentMetadata.fromPathId(TestConstants.Alice.nodeParams.nodeId, pathId) as OfferPaymentMetadata.V1 + // 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)) } + } \ No newline at end of file