Skip to content

Commit 94974e7

Browse files
committed
Support shared trampoline node
When the recipient supports blinded trampoline payments and uses the same introduction node as our trampoline node, we mustn't add a hop from our trampoline node to itself: we can directly use them as the blinded path's trampoline introduction node. This is what payments between mobile wallets using the same trampoline node will look like.
1 parent 02d0cfb commit 94974e7

File tree

2 files changed

+100
-5
lines changed

2 files changed

+100
-5
lines changed

modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentPacket.kt

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,21 @@ object OutgoingPaymentPacket {
8989
listOf(introductionPayload) + intermediatePayloads + listOf(finalPayload)
9090
}
9191
}
92-
val trampolinePayload = PaymentOnion.NodeRelayPayload.create(blindedAmount, blindedExpiry, blindedNodes.first())
93-
val trampolineOnion = buildOnion(listOf(hop.nodeId) + blindedNodes, listOf(trampolinePayload) + blindedPayloads, paymentHash)
94-
val trampolineAmount = blindedAmount + hop.fee(blindedAmount)
95-
val trampolineExpiry = blindedExpiry + hop.cltvExpiryDelta
96-
Triple(trampolineAmount, trampolineExpiry, trampolineOnion)
92+
when {
93+
hop.nodeId == path.route.route.firstNodeId.publicKey -> {
94+
// We don't need a trampoline hop to reach the introduction node of their blinded path, because it's our trampoline node.
95+
val trampolineOnion = buildOnion(blindedNodes, blindedPayloads, paymentHash)
96+
Triple(blindedAmount, blindedExpiry, trampolineOnion)
97+
}
98+
else -> {
99+
// We use our trampoline node to reach the introduction node of their blinded path.
100+
val trampolinePayload = PaymentOnion.NodeRelayPayload.create(blindedAmount, blindedExpiry, blindedNodes.first())
101+
val trampolineOnion = buildOnion(listOf(hop.nodeId) + blindedNodes, listOf(trampolinePayload) + blindedPayloads, paymentHash)
102+
val trampolineAmount = blindedAmount + hop.fee(blindedAmount)
103+
val trampolineExpiry = blindedExpiry + hop.cltvExpiryDelta
104+
Triple(trampolineAmount, trampolineExpiry, trampolineOnion)
105+
}
106+
}
97107
}
98108
val trampolinePaymentSecret = Lightning.randomBytes32()
99109
val payload = PaymentOnion.FinalPayload.Standard.createTrampolinePayload(trampolineAmount, trampolineAmount, trampolineExpiry, trampolinePaymentSecret, trampolineOnion.packet)

modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,91 @@ class PaymentPacketTestsCommon : LightningTestSuite() {
484484
assertEquals(paymentMetadata.paymentHash, invoice.paymentHash)
485485
}
486486

