@@ -498,6 +498,91 @@ class PaymentPacketTestsCommon : LightningTestSuite() {
498498 assertEquals(paymentMetadata.paymentHash, invoice.paymentHash)
499499 }
500500
501+ @Test
502+ fun `send a trampoline payment to blinded recipient` () {
503+ val features = Features (
504+ Feature .BasicMultiPartPayment to FeatureSupport .Optional ,
505+ Feature .TrampolinePayment to FeatureSupport .Optional ,
506+ )
507+ val offer = OfferTypes .Offer .createNonBlindedOffer(finalAmount, " test offer" , d, features, Block .RegtestGenesisBlock .hash)
508+ // D uses a 1-hop blinded path from its trampoline node C.
509+ val (invoice, blindedRoute) = run {
510+ val payerKey = randomKey()
511+ val request = OfferTypes .InvoiceRequest (offer, finalAmount, 1 , features, payerKey, " hello" , Block .RegtestGenesisBlock .hash)
512+ val paymentMetadata = OfferPaymentMetadata .V1 (offer.offerId, finalAmount, paymentPreimage, payerKey.publicKey(), " hello" , 1 , currentTimestampMillis())
513+ val blindedPayloadC = RouteBlindingEncryptedData (
514+ TlvStream (
515+ RouteBlindingEncryptedDataTlv .OutgoingNodeId (EncodedNodeId (d)),
516+ RouteBlindingEncryptedDataTlv .PaymentRelay (channelUpdateCD.cltvExpiryDelta, channelUpdateCD.feeProportionalMillionths, channelUpdateCD.feeBaseMsat),
517+ RouteBlindingEncryptedDataTlv .PaymentConstraints (finalExpiry, 1 .msat),
518+ )
519+ )
520+ val blindedPayloadD = RouteBlindingEncryptedData (
521+ TlvStream (
522+ RouteBlindingEncryptedDataTlv .PathId (paymentMetadata.toPathId(privD))
523+ )
524+ )
525+ val blindedRouteDetails = RouteBlinding .create(randomKey(), listOf (c, d), listOf (blindedPayloadC, blindedPayloadD).map { it.write().byteVector() })
526+ val paymentInfo = createBlindedPaymentInfo(channelUpdateCD)
527+ val path = Bolt12Invoice .Companion .PaymentBlindedContactInfo (OfferTypes .ContactInfo .BlindedPath (blindedRouteDetails.route), paymentInfo)
528+ val invoice = Bolt12Invoice (request, paymentPreimage, blindedRouteDetails.blindedPrivateKey(privD), 600 , features, listOf (path))
529+ assertEquals(invoice.nodeId, blindedRouteDetails.route.blindedNodeIds.last())
530+ assertNotEquals(invoice.nodeId, d)
531+ assertTrue(invoice.features.hasFeature(Feature .TrampolinePayment ))
532+ Pair (invoice, blindedRouteDetails.route)
533+ }
534+
535+ // B pays that invoice using its trampoline node C to relay to D using trampoline.
536+ val (firstAmount, firstExpiry, onion) = OutgoingPaymentPacket .buildPacketToTrampolineRecipient(invoice.paymentHash, finalAmount, finalExpiry, invoice.blindedPaths.first(), nodeHop_cd)
537+ assertEquals(amountBC, firstAmount)
538+ assertEquals(expiryBC, firstExpiry)
539+
540+ // C decrypts the onion, the trampoline onion and the encrypted data before relaying to D.
541+ val addC = UpdateAddHtlc (randomBytes32(), 1 , firstAmount, paymentHash, firstExpiry, onion.packet)
542+ val (outerC, innerC, trampolineOnionD) = decryptRelayToBlindedTrampoline(addC, privC)
543+ assertEquals(amountBC, outerC.amount)
544+ assertEquals(amountBC, outerC.totalAmount)
545+ assertEquals(expiryBC, outerC.expiry)
546+ assertEquals(2 , innerC.records.records.size)
547+ val encryptedData = innerC.records.get<OnionPaymentPayloadTlv .EncryptedRecipientData >()?.data
548+ assertNotNull(encryptedData)
549+ val pathKey = innerC.records.get<OnionPaymentPayloadTlv .PathKey >()?.publicKey
550+ assertNotNull(pathKey)
551+ assertEquals(blindedRoute.firstPathKey, pathKey)
552+ val (encryptedPayload, nextPathKey) = RouteBlinding .decryptPayload(privC, pathKey, encryptedData).right!!
553+ val decryptedPayload = RouteBlindingEncryptedData .read(encryptedPayload.toByteArray()).right!!
554+ assertEquals(EncodedNodeId (d), decryptedPayload.nextNodeId)
555+ val paymentRelay = decryptedPayload.records.get<RouteBlindingEncryptedDataTlv .PaymentRelay >()
556+ assertEquals(channelUpdateCD.cltvExpiryDelta, paymentRelay?.cltvExpiryDelta)
557+ assertEquals(channelUpdateCD.feeBaseMsat, paymentRelay?.feeBase)
558+ assertEquals(channelUpdateCD.feeProportionalMillionths, paymentRelay?.feeProportionalMillionths)
559+
560+ // C relays the trampoline payment to D.
561+ val onionD = run {
562+ val payloadD = PaymentOnion .FinalPayload .Standard (
563+ TlvStream (
564+ OnionPaymentPayloadTlv .AmountToForward (finalAmount),
565+ OnionPaymentPayloadTlv .OutgoingCltv (finalExpiry),
566+ OnionPaymentPayloadTlv .PaymentData (randomBytes32(), finalAmount),
567+ OnionPaymentPayloadTlv .PathKey (nextPathKey),
568+ OnionPaymentPayloadTlv .TrampolineOnion (trampolineOnionD)
569+ )
570+ )
571+ OutgoingPaymentPacket .buildOnion(listOf (d), listOf (payloadD), paymentHash, OnionRoutingPacket .PaymentPacketLength ).packet
572+ }
573+
574+ // D receives the payment.
575+ val addD = UpdateAddHtlc (randomBytes32(), 3 , finalAmount, paymentHash, finalExpiry, onionD)
576+ val payloadD = IncomingPaymentPacket .decrypt(addD, privD).right!!
577+ assertIs<PaymentOnion .FinalPayload .Blinded >(payloadD)
578+ assertEquals(finalAmount, payloadD.amount)
579+ assertEquals(finalExpiry, payloadD.expiry)
580+ val paymentMetadata = OfferPaymentMetadata .fromPathId(d, payloadD.pathId)
581+ assertNotNull(paymentMetadata)
582+ assertEquals(offer.offerId, paymentMetadata.offerId)
583+ assertEquals(paymentMetadata.paymentHash, invoice.paymentHash)
584+ }
585+
501586 // See bolt04/trampoline-to-blinded-path-payment-onion-test.json
502587 @Test
503588 fun `send a trampoline payment to blinded paths -- reference test vector` () {
@@ -1316,6 +1401,89 @@ class PaymentPacketTestsCommon : LightningTestSuite() {
13161401 }
13171402 }
13181403
1404+ @Test
1405+ fun `build htlc failure onion -- trampoline payment to blinded trampoline recipient` () {
1406+ // D uses a 1-hop blinded path from its trampoline node C.
1407+ val (invoice, blindedRoute) = run {
1408+ val offer = OfferTypes .Offer .createNonBlindedOffer(finalAmount, " test offer" , d, Features (Feature .TrampolinePayment to FeatureSupport .Optional ), Block .RegtestGenesisBlock .hash)
1409+ val payerKey = randomKey()
1410+ val request = OfferTypes .InvoiceRequest (offer, finalAmount, 1 , offer.features, payerKey, " hello" , Block .RegtestGenesisBlock .hash)
1411+ val paymentMetadata = OfferPaymentMetadata .V1 (offer.offerId, finalAmount, paymentPreimage, payerKey.publicKey(), " hello" , 1 , currentTimestampMillis())
1412+ val blindedPayloadC = RouteBlindingEncryptedData (
1413+ TlvStream (
1414+ RouteBlindingEncryptedDataTlv .OutgoingNodeId (EncodedNodeId (d)),
1415+ RouteBlindingEncryptedDataTlv .PaymentRelay (channelUpdateCD.cltvExpiryDelta, channelUpdateCD.feeProportionalMillionths, channelUpdateCD.feeBaseMsat),
1416+ RouteBlindingEncryptedDataTlv .PaymentConstraints (finalExpiry, 1 .msat),
1417+ )
1418+ )
1419+ val blindedPayloadD = RouteBlindingEncryptedData (TlvStream (RouteBlindingEncryptedDataTlv .PathId (paymentMetadata.toPathId(privD))))
1420+ val blindedRouteDetails = RouteBlinding .create(randomKey(), listOf (c, d), listOf (blindedPayloadC, blindedPayloadD).map { it.write().byteVector() })
1421+ val paymentInfo = createBlindedPaymentInfo(channelUpdateCD)
1422+ val path = Bolt12Invoice .Companion .PaymentBlindedContactInfo (OfferTypes .ContactInfo .BlindedPath (blindedRouteDetails.route), paymentInfo)
1423+ val invoice = Bolt12Invoice (request, paymentPreimage, blindedRouteDetails.blindedPrivateKey(privD), 600 , offer.features, listOf (path))
1424+ assertTrue(invoice.features.hasFeature(Feature .TrampolinePayment ))
1425+ Pair (invoice, blindedRouteDetails.route)
1426+ }
1427+
1428+ // B pays that invoice using its trampoline node C to relay to D using trampoline.
1429+ val (amountBC, expiryBC, onionC) = OutgoingPaymentPacket .buildPacketToTrampolineRecipient(invoice.paymentHash, finalAmount, finalExpiry, invoice.blindedPaths.first(), nodeHop_cd)
1430+ // C decrypts the onion, the trampoline onion and the encrypted data and relays to D.
1431+ val addC = UpdateAddHtlc (randomBytes32(), 1 , amountBC, invoice.paymentHash, expiryBC, onionC.packet)
1432+ val (_, innerC, trampolineOnionD) = decryptRelayToBlindedTrampoline(addC, privC)
1433+ val (addD, willAddD, onionD) = run {
1434+ val encryptedData = innerC.records.get<OnionPaymentPayloadTlv .EncryptedRecipientData >()?.data!!
1435+ val pathKey = innerC.records.get<OnionPaymentPayloadTlv .PathKey >()?.publicKey!!
1436+ val (_, nextPathKey) = RouteBlinding .decryptPayload(privC, pathKey, encryptedData).right!!
1437+ val payloadD = PaymentOnion .FinalPayload .Standard (
1438+ TlvStream (
1439+ OnionPaymentPayloadTlv .AmountToForward (finalAmount),
1440+ OnionPaymentPayloadTlv .OutgoingCltv (finalExpiry),
1441+ OnionPaymentPayloadTlv .PaymentData (randomBytes32(), finalAmount),
1442+ OnionPaymentPayloadTlv .PathKey (nextPathKey),
1443+ OnionPaymentPayloadTlv .TrampolineOnion (trampolineOnionD)
1444+ )
1445+ )
1446+ val onionD = OutgoingPaymentPacket .buildOnion(listOf (d), listOf (payloadD), paymentHash, OnionRoutingPacket .PaymentPacketLength )
1447+ val addD = UpdateAddHtlc (randomBytes32(), 2 , finalAmount, addC.paymentHash, finalExpiry, onionD.packet)
1448+ val willAddD = WillAddHtlc (Block .RegtestGenesisBlock .hash, randomBytes32(), finalAmount, paymentHash, finalExpiry, onionD.packet)
1449+ Triple (addD, willAddD, onionD)
1450+ }
1451+ // D can correctly decrypt the blinded payment.
1452+ run {
1453+ val payloadD = IncomingPaymentPacket .decrypt(addD, privD).right!!
1454+ assertIs<PaymentOnion .FinalPayload .Blinded >(payloadD)
1455+ assertNotNull(OfferPaymentMetadata .fromPathId(d, payloadD.pathId))
1456+ }
1457+ run {
1458+ val payloadD = IncomingPaymentPacket .decrypt(willAddD, privD).right!!
1459+ assertIs<PaymentOnion .FinalPayload .Blinded >(payloadD)
1460+ assertNotNull(OfferPaymentMetadata .fromPathId(d, payloadD.pathId))
1461+ }
1462+
1463+ // D returns a failure: note that it is not a blinded failure, since there is no need to protect the blinded path against probing.
1464+ val failure = IncorrectOrUnknownPaymentDetails (finalAmount, currentBlockCount)
1465+ val encryptedFailuresD = run {
1466+ val encryptedFailureD = OutgoingPaymentPacket .buildHtlcFailure(privD, paymentHash, addD.onionRoutingPacket, addD.pathKey, ChannelCommand .Htlc .Settlement .Fail .Reason .Failure (failure)).right
1467+ assertNotNull(encryptedFailureD)
1468+ val willFailD = OutgoingPaymentPacket .buildWillAddHtlcFailure(privD, willAddD, failure)
1469+ assertIs<WillFailHtlc >(willFailD)
1470+ listOf (encryptedFailureD, willFailD.reason)
1471+ }
1472+ encryptedFailuresD.forEach { encryptedFailureD ->
1473+ // C cannot decrypt the failure.
1474+ assertTrue(FailurePacket .decrypt(encryptedFailureD.toByteArray(), onionD.sharedSecrets).isLeft)
1475+ // C peels the error coming from D and re-wraps it for B.
1476+ val peeled = FailurePacket .wrap(encryptedFailureD.toByteArray(), onionD.sharedSecrets.first().secret).toByteVector()
1477+ val encryptedFailureC = OutgoingPaymentPacket .buildHtlcFailure(privC, paymentHash, addC.onionRoutingPacket, addC.pathKey, ChannelCommand .Htlc .Settlement .Fail .Reason .Bytes (peeled)).right
1478+ assertNotNull(encryptedFailureC)
1479+ // B decrypts the failure.
1480+ val decrypted = FailurePacket .decrypt(encryptedFailureC.toByteArray(), onionC.outerSharedSecrets + onionC.innerSharedSecrets)
1481+ assertTrue(decrypted.isRight)
1482+ assertEquals(blindedRoute.blindedNodeIds.last(), decrypted.right?.originNode)
1483+ assertEquals(failure, decrypted.right?.failureMessage)
1484+ }
1485+ }
1486+
13191487 @Test
13201488 fun `build htlc failure onion -- trampoline payment to blinded non-trampoline recipient` () {
13211489 // D uses a 1-hop blinded path from its trampoline node C.
0 commit comments