Skip to content

Commit 9f6fb36

Browse files
committed
Add support for trampoline blinded payments
We add the ability to pay recipients that support trampoline *and* blinded paths. We include the blinded path data in the trampoline payloads for each node inside the blinded path. This doesn't reveal unnecessary information to the trampoline node: this is specified in details in lightning/bolts#836.
1 parent 1d49af9 commit 9f6fb36

File tree

7 files changed

+277
-13
lines changed

7 files changed

+277
-13
lines changed

modules/core/src/commonMain/kotlin/fr/acinq/lightning/Features.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ sealed class Feature {
151151
object TrampolinePayment : Feature() {
152152
override val rfcName get() = "trampoline_routing"
153153
override val mandatory get() = 56
154-
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node, FeatureScope.Invoice)
154+
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node, FeatureScope.Invoice, FeatureScope.Bolt12)
155155
}
156156

157157
// The following features have not been standardised, hence the high feature bits to avoid conflicts.

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,11 @@ object IncomingPaymentPacket {
4848
when (val trampolineOnion = outer.records.get<OnionPaymentPayloadTlv.TrampolineOnion>()) {
4949
null -> validate(htlcAmount, htlcExpiry, outer)
5050
else -> {
51-
when (val inner = decryptOnion(paymentHash, trampolineOnion.packet, privateKey, null)) {
51+
when (val inner = decryptOnion(paymentHash, trampolineOnion.packet, privateKey, outer.records.get<OnionPaymentPayloadTlv.PathKey>()?.publicKey)) {
5252
is Either.Left -> Either.Left(inner.value)
5353
is Either.Right -> when (val innerPayload = inner.value) {
5454
is PaymentOnion.FinalPayload.Standard -> validate(htlcAmount, htlcExpiry, outer, innerPayload)
55-
// Blinded trampoline paths are not supported.
56-
is PaymentOnion.FinalPayload.Blinded -> Either.Left(InvalidOnionPayload(0, 0))
55+
is PaymentOnion.FinalPayload.Blinded -> validate(htlcAmount, htlcExpiry, trampolineOnion.packet, innerPayload)
5756
}
5857
}
5958
}
@@ -72,7 +71,6 @@ object IncomingPaymentPacket {
7271
when (val encryptedRecipientData = tlvs.get<OnionPaymentPayloadTlv.EncryptedRecipientData>()?.data) {
7372
null -> when {
7473
pathKey != null -> Either.Left(InvalidOnionBlinding(hash(packet)))
75-
tlvs.get<OnionPaymentPayloadTlv.PathKey>() != null -> Either.Left(InvalidOnionBlinding(hash(packet)))
7674
else -> PaymentOnion.FinalPayload.Standard.read(decrypted.payload)
7775
}
7876
else -> when {

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,11 +318,10 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
318318
is Bolt11Invoice -> {
319319
val minFinalExpiryDelta = paymentRequest.minFinalExpiryDelta ?: Channel.MIN_CLTV_EXPIRY_DELTA
320320
val expiry = nodeParams.paymentRecipientExpiryParams.computeFinalExpiry(currentBlockHeight, minFinalExpiryDelta)
321-
val invoiceFeatures = paymentRequest.features
322321
if (request.recipient == walletParams.trampolineNode.id) {
323322
// We are directly paying our trampoline node.
324323
OutgoingPaymentPacket.buildPacketToTrampolinePeer(paymentRequest, request.amount, expiry)
325-
} else if (invoiceFeatures.hasFeature(Feature.TrampolinePayment)) {
324+
} else if (paymentRequest.features.hasFeature(Feature.TrampolinePayment)) {
326325
OutgoingPaymentPacket.buildPacketToTrampolineRecipient(paymentRequest, request.amount, expiry, hop)
327326
} else {
328327
OutgoingPaymentPacket.buildPacketToLegacyRecipient(paymentRequest, request.amount, expiry, hop)
@@ -332,7 +331,16 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
332331
// The recipient already included a final cltv-expiry-delta in their invoice blinded paths.
333332
val minFinalExpiryDelta = CltvExpiryDelta(0)
334333
val expiry = nodeParams.paymentRecipientExpiryParams.computeFinalExpiry(currentBlockHeight, minFinalExpiryDelta)
335-
OutgoingPaymentPacket.buildPacketToBlindedRecipient(paymentRequest, request.amount, expiry, hop)
334+
if (paymentRequest.features.hasFeature(Feature.TrampolinePayment)) {
335+
// If there are multiple blinded paths, we choose one at random that doesn't require resolving the introduction node.
336+
// If we can't find one, we default to letting our trampoline node relay to the invoice's blinded paths.
337+
when (val path = paymentRequest.blindedPaths.filter { it.route.route.firstNodeId is EncodedNodeId.WithPublicKey }.randomOrNull()) {
338+
null -> OutgoingPaymentPacket.buildPacketToBlindedRecipient(paymentRequest, request.amount, expiry, hop)
339+
else -> OutgoingPaymentPacket.buildPacketToTrampolineRecipient(paymentRequest.paymentHash, request.amount, expiry, path, hop)
340+
}
341+
} else {
342+
OutgoingPaymentPacket.buildPacketToBlindedRecipient(paymentRequest, request.amount, expiry, hop)
343+
}
336344
}
337345
}
338346
}

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

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,7 @@ import fr.acinq.bitcoin.ByteVector32
55
import fr.acinq.bitcoin.PrivateKey
66
import fr.acinq.bitcoin.PublicKey
77
import fr.acinq.bitcoin.utils.Either
8-
import fr.acinq.lightning.CltvExpiry
9-
import fr.acinq.lightning.Feature
10-
import fr.acinq.lightning.Lightning
11-
import fr.acinq.lightning.MilliSatoshi
8+
import fr.acinq.lightning.*
129
import fr.acinq.lightning.channel.ChannelCommand
1310
import fr.acinq.lightning.crypto.sphinx.FailurePacket
1411
import fr.acinq.lightning.crypto.sphinx.PacketAndSecrets
@@ -58,6 +55,42 @@ object OutgoingPaymentPacket {
5855
return Triple(trampolineAmount, trampolineExpiry, paymentOnion)
5956
}
6057

58+
/**
59+
* Build an encrypted payment onion packet when the final recipient supports trampoline.
60+
* We use each hop in the blinded path as a trampoline hop, which doesn't reveal anything to our trampoline node.
61+
* From their point of view, they will be relaying a trampoline payment to another trampoline node.
62+
* They won't even know that a blinded path is being used.
63+
*/
64+
fun buildPacketToTrampolineRecipient(paymentHash: ByteVector32, amount: MilliSatoshi, expiry: CltvExpiry, path: Bolt12Invoice.Companion.PaymentBlindedContactInfo, hop: NodeHop): Triple<MilliSatoshi, CltvExpiry, PacketAndSecrets> {
65+
require(path.route.route.firstNodeId is EncodedNodeId.WithPublicKey) { "blinded path must provide the introduction node_id" }
66+
val (trampolineAmount, trampolineExpiry, trampolineOnion) = run {
67+
val blindedAmount = amount + path.paymentInfo.fee(amount)
68+
val blindedExpiry = expiry + path.paymentInfo.cltvExpiryDelta
69+
val blindedNodes = listOf(path.route.route.firstNodeId.publicKey) + path.route.route.blindedNodeIds.drop(1)
70+
val blindedPayloads = when {
71+
blindedNodes.size == 1 -> {
72+
val finalPayload = PaymentOnion.FinalPayload.Blinded.create(amount, expiry, path.route.route.encryptedPayloads.last(), path.route.route.firstPathKey)
73+
listOf(finalPayload)
74+
}
75+
else -> {
76+
val finalPayload = PaymentOnion.FinalPayload.Blinded.create(amount, expiry, path.route.route.encryptedPayloads.last(), pathKey = null)
77+
val intermediatePayloads = path.route.route.encryptedPayloads.drop(1).dropLast(1).map { PaymentOnion.BlindedChannelRelayPayload.create(it, pathKey = null) }
78+
val introductionPayload = PaymentOnion.BlindedChannelRelayPayload.create(path.route.route.encryptedPayloads.first(), path.route.route.firstPathKey)
79+
listOf(introductionPayload) + intermediatePayloads + listOf(finalPayload)
80+
}
81+
}
82+
val trampolinePayload = PaymentOnion.NodeRelayPayload.create(blindedAmount, blindedExpiry, blindedNodes.first())
83+
val trampolineOnion = buildOnion(listOf(hop.nodeId) + blindedNodes, listOf(trampolinePayload) + blindedPayloads, paymentHash)
84+
val trampolineAmount = blindedAmount + hop.fee(blindedAmount)
85+
val trampolineExpiry = blindedExpiry + hop.cltvExpiryDelta
86+
Triple(trampolineAmount, trampolineExpiry, trampolineOnion)
87+
}
88+
val trampolinePaymentSecret = Lightning.randomBytes32()
89+
val payload = PaymentOnion.FinalPayload.Standard.createTrampolinePayload(trampolineAmount, trampolineAmount, trampolineExpiry, trampolinePaymentSecret, trampolineOnion.packet)
90+
val paymentOnion = buildOnion(listOf(hop.nodeId), listOf(payload), paymentHash, OnionRoutingPacket.PaymentPacketLength)
91+
return Triple(trampolineAmount, trampolineExpiry, paymentOnion)
92+
}
93+
6194
/**
6295
* Build an encrypted payment onion packet when the final recipient is our trampoline node.
6396
*

modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/PaymentOnion.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,21 @@ object PaymentOnion {
399399
else -> Either.Right(Blinded(records, blindedRecords))
400400
}
401401
}
402+
403+
fun create(amount: MilliSatoshi, expiry: CltvExpiry, encryptedData: ByteVector, pathKey: PublicKey?): Blinded {
404+
val tlvs = TlvStream(
405+
setOfNotNull(
406+
OnionPaymentPayloadTlv.AmountToForward(amount),
407+
OnionPaymentPayloadTlv.OutgoingCltv(expiry),
408+
OnionPaymentPayloadTlv.TotalAmount(amount),
409+
OnionPaymentPayloadTlv.EncryptedRecipientData(encryptedData),
410+
pathKey?.let { OnionPaymentPayloadTlv.PathKey(it) },
411+
)
412+
)
413+
// We're creating a payload for an outgoing payment: we don't have access to the decrypted data, so we create an unused dummy one.
414+
val dummyPathId = ByteVector.fromHex("deadbeef")
415+
return Blinded(tlvs, RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(dummyPathId))))
416+
}
402417
}
403418
}
404419
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ class OfferManagerTestsCommon : LightningTestSuite() {
9595
assertIs<OnionMessageAction.PayInvoice>(payInvoice)
9696
assertEquals(OfferInvoiceReceived(payOffer, payInvoice.invoice), bobOfferManager.eventsFlow.first())
9797
assertEquals(payOffer, payInvoice.payOffer)
98+
assertTrue(payInvoice.invoice.features.hasFeature(Feature.BasicMultiPartPayment))
99+
assertTrue(payInvoice.invoice.features.hasFeature(Feature.TrampolinePayment))
98100
assertEquals(1, payInvoice.invoice.blindedPaths.size)
99101
val path = payInvoice.invoice.blindedPaths.first()
100102
assertEquals(EncodedNodeId(aliceTrampolineKey.publicKey()), path.route.route.firstNodeId)

0 commit comments

Comments
 (0)