487+
@Test
488+
fun `send a trampoline payment to blinded recipient`() {
489+
val features = Features(
490+
Feature.BasicMultiPartPayment to FeatureSupport.Optional,
491+
Feature.TrampolinePayment to FeatureSupport.Optional,
492+
)
493+
val offer = OfferTypes.Offer.createNonBlindedOffer(finalAmount, "test offer", d, features, Block.RegtestGenesisBlock.hash)
494+
// D uses a 1-hop blinded path from its trampoline node C.
495+
val (invoice, blindedRoute) = run {
496+
val payerKey = randomKey()
497+
val request = OfferTypes.InvoiceRequest(offer, finalAmount, 1, features, payerKey, "hello", Block.RegtestGenesisBlock.hash)
498+
val paymentMetadata = OfferPaymentMetadata.V1(offer.offerId, finalAmount, paymentPreimage, payerKey.publicKey(), "hello", 1, currentTimestampMillis())
499+
val blindedPayloadC = RouteBlindingEncryptedData(
500+
TlvStream(
501+
RouteBlindingEncryptedDataTlv.OutgoingNodeId(EncodedNodeId(d)),
502+
RouteBlindingEncryptedDataTlv.PaymentRelay(channelUpdateCD.cltvExpiryDelta, channelUpdateCD.feeProportionalMillionths, channelUpdateCD.feeBaseMsat),
503+
RouteBlindingEncryptedDataTlv.PaymentConstraints(finalExpiry, 1.msat),
504+
)
505+
)
506+
val blindedPayloadD = RouteBlindingEncryptedData(
507+
TlvStream(
508+
RouteBlindingEncryptedDataTlv.PathId(paymentMetadata.toPathId(privD))
509+
)
510+
)
511+
val blindedRouteDetails = RouteBlinding.create(randomKey(), listOf(c, d), listOf(blindedPayloadC, blindedPayloadD).map { it.write().byteVector() })
512+
val paymentInfo = createBlindedPaymentInfo(channelUpdateCD)
513+
val path = Bolt12Invoice.Companion.PaymentBlindedContactInfo(OfferTypes.ContactInfo.BlindedPath(blindedRouteDetails.route), paymentInfo)
514+
val invoice = Bolt12Invoice(request, paymentPreimage, blindedRouteDetails.blindedPrivateKey(privD), 600, features, listOf(path))
515+
assertEquals(invoice.nodeId, blindedRouteDetails.route.blindedNodeIds.last())
516+
assertNotEquals(invoice.nodeId, d)
517+
assertTrue(invoice.features.hasFeature(Feature.TrampolinePayment))
518+
Pair(invoice, blindedRouteDetails.route)
519+
}
520+
521+
// B pays that invoice using its trampoline node C to relay to D using trampoline.
522+
val (firstAmount, firstExpiry, onion) = OutgoingPaymentPacket.buildPacketToTrampolineRecipient(invoice.paymentHash, finalAmount, finalExpiry, invoice.blindedPaths.first(), nodeHop_cd)
523+
assertEquals(amountBC, firstAmount)
524+
assertEquals(expiryBC, firstExpiry)
525+
526+
// C decrypts the onion, the trampoline onion and the encrypted data before relaying to D.
527+
val addC = UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet)
528+
val (outerC, innerC, trampolineOnionD) = decryptRelayToBlindedTrampoline(addC, privC)
529+
assertEquals(amountBC, outerC.amount)
530+
assertEquals(amountBC, outerC.totalAmount)
531+
assertEquals(expiryBC, outerC.expiry)
532+
assertEquals(2, innerC.records.records.size)
533+
val encryptedData = innerC.records.get<OnionPaymentPayloadTlv.EncryptedRecipientData>()?.data
534+
assertNotNull(encryptedData)
535+
val pathKey = innerC.records.get<OnionPaymentPayloadTlv.PathKey>()?.publicKey
536+
assertNotNull(pathKey)
537+
assertEquals(blindedRoute.firstPathKey, pathKey)
538+
val (encryptedPayload, nextPathKey) = RouteBlinding.decryptPayload(privC, pathKey, encryptedData).right!!
539+
val decryptedPayload = RouteBlindingEncryptedData.read(encryptedPayload.toByteArray()).right!!
540+
assertEquals(EncodedNodeId(d), decryptedPayload.nextNodeId)
541+
val paymentRelay = decryptedPayload.records.get<RouteBlindingEncryptedDataTlv.PaymentRelay>()
542+
assertEquals(channelUpdateCD.cltvExpiryDelta, paymentRelay?.cltvExpiryDelta)
543+
assertEquals(channelUpdateCD.feeBaseMsat, paymentRelay?.feeBase)
544+
assertEquals(channelUpdateCD.feeProportionalMillionths, paymentRelay?.feeProportionalMillionths)
545+
546+
// C relays the trampoline payment to D.
547+
val onionD = run {
548+
val payloadD = PaymentOnion.FinalPayload.Standard(
549+
TlvStream(
550+
OnionPaymentPayloadTlv.AmountToForward(finalAmount),
551+
OnionPaymentPayloadTlv.OutgoingCltv(finalExpiry),
552+
OnionPaymentPayloadTlv.PaymentData(randomBytes32(), finalAmount),
553+
OnionPaymentPayloadTlv.PathKey(nextPathKey),
554+
OnionPaymentPayloadTlv.TrampolineOnion(trampolineOnionD)
555+
)
556+
)
557+
OutgoingPaymentPacket.buildOnion(listOf(d), listOf(payloadD), paymentHash, OnionRoutingPacket.PaymentPacketLength).packet
558+
}
559+
560+
// D receives the payment.
561+
val addD = UpdateAddHtlc(randomBytes32(), 3, finalAmount, paymentHash, finalExpiry, onionD)
562+
val payloadD = IncomingPaymentPacket.decrypt(addD, privD).right!!
563+
assertIs<PaymentOnion.FinalPayload.Blinded>(payloadD)
564+
assertEquals(finalAmount, payloadD.amount)
565+
assertEquals(finalExpiry, payloadD.expiry)
566+
val paymentMetadata = OfferPaymentMetadata.fromPathId(d, payloadD.pathId)
567+
assertNotNull(paymentMetadata)
568+
assertEquals(offer.offerId, paymentMetadata.offerId)
569+
assertEquals(paymentMetadata.paymentHash, invoice.paymentHash)
570+
}
571+
487572
// See bolt04/trampoline-to-blinded-path-payment-onion-test.json
488573
@Test
489574
fun `send a trampoline payment to blinded paths -- reference test vector`() {

0 commit comments

Comments
 (0)