Skip to content

Commit eed5998

Browse files
authored
Increase blinded path expiry (#718)
Blinded paths should expire when we will start rejecting payments for the corresponding invoice: this is thus related to the bolt 12 invoice expiry that we used. However, when we receive an HTLC, its `cltv_expiry` is set to a future block height, which must be at least `min_final_expiry_delta` in the future. Payers may add some additional margin to the current block height to protect against delays in HTLC relay and protect the privacy of the payment. So we must add a large enough `cltv_expiry_delta` to the invoice expiry to account for those.
1 parent 78d8fa9 commit eed5998

File tree

2 files changed

+28
-30
lines changed

2 files changed

+28
-30
lines changed

src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,9 @@ import fr.acinq.bitcoin.ByteVector32
44
import fr.acinq.bitcoin.PublicKey
55
import fr.acinq.bitcoin.utils.Either.Left
66
import fr.acinq.bitcoin.utils.Either.Right
7-
import fr.acinq.lightning.EncodedNodeId
8-
import fr.acinq.lightning.Features
7+
import fr.acinq.lightning.*
98
import fr.acinq.lightning.Lightning.randomBytes32
109
import fr.acinq.lightning.Lightning.randomKey
11-
import fr.acinq.lightning.NodeParams
12-
import fr.acinq.lightning.WalletParams
1310
import fr.acinq.lightning.crypto.RouteBlinding
1411
import fr.acinq.lightning.io.OfferInvoiceReceived
1512
import fr.acinq.lightning.io.OfferNotPaid
@@ -181,8 +178,12 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
181178
maxHtlc = amount * 2,
182179
allowedFeatures = Features.empty
183180
)
184-
// We assume 10 minutes between each block to convert the invoice expiry to a CLTV expiry for the blinded path.
185-
val pathExpiry = (paymentInfo.cltvExpiryDelta + (nodeParams.bolt12InvoiceExpiry.inWholeMinutes.toInt() / 10)).toCltvExpiry(currentBlockHeight.toLong())
181+
// Once the invoice expires, the blinded path shouldn't be usable anymore.
182+
// We assume 10 minutes between each block to convert the invoice expiry to a cltv_expiry_delta.
183+
// When paying the invoice, payers may add any number of blocks to the current block height to protect recipient privacy.
184+
// We assume that they won't add more than 720 blocks, which is reasonable because adding a large delta increases the risk
185+
// that intermediate nodes reject the payment because they don't want their funds potentially locked for a long duration.
186+
val pathExpiry = (paymentInfo.cltvExpiryDelta + CltvExpiryDelta(720) + (nodeParams.bolt12InvoiceExpiry.inWholeMinutes.toInt() / 10)).toCltvExpiry(currentBlockHeight.toLong())
186187
val remoteNodePayload = RouteBlindingEncryptedData(
187188
TlvStream(
188189
RouteBlindingEncryptedDataTlv.OutgoingNodeId(EncodedNodeId.WithPublicKey.Wallet(nodeParams.nodeId)),

src/commonTest/kotlin/fr/acinq/lightning/payment/OfferManagerTestsCommon.kt

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ class OfferManagerTestsCommon : LightningTestSuite() {
6363
return offer
6464
}
6565

66+
private fun decryptPathId(invoice: Bolt12Invoice, trampolineKey: PrivateKey): OfferPaymentMetadata.V1 {
67+
val blindedRoute = invoice.blindedPaths.first().route.route
68+
assertEquals(2, blindedRoute.encryptedPayloads.size)
69+
val (_, nextBlinding) = RouteBlinding.decryptPayload(trampolineKey, blindedRoute.blindingKey, blindedRoute.encryptedPayloads.first()).right!!
70+
val (lastPayload, _) = RouteBlinding.decryptPayload(TestConstants.Alice.nodeParams.nodePrivateKey, nextBlinding, blindedRoute.encryptedPayloads.last()).right!!
71+
val pathId = RouteBlindingEncryptedData.read(lastPayload.toByteArray()).right!!.pathId!!
72+
return OfferPaymentMetadata.fromPathId(TestConstants.Alice.nodeParams.nodeId, pathId) as OfferPaymentMetadata.V1
73+
}
74+
6675
@Test
6776
fun `pay offer through the same trampoline node`() = runSuspendTest {
6877
// Alice and Bob use the same trampoline node.
@@ -71,17 +80,18 @@ class OfferManagerTestsCommon : LightningTestSuite() {
7180
val offer = createOffer(aliceOfferManager, amount = 1000.msat)
7281

7382
// Bob sends an invoice request to Alice.
83+
val currentBlockHeight = 0
7484
val payOffer = PayOffer(UUID.randomUUID(), randomKey(), null, 5500.msat, offer, 20.seconds)
7585
val (_, invoiceRequests) = bobOfferManager.requestInvoice(payOffer)
7686
assertTrue(invoiceRequests.size == 1)
7787
val (messageForAlice, nextNodeAlice) = trampolineRelay(invoiceRequests.first(), aliceTrampolineKey)
7888
assertEquals(Either.Right(EncodedNodeId.WithPublicKey.Wallet(TestConstants.Alice.nodeParams.nodeId)), nextNodeAlice)
7989
// Alice sends an invoice back to Bob.
80-
val invoiceResponse = aliceOfferManager.receiveMessage(messageForAlice, listOf(), 0)
90+
val invoiceResponse = aliceOfferManager.receiveMessage(messageForAlice, listOf(), currentBlockHeight)
8191
assertIs<OnionMessageAction.SendMessage>(invoiceResponse)
8292
val (messageForBob, nextNodeBob) = trampolineRelay(invoiceResponse.message, aliceTrampolineKey)
8393
assertEquals(Either.Right(EncodedNodeId.WithPublicKey.Wallet(TestConstants.Bob.nodeParams.nodeId)), nextNodeBob)
84-
val payInvoice = bobOfferManager.receiveMessage(messageForBob, listOf(), 0)
94+
val payInvoice = bobOfferManager.receiveMessage(messageForBob, listOf(), currentBlockHeight)
8595
assertIs<OnionMessageAction.PayInvoice>(payInvoice)
8696
assertEquals(OfferInvoiceReceived(payOffer, payInvoice.invoice), bobOfferManager.eventsFlow.first())
8797
assertEquals(payOffer, payInvoice.payOffer)
@@ -91,6 +101,10 @@ class OfferManagerTestsCommon : LightningTestSuite() {
91101
assertEquals(aliceOfferManager.nodeParams.expiryDeltaBlocks + aliceOfferManager.nodeParams.minFinalCltvExpiryDelta, path.paymentInfo.cltvExpiryDelta)
92102
assertEquals(TestConstants.Alice.nodeParams.htlcMinimum, path.paymentInfo.minHtlc)
93103
assertEquals(payOffer.amount * 2, path.paymentInfo.maxHtlc)
104+
// The blinded path expires long after the invoice expiry to allow senders to add their own expiry delta.
105+
val (alicePayload, _) = RouteBlinding.decryptPayload(aliceTrampolineKey, path.route.route.blindingKey, path.route.route.encryptedPayloads.first()).right!!
106+
val paymentConstraints = RouteBlindingEncryptedData.read(alicePayload.toByteArray()).right!!.paymentConstraints!!
107+
assertTrue(paymentConstraints.maxCltvExpiry > CltvExpiryDelta(720).toCltvExpiry(currentBlockHeight.toLong()))
94108
}
95109

96110
@Test
@@ -271,17 +285,8 @@ class OfferManagerTestsCommon : LightningTestSuite() {
271285
assertEquals(OfferInvoiceReceived(payOffer, payInvoice.invoice), bobOfferManager.eventsFlow.first())
272286
assertEquals(payOffer, payInvoice.payOffer)
273287

274-
val blindedRoute = payInvoice.invoice.blindedPaths.first().route.route
275-
val (firstPayload, secondBlinding) = RouteBlinding.decryptPayload(aliceTrampolineKey, blindedRoute.blindingKey, blindedRoute.encryptedPayloads.first()).right!!
276-
var blinding = secondBlinding
277-
var lastPayload = firstPayload
278-
for (encryptedPayload in blindedRoute.encryptedPayloads.drop(1)) {
279-
val (payload, nextBlinding) = RouteBlinding.decryptPayload(TestConstants.Alice.nodeParams.nodePrivateKey, blinding, encryptedPayload).right!!
280-
blinding = nextBlinding
281-
lastPayload = payload
282-
}
283-
val pathId = RouteBlindingEncryptedData.read(lastPayload.toByteArray()).right!!.pathId!!
284-
val metadata = OfferPaymentMetadata.fromPathId(TestConstants.Alice.nodeParams.nodeId, pathId) as OfferPaymentMetadata.V1
288+
// The payer note is correctly included in the payment metadata.
289+
val metadata = decryptPathId(payInvoice.invoice, aliceTrampolineKey)
285290
assertEquals(payerNote, metadata.payerNote)
286291
}
287292

@@ -309,18 +314,10 @@ class OfferManagerTestsCommon : LightningTestSuite() {
309314
assertEquals(OfferInvoiceReceived(payOffer, payInvoice.invoice), bobOfferManager.eventsFlow.first())
310315
assertEquals(payOffer, payInvoice.payOffer)
311316

312-
val blindedRoute = payInvoice.invoice.blindedPaths.first().route.route
313-
val (firstPayload, secondBlinding) = RouteBlinding.decryptPayload(aliceTrampolineKey, blindedRoute.blindingKey, blindedRoute.encryptedPayloads.first()).right!!
314-
var blinding = secondBlinding
315-
var lastPayload = firstPayload
316-
for (encryptedPayload in blindedRoute.encryptedPayloads.drop(1)) {
317-
val (payload, nextBlinding) = RouteBlinding.decryptPayload(TestConstants.Alice.nodeParams.nodePrivateKey, blinding, encryptedPayload).right!!
318-
blinding = nextBlinding
319-
lastPayload = payload
320-
}
321-
val pathId = RouteBlindingEncryptedData.read(lastPayload.toByteArray()).right!!.pathId!!
322-
val metadata = OfferPaymentMetadata.fromPathId(TestConstants.Alice.nodeParams.nodeId, pathId) as OfferPaymentMetadata.V1
317+
// The payer note is truncated in the payment metadata.
318+
val metadata = decryptPathId(payInvoice.invoice, aliceTrampolineKey)
323319
assertEquals(64, metadata.payerNote!!.length)
324320
assertEquals(payerNote.take(63), metadata.payerNote!!.take(63))
325321
}
322+
326323
}

0 commit comments

Comments
 (0)