diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala index f1cadcef2b..4b2f45703d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala @@ -59,6 +59,7 @@ object IncomingPaymentPacket { def innerPayload: IntermediatePayload.NodeRelay } case class RelayToTrampolinePacket(add: UpdateAddHtlc, outerPayload: FinalPayload.Standard, innerPayload: IntermediatePayload.NodeRelay.Standard, nextPacket: OnionRoutingPacket) extends NodeRelayPacket + case class RelayToBlindedTrampolinePacket(add: UpdateAddHtlc, outerPayload: FinalPayload.Standard, innerPayload: IntermediatePayload.NodeRelay.Blinded, nextPacket: OnionRoutingPacket) extends NodeRelayPacket case class RelayToNonTrampolinePacket(add: UpdateAddHtlc, outerPayload: FinalPayload.Standard, innerPayload: IntermediatePayload.NodeRelay.ToNonTrampoline) extends NodeRelayPacket case class RelayToBlindedPathsPacket(add: UpdateAddHtlc, outerPayload: FinalPayload.Standard, innerPayload: IntermediatePayload.NodeRelay.ToBlindedPaths) extends NodeRelayPacket // @formatter:on @@ -161,9 +162,14 @@ object IncomingPaymentPacket { val trampolinePacket_opt = payload.get[OnionPaymentPayloadTlv.TrampolineOnion].map(_.packet).orElse(payload.get[OnionPaymentPayloadTlv.LegacyTrampolineOnion].map(_.packet)) trampolinePacket_opt match { case Some(trampolinePacket) => - // NB: when we enable blinded trampoline routes, we will need to check if the outer onion contains a - // path key and use it to derive the decryption key for the blinded trampoline onion. - decryptOnion(add.paymentHash, privateKey, trampolinePacket).flatMap { + // If we are an intermediate trampoline node inside a blinded path, the payer doesn't know our node_id + // and has encrypted the trampoline onion to our blinded node_id: in that case, the previous trampoline + // node will provide the path key in the outer onion. + val trampolineOnionDecryptionKey = payload.get[OnionPaymentPayloadTlv.PathKey].map(_.publicKey) match { + case Some(pathKey) => Sphinx.RouteBlinding.derivePrivateKey(privateKey, pathKey) + case None => privateKey + } + decryptOnion(add.paymentHash, trampolineOnionDecryptionKey, trampolinePacket).flatMap { case DecodedOnionPacket(innerPayload, Some(next)) => // We are an intermediate trampoline node. if (innerPayload.get[InvoiceRoutingInfo].isDefined) { @@ -172,7 +178,8 @@ object IncomingPaymentPacket { // The payer is a wallet using the legacy trampoline feature. validateTrampolineToNonTrampoline(add, payload, innerPayload) } else { - validateNodeRelay(add, payload, innerPayload, next) + // The recipient supports trampoline (and may support blinded payments). + validateNodeRelay(add, privateKey, payload, innerPayload, next) } case DecodedOnionPacket(innerPayload, None) => if (innerPayload.get[OutgoingBlindedPaths].isDefined) { @@ -184,8 +191,8 @@ object IncomingPaymentPacket { // They can be reached with the invoice data provided. validateTrampolineToNonTrampoline(add, payload, innerPayload) } else { - // We're the final recipient of this trampoline payment. - validateTrampolineFinalPayload(add, payload, innerPayload) + // We're the final recipient of this trampoline payment (which may be blinded). + validateTrampolineFinalPayload(add, privateKey, payload, innerPayload) } } case None => @@ -228,30 +235,49 @@ object IncomingPaymentPacket { } } - private def validateTrampolineFinalPayload(add: UpdateAddHtlc, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv]): Either[FailureMessage, FinalPacket] = { - // The outer payload cannot use route blinding, but the inner payload may (but it's not supported yet). - FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap { outerPayload => - FinalPayload.Standard.validate(innerPayload).left.map(_.failureMessage).flatMap { - case _ if add.amountMsat < outerPayload.amount => Left(FinalIncorrectHtlcAmount(add.amountMsat)) - case _ if add.cltvExpiry < outerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) - case innerPayload if outerPayload.expiry < innerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) // previous trampoline didn't forward the right expiry - case innerPayload if outerPayload.totalAmount < innerPayload.amount => Left(FinalIncorrectHtlcAmount(outerPayload.totalAmount)) // previous trampoline didn't forward the right amount - case innerPayload => - // We merge contents from the outer and inner payloads. - // We must use the inner payload's total amount and payment secret because the payment may be split between multiple trampoline payments (#reckless). - Right(FinalPacket(add, FinalPayload.Standard.createPayload(outerPayload.amount, innerPayload.totalAmount, innerPayload.expiry, innerPayload.paymentSecret, innerPayload.paymentMetadata))) - } + private def validateTrampolineFinalPayload(add: UpdateAddHtlc, privateKey: PrivateKey, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv]): Either[FailureMessage, FinalPacket] = { + // The outer payload cannot use route blinding, but the inner payload may. + FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap { + case outerPayload if add.amountMsat < outerPayload.amount => Left(FinalIncorrectHtlcAmount(add.amountMsat)) + case outerPayload if add.cltvExpiry < outerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) + case outerPayload => + innerPayload.get[OnionPaymentPayloadTlv.EncryptedRecipientData] match { + case Some(encrypted) => + decryptEncryptedRecipientData(add, privateKey, outerPayload.records, encrypted.data).flatMap { + case DecodedEncryptedRecipientData(blindedPayload, _) => validateBlindedFinalPayload(add, innerPayload, blindedPayload) + } + case None => + FinalPayload.Standard.validate(innerPayload).left.map(_.failureMessage).flatMap { + case innerPayload if outerPayload.expiry < innerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) // previous trampoline didn't forward the right expiry + case innerPayload if outerPayload.totalAmount < innerPayload.amount => Left(FinalIncorrectHtlcAmount(outerPayload.totalAmount)) // previous trampoline didn't forward the right amount + case innerPayload => + // We merge contents from the outer and inner payloads. + // We must use the inner payload's total amount and payment secret because the payment may be split between multiple trampoline payments (#reckless). + Right(FinalPacket(add, FinalPayload.Standard.createPayload(outerPayload.amount, innerPayload.totalAmount, innerPayload.expiry, innerPayload.paymentSecret, innerPayload.paymentMetadata))) + } + } } } - private def validateNodeRelay(add: UpdateAddHtlc, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv], next: OnionRoutingPacket): Either[FailureMessage, RelayToTrampolinePacket] = { - // The outer payload cannot use route blinding, but the inner payload may (but it's not supported yet). - FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap { outerPayload => - IntermediatePayload.NodeRelay.Standard.validate(innerPayload).left.map(_.failureMessage).flatMap { - case _ if add.amountMsat < outerPayload.amount => Left(FinalIncorrectHtlcAmount(add.amountMsat)) - case _ if add.cltvExpiry != outerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) - case innerPayload => Right(RelayToTrampolinePacket(add, outerPayload, innerPayload, next)) - } + private def validateNodeRelay(add: UpdateAddHtlc, privateKey: PrivateKey, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv], next: OnionRoutingPacket): Either[FailureMessage, IncomingPaymentPacket] = { + // The outer payload cannot use route blinding, but the inner payload may. + FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap { + case outerPayload if add.amountMsat < outerPayload.amount => Left(FinalIncorrectHtlcAmount(add.amountMsat)) + case outerPayload if add.cltvExpiry != outerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) + case outerPayload => + innerPayload.get[OnionPaymentPayloadTlv.EncryptedRecipientData] match { + case Some(encrypted) => + // The path key can be found: + // - in the inner payload if we are the introduction node of the blinded path (provided by the payer). + // - in the outer payload if we are an intermediate node in the blinded path (provided by the previous trampoline node). + val pathKey_opt = innerPayload.get[OnionPaymentPayloadTlv.PathKey].orElse(outerPayload.records.get[OnionPaymentPayloadTlv.PathKey]).map(_.publicKey) + decryptEncryptedRecipientData(add, privateKey, pathKey_opt, encrypted.data).flatMap { + case DecodedEncryptedRecipientData(blindedPayload, nextPathKey) => + IntermediatePayload.NodeRelay.Blinded.validate(innerPayload, blindedPayload, nextPathKey).left.map(_.failureMessage).map(innerPayload => RelayToBlindedTrampolinePacket(add, outerPayload, innerPayload, next)) + } + case None => + IntermediatePayload.NodeRelay.Standard.validate(innerPayload).left.map(_.failureMessage).map(innerPayload => RelayToTrampolinePacket(add, outerPayload, innerPayload, next)) + } } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala index 5b64b7808d..e9b30c969d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala @@ -65,6 +65,7 @@ object NodeRelay { private case class WrappedPaymentSent(paymentSent: PaymentSent) extends Command private case class WrappedPaymentFailed(paymentFailed: PaymentFailed) extends Command private case class WrappedPeerReadyResult(result: PeerReadyNotifier.Result) extends Command + private case class WrappedOutgoingNodeId(nodeId_opt: Option[PublicKey]) extends Command private case class WrappedResolvedPaths(resolved: Seq[ResolvedPath]) extends Command private case class WrappedOnTheFlyFundingResponse(result: Peer.ProposeOnTheFlyFundingResponse) extends Command // @formatter:on @@ -108,6 +109,7 @@ object NodeRelay { val incomingPaymentHandler = context.actorOf(MultiPartPaymentFSM.props(nodeParams, paymentHash, totalAmountIn, mppFsmAdapters)) val nextPacket_opt = nodeRelayPacket match { case IncomingPaymentPacket.RelayToTrampolinePacket(_, _, _, nextPacket) => Some(nextPacket) + case IncomingPaymentPacket.RelayToBlindedTrampolinePacket(_, _, _, nextPacket) => Some(nextPacket) case _: IncomingPaymentPacket.RelayToNonTrampolinePacket => None case _: IncomingPaymentPacket.RelayToBlindedPathsPacket => None } @@ -198,6 +200,7 @@ object NodeRelay { case nextPayload: IntermediatePayload.NodeRelay.Standard => failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => e.failureMessage } case nextPayload: IntermediatePayload.NodeRelay.ToNonTrampoline => failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => e.failureMessage } // When using blinded paths, we will never get a failure from the final node (for privacy reasons). + case _: IntermediatePayload.NodeRelay.Blinded => None case _: IntermediatePayload.NodeRelay.ToBlindedPaths => None } val otherNodeFailure = failures.collectFirst { case RemoteFailure(_, _, e) => e.failureMessage } @@ -267,6 +270,28 @@ class NodeRelay private(nodeParams: NodeParams, val recipient = ClearRecipient(payloadOut.outgoingNodeId, features, payloadOut.amountToForward, payloadOut.outgoingCltv, paymentSecret, nextTrampolineOnion_opt = nextPacket_opt) context.log.debug("forwarding payment to the next trampoline node {}", recipient.nodeId) ensureRecipientReady(upstream, recipient, nextPayload, nextPacket_opt) + case payloadOut: IntermediatePayload.NodeRelay.Blinded => + // Blinded paths in Bolt 12 invoices may use an scid to reference the next node, if it is one of our peers. + // We need to resolve that to a nodeId in order to create a payment onion. + payloadOut.outgoing match { + case Left(outgoingNodeId) => context.self ! WrappedOutgoingNodeId(Some(outgoingNodeId)) + case Right(outgoingChannelId) => register ! Register.GetNextNodeId(context.messageAdapter[Option[PublicKey]](WrappedOutgoingNodeId), outgoingChannelId) + } + Behaviors.receiveMessagePartial { + rejectExtraHtlcPartialFunction orElse { + case WrappedOutgoingNodeId(Some(outgoingNodeId)) => + val outgoingAmount = nextPayload.outgoingAmount(upstream.amountIn) + val outgoingExpiry = nextPayload.outgoingExpiry(upstream.expiryIn) + val paymentSecret = randomBytes32() // we generate a new secret to protect against probing attacks + val recipient = ClearRecipient(outgoingNodeId, Features.empty, outgoingAmount, outgoingExpiry, paymentSecret, nextTrampolineOnion_opt = nextPacket_opt, trampolinePathKey_opt = Some(payloadOut.nextPathKey)) + context.log.debug("forwarding payment to the next blinded trampoline node {}", recipient.nodeId) + ensureRecipientReady(upstream, recipient, nextPayload, nextPacket_opt) + case WrappedOutgoingNodeId(None) => + context.log.warn("rejecting trampoline payment to blinded trampoline: cannot identify next node for scid={}", payloadOut.outgoing) + rejectPayment(upstream, Some(UnknownNextPeer())) + stopping() + } + } case payloadOut: IntermediatePayload.NodeRelay.ToNonTrampoline => val paymentSecret = payloadOut.paymentSecret val features = Features(payloadOut.invoiceFeatures).invoiceFeatures() diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/Recipient.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/Recipient.scala index 8b9aa434de..b87d78b55c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/Recipient.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/Recipient.scala @@ -73,6 +73,8 @@ case class ClearRecipient(nodeId: PublicKey, extraEdges: Seq[ExtraEdge] = Nil, paymentMetadata_opt: Option[ByteVector] = None, nextTrampolineOnion_opt: Option[OnionRoutingPacket] = None, + // Must be provided if the payer is using a blinded trampoline path. + trampolinePathKey_opt: Option[PublicKey] = None, customTlvs: Set[GenericTlv] = Set.empty) extends Recipient { // Feature bit used by the legacy trampoline feature. private val isLegacyTrampoline = features.unknown.contains(UnknownFeature(149)) @@ -81,7 +83,7 @@ case class ClearRecipient(nodeId: PublicKey, ClearRecipient.validateRoute(nodeId, route).map(_ => { val finalPayload = nextTrampolineOnion_opt match { case Some(trampolinePacket) if isLegacyTrampoline => NodePayload(nodeId, FinalPayload.Standard.createLegacyTrampolinePayload(route.amount, totalAmount, expiry, paymentSecret, trampolinePacket)) - case Some(trampolinePacket) => NodePayload(nodeId, FinalPayload.Standard.createTrampolinePayload(route.amount, totalAmount, expiry, paymentSecret, trampolinePacket)) + case Some(trampolinePacket) => NodePayload(nodeId, FinalPayload.Standard.createTrampolinePayload(route.amount, totalAmount, expiry, paymentSecret, trampolinePacket, trampolinePathKey_opt)) case None => NodePayload(nodeId, FinalPayload.Standard.createPayload(route.amount, totalAmount, expiry, paymentSecret, paymentMetadata_opt, customTlvs)) } Recipient.buildPayloads(PaymentPayloads(route.amount, expiry, Seq(finalPayload), None), route.hops) @@ -91,7 +93,7 @@ case class ClearRecipient(nodeId: PublicKey, object ClearRecipient { def apply(invoice: Bolt11Invoice, totalAmount: MilliSatoshi, expiry: CltvExpiry, customTlvs: Set[GenericTlv]): ClearRecipient = { - ClearRecipient(invoice.nodeId, invoice.features, totalAmount, expiry, invoice.paymentSecret, invoice.extraEdges, invoice.paymentMetadata, None, customTlvs) + ClearRecipient(invoice.nodeId, invoice.features, totalAmount, expiry, invoice.paymentSecret, invoice.extraEdges, invoice.paymentMetadata, None, None, customTlvs) } def validateRoute(nodeId: PublicKey, route: Route): Either[OutgoingPaymentError, Route] = { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala index 2edc264294..e370099c00 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala @@ -28,8 +28,9 @@ import fr.acinq.eclair.payment.OutgoingPaymentPacket.{NodePayload, buildOnion} import fr.acinq.eclair.payment.PaymentSent.PartialPayment import fr.acinq.eclair.payment._ import fr.acinq.eclair.router.Router.RouteParams -import fr.acinq.eclair.wire.protocol.{PaymentOnion, PaymentOnionCodecs} -import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, Logs, MilliSatoshi, NodeParams, randomBytes32} +import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, EncodedNodeId, Features, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, ShortChannelId, randomBytes32, randomKey} +import scodec.bits.ByteVector import java.util.UUID @@ -108,7 +109,7 @@ class TrampolinePaymentLifecycle private(nodeParams: NodeParams, } private def sendPayment(channels: Seq[Peer.ChannelInfo], attemptNumber: Int): Behavior[Command] = { - val trampolineAmount = computeTrampolineAmount(totalAmount, attemptNumber) + val trampolineAmount = computeTrampolineAmount(totalAmount, attemptNumber, cmd.invoice) // We always use MPP to verify that the trampoline node is able to handle it. // This is a very naive way of doing MPP that simply splits the payment in two HTLCs. val filtered = channels.flatMap(c => { @@ -136,7 +137,7 @@ class TrampolinePaymentLifecycle private(nodeParams: NodeParams, val add = CMD_ADD_HTLC(addHtlcAdapter.toClassic, outgoing.trampolineAmount, paymentHash, outgoing.trampolineExpiry, outgoing.onion.packet, None, 1.0, None, origin, commit = true) channelInfo.channel ! add val channelId = channelInfo.data.asInstanceOf[DATA_NORMAL].channelId - PartialPayment(cmd.paymentId, amount, computeFees(amount, attemptNumber), channelId, None) + PartialPayment(cmd.paymentId, amount, computeFees(amount, attemptNumber, cmd.invoice), channelId, None) } waitForSettlement(remaining = 2, attemptNumber, parts) } @@ -183,7 +184,7 @@ class TrampolinePaymentLifecycle private(nodeParams: NodeParams, } private def retryOrStop(attemptNumber: Int): Behavior[Command] = { - val nextFees = computeFees(totalAmount, attemptNumber) + val nextFees = computeFees(totalAmount, attemptNumber, cmd.invoice) if (attemptNumber > 3) { context.log.warn("cannot retry trampoline payment: retries exceeded") cmd.replyTo ! PaymentFailed(cmd.paymentId, paymentHash, LocalFailure(totalAmount, Nil, new RuntimeException("maximum trampoline retries exceeded")) :: Nil) @@ -218,24 +219,72 @@ object TrampolinePayment { case invoice: Bolt11Invoice => val trampolinePayload = PaymentOnion.IntermediatePayload.NodeRelay.ToNonTrampoline(totalAmount, totalAmount, expiry, invoice.nodeId, invoice) buildOnion(NodePayload(trampolineNodeId, trampolinePayload) :: Nil, invoice.paymentHash, None).toOption.get + case invoice: Bolt12Invoice if invoice.features.hasFeature(Features.TrampolinePayment) => + val path = invoice.blindedPaths.head + require(path.route.firstNodeId.isInstanceOf[EncodedNodeId.WithPublicKey], "blinded path must provide the introduction node_id") + val introductionNodeId = path.route.firstNodeId.asInstanceOf[EncodedNodeId.WithPublicKey].publicKey + // We're creating blinded payloads for an outgoing payment: we don't have access to the decrypted data, so we use dummy data. + val dummyPathId = RouteBlindingEncryptedDataTlv.PathId(ByteVector.empty) + val dummyPaymentRelayData = BlindedRouteData.PaymentRelayData(TlvStream( + RouteBlindingEncryptedDataTlv.OutgoingChannelId(ShortChannelId(0)), + RouteBlindingEncryptedDataTlv.PaymentRelay(CltvExpiryDelta(0), 0, 0 msat), + RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(0), 0 msat) + )) + val finalPayload = NodePayload(path.route.blindedHops.last.blindedPublicKey, PaymentOnion.FinalPayload.Blinded( + records = TlvStream( + OnionPaymentPayloadTlv.AmountToForward(totalAmount), + OnionPaymentPayloadTlv.OutgoingCltv(expiry), + OnionPaymentPayloadTlv.TotalAmount(totalAmount), + OnionPaymentPayloadTlv.EncryptedRecipientData(path.route.blindedHops.last.encryptedPayload), + ), + blindedRecords = TlvStream(dummyPathId), + )) + val intermediatePayloads = path.route.blindedHops.drop(1).dropRight(1).map { b => + NodePayload(b.blindedPublicKey, PaymentOnion.IntermediatePayload.ChannelRelay.Blinded( + records = TlvStream(OnionPaymentPayloadTlv.EncryptedRecipientData(b.encryptedPayload)), + paymentRelayData = dummyPaymentRelayData, + nextPathKey = randomKey().publicKey, + )) + } + val introductionPayload = NodePayload(introductionNodeId, PaymentOnion.IntermediatePayload.ChannelRelay.Blinded( + records = TlvStream(OnionPaymentPayloadTlv.EncryptedRecipientData(path.route.encryptedPayloads.head), OnionPaymentPayloadTlv.PathKey(path.route.firstPathKey)), + paymentRelayData = dummyPaymentRelayData, + nextPathKey = randomKey().publicKey, + )) + // We use our trampoline node to reach the introduction node of the blinded path. + val blindedAmount = totalAmount + path.paymentInfo.fee(totalAmount) + val blindedExpiry = expiry + path.paymentInfo.cltvExpiryDelta + val trampolinePayload = NodePayload(trampolineNodeId, PaymentOnion.IntermediatePayload.NodeRelay.Standard(blindedAmount, blindedExpiry, introductionNodeId)) + buildOnion(trampolinePayload +: introductionPayload +: intermediatePayloads :+ finalPayload, invoice.paymentHash, None).toOption.get case invoice: Bolt12Invoice => val trampolinePayload = PaymentOnion.IntermediatePayload.NodeRelay.ToBlindedPaths(totalAmount, expiry, invoice) buildOnion(NodePayload(trampolineNodeId, trampolinePayload) :: Nil, invoice.paymentHash, None).toOption.get } - val trampolineAmount = computeTrampolineAmount(amount, attemptNumber) - val trampolineTotalAmount = computeTrampolineAmount(totalAmount, attemptNumber) - val trampolineExpiry = computeTrampolineExpiry(expiry, attemptNumber) - val payload = PaymentOnion.FinalPayload.Standard.createTrampolinePayload(trampolineAmount, trampolineTotalAmount, trampolineExpiry, trampolinePaymentSecret, trampolineOnion.packet) + val trampolineAmount = computeTrampolineAmount(amount, attemptNumber, invoice) + val trampolineTotalAmount = computeTrampolineAmount(totalAmount, attemptNumber, invoice) + val trampolineExpiry = computeTrampolineExpiry(expiry, attemptNumber, invoice) + val payload = PaymentOnion.FinalPayload.Standard.createTrampolinePayload(trampolineAmount, trampolineTotalAmount, trampolineExpiry, trampolinePaymentSecret, trampolineOnion.packet, trampolinePathKey_opt = None) val paymentOnion = buildOnion(NodePayload(trampolineNodeId, payload) :: Nil, invoice.paymentHash, Some(PaymentOnionCodecs.paymentOnionPayloadLength)).toOption.get OutgoingPayment(trampolineAmount, trampolineExpiry, paymentOnion, trampolineOnion) } - // We increase the fees paid by 0.2% of the amount sent at each attempt. - def computeFees(amount: MilliSatoshi, attemptNumber: Int): MilliSatoshi = amount * (attemptNumber + 1) * 0.002 + def computeFees(amount: MilliSatoshi, attemptNumber: Int, invoice: Invoice): MilliSatoshi = invoice match { + case _: Bolt11Invoice => + // We increase the fees paid by 0.2% of the amount sent at each attempt. + amount * (attemptNumber + 1) * 0.002 + case _ => + // We increase the fees paid by 1% of the amount sent at each attempt. + // That's because integration tests are much easier to write if the first attempt uses large enough fees and + // expiry because we must inject a plugin actor to respond to HandleInvoiceRequest and HandlePayment. + amount * (attemptNumber + 1) * 0.01 + } - def computeTrampolineAmount(amount: MilliSatoshi, attemptNumber: Int): MilliSatoshi = amount + computeFees(amount, attemptNumber) + def computeTrampolineAmount(amount: MilliSatoshi, attemptNumber: Int, invoice: Invoice): MilliSatoshi = amount + computeFees(amount, attemptNumber, invoice) // We increase the trampoline expiry delta at each attempt. - private def computeTrampolineExpiry(expiry: CltvExpiry, attemptNumber: Int): CltvExpiry = expiry + CltvExpiryDelta(144) * (attemptNumber + 1) + private def computeTrampolineExpiry(expiry: CltvExpiry, attemptNumber: Int, invoice: Invoice): CltvExpiry = invoice match { + case _: Bolt11Invoice => expiry + CltvExpiryDelta(144) * (attemptNumber + 1) + case _ => expiry + CltvExpiryDelta(432) + CltvExpiryDelta(144) * (attemptNumber + 1) + } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/PaymentOnion.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/PaymentOnion.scala index 3f83585677..6c2b14ae5e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/PaymentOnion.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/PaymentOnion.scala @@ -336,6 +336,36 @@ object PaymentOnion { } } + /** + * @param paymentRelayData decrypted relaying data from the encrypted_recipient_data tlv. + * @param nextPathKey path key that must be forwarded to the next hop in the outer onion. + */ + case class Blinded(records: TlvStream[OnionPaymentPayloadTlv], paymentRelayData: PaymentRelayData, nextPathKey: PublicKey) extends NodeRelay { + // @formatter:off + val isLegacy: Boolean = false + val outgoing: Either[PublicKey, ShortChannelId] = paymentRelayData.outgoing + override def outgoingAmount(incomingAmount: MilliSatoshi): MilliSatoshi = paymentRelayData.amountToForward(incomingAmount) + override def outgoingExpiry(incomingCltv: CltvExpiry): CltvExpiry = paymentRelayData.outgoingCltv(incomingCltv) + // @formatter:on + } + + object Blinded { + def validate(records: TlvStream[OnionPaymentPayloadTlv], blindedRecords: TlvStream[RouteBlindingEncryptedDataTlv], nextPathKey: PublicKey): Either[InvalidTlvPayload, Blinded] = { + if (records.get[EncryptedRecipientData].isEmpty) return Left(MissingRequiredTlv(UInt64(10))) + // Bolt 4: MUST return an error if the payload contains other tlv fields than `encrypted_recipient_data` and `current_path_key`. + if (records.unknown.nonEmpty) return Left(ForbiddenTlv(records.unknown.head.tag)) + records.records.find { + case _: EncryptedRecipientData => false + case _: PathKey => false + case _ => true + } match { + case Some(_) => return Left(ForbiddenTlv(UInt64(0))) + case None => // no forbidden tlv found + } + BlindedRouteData.validatePaymentRelayData(blindedRecords).map(paymentRelayData => Blinded(records, paymentRelayData, nextPathKey)) + } + } + /** We relay to a payment recipient that doesn't support trampoline, which exposes its identity. */ case class ToNonTrampoline(records: TlvStream[OnionPaymentPayloadTlv]) extends NodeRelay { val amountToForward = records.get[AmountToForward].get.amount @@ -472,8 +502,15 @@ object PaymentOnion { } /** Create a trampoline outer payload. */ - def createTrampolinePayload(amount: MilliSatoshi, totalAmount: MilliSatoshi, expiry: CltvExpiry, paymentSecret: ByteVector32, trampolinePacket: OnionRoutingPacket): Standard = { - Standard(TlvStream(AmountToForward(amount), OutgoingCltv(expiry), PaymentData(paymentSecret, totalAmount), TrampolineOnion(trampolinePacket))) + def createTrampolinePayload(amount: MilliSatoshi, totalAmount: MilliSatoshi, expiry: CltvExpiry, paymentSecret: ByteVector32, trampolinePacket: OnionRoutingPacket, trampolinePathKey_opt: Option[PublicKey]): Standard = { + val tlvs: Set[OnionPaymentPayloadTlv] = Set( + Some(AmountToForward(amount)), + Some(OutgoingCltv(expiry)), + Some(PaymentData(paymentSecret, totalAmount)), + trampolinePathKey_opt.map(k => PathKey(k)), + Some(TrampolineOnion(trampolinePacket)) + ).flatten + Standard(TlvStream(tlvs)) } /** Create a trampoline outer payload for legacy trampoline payments. */ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala index 9d413dcdb1..7be54a4ccb 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala @@ -32,9 +32,10 @@ import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel.{BroadcastChannelUpdate, PeriodicRefresh} import fr.acinq.eclair.crypto.Sphinx.DecryptedFailurePacket import fr.acinq.eclair.crypto.Sphinx.RouteBlinding.BlindedRoute -import fr.acinq.eclair.crypto.TransportHandler +import fr.acinq.eclair.crypto.{Sphinx, TransportHandler} import fr.acinq.eclair.db._ import fr.acinq.eclair.io.Peer.PeerRoutingMessage +import fr.acinq.eclair.message.OnionMessages import fr.acinq.eclair.message.OnionMessages.{IntermediateNode, Recipient, buildRoute} import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.offer.OfferManager._ @@ -778,6 +779,37 @@ class PaymentIntegrationSpec extends IntegrationSpec { assert(receivedAmount >= amount) } + test("send a blinded payment D->A with trampoline (non-trampoline recipient)") { + val amount = 10_000_000 msat + val chain = nodes("A").nodeParams.chainHash + val offerPath = OnionMessages.buildRoute(randomKey(), Seq(IntermediateNode(nodes("B").nodeParams.nodeId)), Recipient(nodes("A").nodeParams.nodeId, None)) + val offerKey = Sphinx.RouteBlinding.derivePrivateKey(nodes("A").nodeParams.privateKey, offerPath.lastPathKey) + val offer = Offer.withPaths(Some(amount), Some("test offer"), Seq(offerPath.route), nodes("A").nodeParams.features.bolt12Features(), chain) + val offerHandler = TypedProbe[HandlerCommand]()(nodes("A").system.toTyped) + nodes("A").offerManager ! RegisterOffer(offer, Some(offerKey), None, offerHandler.ref) + + val sender = TestProbe() + val dave = new EclairImpl(nodes("D")) + dave.payOfferTrampoline(offer, amount, 1, nodes("C").nodeParams.nodeId, maxAttempts_opt = Some(1))(30 seconds).pipeTo(sender.ref) + + val handleInvoiceRequest = offerHandler.expectMessageType[HandleInvoiceRequest] + val receivingRoutes = Seq(ReceivingRoute(Seq(nodes("B").nodeParams.nodeId, nodes("A").nodeParams.nodeId), CltvExpiryDelta(500))) + handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, receivingRoutes, pluginData_opt = Some(hex"0123")) + + val handlePayment = offerHandler.expectMessageType[HandlePayment] + assert(handlePayment.offerId == offer.offerId) + assert(handlePayment.pluginData_opt.contains(hex"0123")) + handlePayment.replyTo ! PaymentActor.AcceptPayment() + + val paymentSent = sender.expectMsgType[PaymentSent] + assert(paymentSent.recipientAmount == amount, paymentSent) + assert(paymentSent.feesPaid >= 0.msat, paymentSent) + + awaitCond(nodes("A").nodeParams.db.payments.getIncomingPayment(paymentSent.paymentHash).exists(_.status.isInstanceOf[IncomingPaymentStatus.Received])) + val Some(IncomingBlindedPayment(_, _, _, _, IncomingPaymentStatus.Received(receivedAmount, _))) = nodes("A").nodeParams.db.payments.getIncomingPayment(paymentSent.paymentHash) + assert(receivedAmount >= amount) + } + test("send to compact route") { val probe = TestProbe() val recipientKey = randomKey() diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala index cd35d56817..0a46be687f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala @@ -394,7 +394,7 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll { val carol = PublicKey(hex"027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007") val payloads = Seq( NodePayload(bob, PaymentOnion.IntermediatePayload.ChannelRelay.Standard(ShortChannelId.fromCoordinates("572330x7x1105").get, 100_005_000 msat, CltvExpiry(800_250))), - NodePayload(carol, PaymentOnion.FinalPayload.Standard.createTrampolinePayload(100_005_000 msat, 100_005_000 msat, CltvExpiry(800_250), paymentSecret, trampolineOnionForCarol)), + NodePayload(carol, PaymentOnion.FinalPayload.Standard.createTrampolinePayload(100_005_000 msat, 100_005_000 msat, CltvExpiry(800_250), paymentSecret, trampolineOnionForCarol, None)), ) val sessionKey = PrivateKey(hex"0404040404040404040404040404040404040404040404040404040404040404") val onionForBob = OutgoingPaymentPacket.buildOnion(sessionKey, payloads, paymentHash, Some(PaymentOnionCodecs.paymentOnionPayloadLength)).toOption.get.packet @@ -436,7 +436,7 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll { val eve = PublicKey(hex"02edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145") val payloads = Seq( NodePayload(dave, PaymentOnion.IntermediatePayload.ChannelRelay.Standard(ShortChannelId.fromCoordinates("572330x42x1729").get, 100_000_000 msat, CltvExpiry(800_000))), - NodePayload(eve, PaymentOnion.FinalPayload.Standard.createTrampolinePayload(100_000_000 msat, 100_000_000 msat, CltvExpiry(800_000), paymentSecret, trampolineOnionForEve)), + NodePayload(eve, PaymentOnion.FinalPayload.Standard.createTrampolinePayload(100_000_000 msat, 100_000_000 msat, CltvExpiry(800_000), paymentSecret, trampolineOnionForEve, None)), ) val sessionKey = PrivateKey(hex"0505050505050505050505050505050505050505050505050505050505050505") val onionForDave = OutgoingPaymentPacket.buildOnion(sessionKey, payloads, paymentHash, Some(PaymentOnionCodecs.paymentOnionPayloadLength)).toOption.get.packet @@ -457,11 +457,310 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll { // Eve decrypts the payment onion and the inner trampoline onion. val priv = PrivateKey(hex"4545454545454545454545454545454545454545454545454545454545454545") - val add = UpdateAddHtlc(randomBytes32(), 1, 100_000_000.msat, paymentHash, CltvExpiry(800_000), onionForEve, None, 1.0, None) + val add = UpdateAddHtlc(randomBytes32(), 1, 100_000_000 msat, paymentHash, CltvExpiry(800_000), onionForEve, None, 1.0, None) val Right(FinalPacket(_, payloadForEve)) = decrypt(add, priv, Features.empty) assert(payloadForEve == FinalPayload.Standard(TlvStream(AmountToForward(100_000_000 msat), OutgoingCltv(CltvExpiry(800_000)), PaymentData(ByteVector32.fromValidHex("2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a"), 100_000_000 msat)))) } + // See bolt04/trampoline-to-blinded-path-payment-onion-test.json + test("build outgoing trampoline payment to blinded recipient (reference test vector)") { + val preimage = ByteVector32.fromValidHex("8bb624f63457695115152f4bf9950bbd14972a5f49d882cb1a68aa064742c057") + val paymentHash = Crypto.sha256(preimage) + val evePriv = PrivateKey(hex"4545454545454545454545454545454545454545454545454545454545454545") + val eve = evePriv.publicKey + + // Eve creates a blinded path to herself going through Dave. + val pathId = hex"01caa1dcc994683479a217fb32ac30e9c2b6f7960121ca169ae732477e10349b560000000008f0d1808bb624f63457695115152f4bf9950bbd14972a5f49d882cb1a68aa064742c05702414343fd4a723942a86d5f60d2cfecb6c5e8a65595c9995332ec2dba8fe004a20000000000000001000000000000000068656c6c6f188d0b54e9a7df32f0e00c62ace869bac50e0e9c77bcf4c9850bae33d5e48ada65267a3d56111e3c3d84366f3cd53d998256929b0440355dff20455396fe6aac" + val path = { + val dave = PublicKey(hex"032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991") + val blindedPayloadEve = TlvStream[RouteBlindingEncryptedDataTlv](RouteBlindingEncryptedDataTlv.PathId(pathId)) + val blindedPayloadDave = TlvStream[RouteBlindingEncryptedDataTlv]( + RouteBlindingEncryptedDataTlv.OutgoingNodeId(EncodedNodeId(eve)), + RouteBlindingEncryptedDataTlv.PaymentRelay(CltvExpiryDelta(36), 1000, 500 msat), + RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(850_000), 1 msat), + ) + assert(RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec.encode(blindedPayloadDave).require.bytes == hex"042102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145 0a080024000003e801f4 0c05000cf85001") + assert(RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec.encode(blindedPayloadEve).require.bytes == hex"06 bf 01caa1dcc994683479a217fb32ac30e9c2b6f7960121ca169ae732477e10349b560000000008f0d1808bb624f63457695115152f4bf9950bbd14972a5f49d882cb1a68aa064742c05702414343fd4a723942a86d5f60d2cfecb6c5e8a65595c9995332ec2dba8fe004a20000000000000001000000000000000068656c6c6f188d0b54e9a7df32f0e00c62ace869bac50e0e9c77bcf4c9850bae33d5e48ada65267a3d56111e3c3d84366f3cd53d998256929b0440355dff20455396fe6aac") + val sessionKey = PrivateKey(hex"090a684b173ac8da6716859095a779208943cf88680c38c249d3e8831e2caf7e") + val blindedRouteDetails = Sphinx.RouteBlinding.create(sessionKey, Seq(dave, eve), Seq(blindedPayloadDave, blindedPayloadEve).map { p => + RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec.encode(p).require.bytes + }) + assert(EncodedNodeId(dave) == blindedRouteDetails.route.firstNodeId) + assert(PublicKey(hex"02c952268f1501cf108839f4f5d0fbb41a97de778a6ead8caf161c569bd4df1ad7") == blindedRouteDetails.lastPathKey) + assert(PublicKey(hex"02988face71e92c345a068f740191fd8e53be14f0bb957ef730d3c5f76087b960e") == blindedRouteDetails.route.firstPathKey) + val blindedNodes = Seq( + PublicKey(hex"0295d40514096a8be54859e7dfe947b376eaafea8afe5cb4eb2c13ff857ed0b4be"), + PublicKey(hex"020e2dbadcc2005e859819ddebbe88a834ae8a6d2b049233c07335f15cd1dc5f22"), + ) + assert(blindedNodes == blindedRouteDetails.route.blindedNodeIds) + val encryptedPayloads = Seq( + hex"0ccf3c8a58deaa603f657ee2a5ed9d604eb5c8ca1e5f801989afa8f3ea6d789bbdde2c7e7a1ef9ca8c38d2c54760febad8446d3f273ddb537569ef56613846ccd3aba78a", + hex"bcd747394fbd4d99588da075a623316e15a576df5bc785cccc7cd6ec7b398acce6faf520175f9ec920f2ef261cdb83dc28cc3a0eeb970107b3306489bf771ef5b1213bca811d345285405861d08a655b6c237fa247a8b4491beee20c878a60e9816492026d8feb9dafa84585b253978db6a0aa2945df5ef445c61e801fb82f43d5f00716baf9fc9b3de50bc22950a36bda8fc27bfb1242e5860c7e687438d4133e058770361a19b6c271a2a07788d34dccc27e39b9829b061a4d960eac4a2c2b0f4de506c24f9af3868c0aff6dda27281c", + ) + assert(encryptedPayloads == blindedRouteDetails.route.encryptedPayloads) + val paymentInfo = OfferTypes.PaymentInfo(500 msat, 1000, CltvExpiryDelta(36), 1 msat, 500_000_000 msat, Features.empty) + PaymentBlindedRoute(blindedRouteDetails.route, paymentInfo) + } + + // Alice creates a trampoline onion using Eve's blinded path and starting at Carol (Carol -> Dave -> Eve). + val trampolineOnion = { + val carol = PublicKey(hex"027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007") + val dave = PublicKey(hex"032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991") + assert(EncodedNodeId(dave) == path.route.firstNodeId) + val payloadEve = PaymentOnion.FinalPayload.Blinded( + records = TlvStream( + OnionPaymentPayloadTlv.AmountToForward(150_000_000 msat), + OnionPaymentPayloadTlv.OutgoingCltv(CltvExpiry(800_000)), + OnionPaymentPayloadTlv.TotalAmount(150_000_000 msat), + OnionPaymentPayloadTlv.EncryptedRecipientData(path.route.encryptedPayloads.last), + ), + blindedRecords = TlvStream(RouteBlindingEncryptedDataTlv.PathId(pathId)), + ) + assert(PaymentOnionCodecs.perHopPayloadCodec.encode(payloadEve.records).require.bytes == hex"e4 020408f0d180 04030c3500 0ad1bcd747394fbd4d99588da075a623316e15a576df5bc785cccc7cd6ec7b398acce6faf520175f9ec920f2ef261cdb83dc28cc3a0eeb970107b3306489bf771ef5b1213bca811d345285405861d08a655b6c237fa247a8b4491beee20c878a60e9816492026d8feb9dafa84585b253978db6a0aa2945df5ef445c61e801fb82f43d5f00716baf9fc9b3de50bc22950a36bda8fc27bfb1242e5860c7e687438d4133e058770361a19b6c271a2a07788d34dccc27e39b9829b061a4d960eac4a2c2b0f4de506c24f9af3868c0aff6dda27281c 120408f0d180") + val payloadDave = PaymentOnion.IntermediatePayload.NodeRelay.Blinded( + records = TlvStream( + OnionPaymentPayloadTlv.EncryptedRecipientData(path.route.encryptedPayloads.head), + OnionPaymentPayloadTlv.PathKey(path.route.firstPathKey), + ), + paymentRelayData = BlindedRouteData.PaymentRelayData(TlvStream( + RouteBlindingEncryptedDataTlv.OutgoingNodeId(EncodedNodeId(eve)), + RouteBlindingEncryptedDataTlv.PaymentRelay(CltvExpiryDelta(36), 1000, 500 msat), + RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(850_000), 1 msat), + )), + nextPathKey = randomKey().publicKey + ) + assert(PaymentOnionCodecs.perHopPayloadCodec.encode(payloadDave.records).require.bytes == hex"69 0a440ccf3c8a58deaa603f657ee2a5ed9d604eb5c8ca1e5f801989afa8f3ea6d789bbdde2c7e7a1ef9ca8c38d2c54760febad8446d3f273ddb537569ef56613846ccd3aba78a 0c2102988face71e92c345a068f740191fd8e53be14f0bb957ef730d3c5f76087b960e") + val payloadCarol = PaymentOnion.IntermediatePayload.NodeRelay.Standard(150_150_500 msat, CltvExpiry(800_036), dave) + assert(PaymentOnionCodecs.perHopPayloadCodec.encode(payloadCarol.records).require.bytes == hex"2e 020408f31d64 04030c3524 0e21032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991") + val sessionKey = PrivateKey(hex"a64feb81abd58e473df290e9e1c07dc3e56114495cadf33191f44ba5448ebe99") + val trampolineOnion = OutgoingPaymentPacket.buildOnion(sessionKey, NodePayload(carol, payloadCarol) :: NodePayload(dave, payloadDave) :: NodePayload(path.route.blindedNodeIds.last, payloadEve) :: Nil, paymentHash, None).toOption.get.packet + val encoded = OnionRoutingCodecs.onionRoutingPacketCodec(trampolineOnion.payload.length.toInt).encode(trampolineOnion).require.bytes + assert(encoded == hex"0002bc59a9abc893d75a8d4f56a6572f9a3507323a8de22abe0496ea8d37da166a8b4bba0e560f1a9deb602bfd98fe9167141d0b61d669df90c0149096d505b85d3d02806e6c12caeb308b878b6bc7f1b15839c038a6443cd3bec3a94c2293165375555f6d7720862b525930f41fddcc02260d197abd93fb58e60835fd97d9dc14e7979c12f59df08517b02e3e4d50e1817de4271df66d522c4e9675df71c635c4176a8381bc22b342ff4e9031cede87f74cc039fca74aa0a3786bc1db2e158a9a520ecb99667ef9a6bbfaf5f0e06f81c27ca48134ba2103229145937c5dc7b8ecc5201d6aeb592e78faa3c05d3a035df77628f0be9b1af3ef7d386dd5cc87b20778f47ebd40dbfcf12b9071c5d7112ab84c3e0c5c14867e684d09a18bc93ac47d73b7343e3403ef6e3b70366835988920e7d772c3719d3596e53c29c4017cb6938421a557ce81b4bb26701c25bf622d4c69f1359dc85857a375c5c74987a4d3152f66987001c68a50c4bf9e0b1dab4ad1a64b0535319bbf6c4fbe4f9c50cb65f5ef887bfb91b0a57c0f86ba3d91cbeea1607fb0c12c6c75d03bbb0d3a3019c40597027f5eebca23083e50ec79d41b1152131853525bf3fc13fb0be62c2e3ce733f59671eee5c4064863fb92ae74be9ca68b9c716f9519fd268478ee27d91d466b0de51404de3226b74217d28250ead9d2c95411e0230570f547d4cc7c1d589791623131aa73965dccc5aa17ec12b442215ce5d346df664d799190df5dd04a13") + trampolineOnion + } + + // Alice creates a payment onion for Carol (Alice -> Bob -> Carol). + val onionForBob = { + val sessionKey = PrivateKey(hex"4f777e8dac16e6dfe333066d9efb014f7a51d11762ff76eca4d3a95ada99ba3e") + val bob = PublicKey(hex"0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c") + val carol = PublicKey(hex"027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007") + val paymentSecret = ByteVector32.fromValidHex("7494b65bc092b48a75465e43e29be807eb2cc535ce8aaba31012b8ff1ceac5da") + val payloadBob = PaymentOnion.IntermediatePayload.ChannelRelay.Standard(ShortChannelId.fromCoordinates("572330x42x2821").get, 150_153_000 msat, CltvExpiry(800_060)) + assert(PaymentOnionCodecs.perHopPayloadCodec.encode(payloadBob.records).require.bytes == hex"15 020408f32728 04030c353c 060808bbaa00002a0b05") + val payloadCarol = PaymentOnion.FinalPayload.Standard.createTrampolinePayload(150_153_000 msat, 150_153_000 msat, CltvExpiry(800_060), paymentSecret, trampolineOnion, None) + assert(PaymentOnionCodecs.perHopPayloadCodec.encode(payloadCarol.records).require.bytes == hex"fd0255 020408f32728 04030c353c 08247494b65bc092b48a75465e43e29be807eb2cc535ce8aaba31012b8ff1ceac5da08f32728 14fd02200002bc59a9abc893d75a8d4f56a6572f9a3507323a8de22abe0496ea8d37da166a8b4bba0e560f1a9deb602bfd98fe9167141d0b61d669df90c0149096d505b85d3d02806e6c12caeb308b878b6bc7f1b15839c038a6443cd3bec3a94c2293165375555f6d7720862b525930f41fddcc02260d197abd93fb58e60835fd97d9dc14e7979c12f59df08517b02e3e4d50e1817de4271df66d522c4e9675df71c635c4176a8381bc22b342ff4e9031cede87f74cc039fca74aa0a3786bc1db2e158a9a520ecb99667ef9a6bbfaf5f0e06f81c27ca48134ba2103229145937c5dc7b8ecc5201d6aeb592e78faa3c05d3a035df77628f0be9b1af3ef7d386dd5cc87b20778f47ebd40dbfcf12b9071c5d7112ab84c3e0c5c14867e684d09a18bc93ac47d73b7343e3403ef6e3b70366835988920e7d772c3719d3596e53c29c4017cb6938421a557ce81b4bb26701c25bf622d4c69f1359dc85857a375c5c74987a4d3152f66987001c68a50c4bf9e0b1dab4ad1a64b0535319bbf6c4fbe4f9c50cb65f5ef887bfb91b0a57c0f86ba3d91cbeea1607fb0c12c6c75d03bbb0d3a3019c40597027f5eebca23083e50ec79d41b1152131853525bf3fc13fb0be62c2e3ce733f59671eee5c4064863fb92ae74be9ca68b9c716f9519fd268478ee27d91d466b0de51404de3226b74217d28250ead9d2c95411e0230570f547d4cc7c1d589791623131aa73965dccc5aa17ec12b442215ce5d346df664d799190df5dd04a13") + val onionForBob = OutgoingPaymentPacket.buildOnion(sessionKey, NodePayload(bob, payloadBob) :: NodePayload(carol, payloadCarol) :: Nil, paymentHash, Some(PaymentOnionCodecs.paymentOnionPayloadLength)).toOption.get.packet + val encoded = PaymentOnionCodecs.paymentOnionPacketCodec.encode(onionForBob).require.bytes + assert(encoded == hex"00025fd60556c134ae97e4baedba220a644037754ee67c54fd05e93bf40c17cbb73362fb9dee96001ff229945595b6edb59437a6bc143406d3f90f749892a84d8d430c6890437d26d5bfc599d565316ef51347521075bbab87c59c57bcf20af7e63d7192b46cf171e4f73cb11f9f603915389105d91ad630224bea95d735e3988add1e24b5bf28f1d7128db64284d90a839ba340d088c74b1fb1bd21136b1809428ec5399c8649e9bdf92d2dcfc694deae5046fa5b2bdf646847aaad73f5e95275763091c90e71031cae1f9a770fdea559642c9c02f424a2a28163dd0957e3874bd28a97bec67d18c0321b0e68bc804aa8345b17cb626e2348ca06c8312a167c989521056b0f25c55559d446507d6c491d50605cb79fa87929ce64b0a9860926eeaec2c431d926a1cadb9a1186e4061cb01671a122fc1f57602cbef06d6c194ec4b715c2e3dd4120baca3172cd81900b49fef857fb6d6afd24c983b608108b0a5ac0c1c6c52011f23b8778059ffadd1bb7cd06e2525417365f485a7fd1d4a9ba3818ede7cdc9e71afee8532252d08e2531ca52538655b7e8d912f7ec6d37bbcce8d7ec690709dbf9321e92c565b78e7fe2c22edf23e0902153d1ca15a112ad32fb19695ec65ce11ddf670da7915f05ad4b86c154fb908cb567315d1124f303f75fa075ebde8ef7bb12e27737ad9e4924439097338ea6d7a6fc3721b88c9b830a34e8d55f4c582b74a3895cc848fe57f4fe29f115dabeb6b3175be15d94408ed6771109cfaf57067ae658201082eae7605d26b1449af4425ae8e8f58cdda5c6265f1fd7a386fc6cea3074e4f25b909b96175883676f7610a00fdf34df9eb6c7b9a4ae89b839c69fd1f285e38cdceb634d782cc6d81179759bc9fd47d7fd060470d0b048287764c6837963274e708314f017ac7dc26d0554d59bfcfd3136225798f65f0b0fea337c6b256ebbb63a90b994c0ab93fd8b1d6bd4c74aebe535d6110014cd3d525394027dfe8faa98b4e9b2bee7949eb1961f1b026791092f84deea63afab66603dbe9b6365a102a1fef2f6b9744bc1bb091a8da9130d34d4d39f25dbad191649cfb67e10246364b7ce0c6ec072f9690cabb459d9fda0c849e17535de4357e9907270c75953fca3c845bb613926ecf73205219c7057a4b6bb244c184362bb4e2f24279dc4e60b94a5b1ec11c34081a628428ba5646c995b9558821053ba9c84a05afbf00dabd60223723096516d2f5668f3ec7e11612b01eb7a3a0506189a2272b88e89807943adb34291a17f6cb5516ffd6f945a1c42a524b21f096d66f350b1dad4db455741ae3d0e023309fbda5ef55fb0dc74f3297041448b2be76c525141963934c6afc53d263fb7836626df502d7c2ee9e79cbbd87afd84bbb8dfbf45248af3cd61ad5fac827e7683ca4f91dfad507a8eb9c17b2c9ac5ec051fe645a4a6cb37136f6f19b611e0ea8da7960af2d779507e55f57305bc74b7568928c5dd5132990fe54c22117df91c257d8c7b61935a018a28c1c3b17bab8e4294fa699161ec21123c9fc4e71079df31f300c2822e1246561e04765d3aab333eafd026c7431ac7616debb0e022746f4538e1c6348b600c988eeb2d051fc60c468dca260a84c79ab3ab8342dc345a764672848ea234e17332bc124799daf7c5fcb2e2358514a7461357e1c19c802c5ee32deccf1776885dd825bedd5f781d459984370a6b7ae885d4483a76ddb19b30f47ed47cd56aa5a079a89793dbcad461c59f2e002067ac98dd5a534e525c9c46c2af730741bf1f8629357ec0bfc0bc9ecb31af96777e507648ff4260dc3673716e098d9111dfd245f1d7c55a6de340deb8bd7a053e5d62d760f184dc70ca8fa255b9023b9b9aedfb6e419a5b5951ba0f83b603793830ee68d442d7b88ee1bbf6bbd1bcd6f68cc1af") + onionForBob + } + + // Bob decrypts the onion and relays to Carol. + val onionForCarol = { + val bobPriv = PrivateKey(hex"4242424242424242424242424242424242424242424242424242424242424242") + val add = UpdateAddHtlc(randomBytes32(), 1, 150_155_000 msat, paymentHash, CltvExpiry(800_100), onionForBob, None, 1.0, None) + val Right(packetForBob: ChannelRelayPacket) = decrypt(add, bobPriv, Features.empty) + assert(packetForBob.payload.outgoing.contains(ShortChannelId.fromCoordinates("572330x42x2821").get)) + assert(packetForBob.amountToForward == 150_153_000.msat) + assert(packetForBob.outgoingCltv == CltvExpiry(800_060)) + val encoded = PaymentOnionCodecs.paymentOnionPacketCodec.encode(packetForBob.nextPacket).require.bytes + assert(encoded == hex"0003dc6a4c9b34bdcd2191fc4dacfc1aeb20f71991acbd17847b9ab17d69579c1614da1276d18820e55534d7352839caa436aa79a6d5be26c6ecbd1c79f74442b1b7bf8ed8b739b736b9248769c2c422eebc85fb0d580e9561b1cda1be3fd4cfa6ed0d839a2feb878acf686b112febaae9c1494a2ad20d97b2d2f7e59d6d296a104ba1b29e5d06d7a7d0279e627c51d6eed9c6a56bdbde81b22dc92e07e151546fc9568d4b27d0a1e217c3caf42e8f6e629829ee76e2a077fd1eb38dcce22217458c529cc7a9df4adc507ead08b267a722cb6b06cfa3d2a35b6387f878fd7b18e6debe7c2ff03603687adfc654606756b2c609a891956a9f4c2918d625632833ca371fe605da31a10044393c240bf4db8a1e413da7f8a6ea9dc80b0031e0dc43c1f5922ca5003e87f405ef73fc492bc813962192e3c4c4801c0f03baab2e0aba3a6cd101f8d09de15c027bff835beacc5ba09420323f4d5f75e6818939fbe02cb2aaa3e6651d512eee37ded2f27406974a3fedf77c8364beb2ce60d869b0de5ce33c466406b45e5791e189f0795c623d786d794c3d9b927b9fb7fde99df2b4359da128496ceb1a336049f05f06937c45d0fd90f10f7654ea4bb5734d5a9a3e0b2ace8ac771494a6e442fedd2314772e761704ad16f8aa9a16832b30987535c43963e880acda194119407de24fcf23558596a2848d6c98430f504e9281127b2bf649a25c6c1d35783d509f17fec8c2c0ee4004778d66822db24f01bc3361454efb60cde453c6cfe33f2f0177a3b0e326a077a0d8c433f8a21613fb62ceb2f314aa69f81b7756e76ac9f6c6dcfdace9a320e1afc738b223d192fcbc0adf5cd84f4cc161abe0455c26cdaa1270b2823fedbe7ee982ca5af8c8b8bec0ba90c3bb65a8e1ee89cc6c44114d658fb89985c7ca8e8eeb32bcf3fedae62330eff3da9654fda0a58281480d4be76c916889b2db9210e3a66c9ccaa3f06232150d5d96cbf6c18916d603e1495ab6f17baacb5005ed5ec17864c2da1bb400e6e68975cc84325e18215a6052313c3c75e25163648c840506916c2d845760063d6a4385df4f54bd0ec5da029b837202e45e399d1ee794160f49cd6d0149457d3efc2537e2ec36ce6a02727a5104d6f37fd612fec4e96f169f4f7d66706ea7d9ba344bef8e2e57664bde30f26249664bdc3eeb1dfa88a9f33e6d790581b67d57c30255d43624751f269da3f98df459a6bafe6e37d62be589eb938d8d223c7e80038a8dae2313126822fe16771a6ab6598d15bc350ddd76180e0963b5765834365254c611e2de46bde204a0377e13dab44afdacf5d77465dc035ca1ff70603f5887a023cca650ab9e3b4244f3a1870bb07b2556e3bcd47fafa3adec659aee17a881310c208b2d696dc14fbdd2209d89e7e61bbee19263ea98eb994eef0dc97e2ae0e56a6fc9592f9e27de5a22d749c9dbed19f2b2b8602ff890e82fedbdb41e019fbaf74256d6bcaf31538fff1329ed30b4b7bd991e9f1b5c6ef4f119387fcb7875f4f21f2d39b0ad01500201da644158e1260a58b4bcd1712ca3bc6e093951424452197b4fb3ac2aaf16e70fcedddd9fa96fcf46c2d60cb40a64c807fffb2448672c5bb2afce2205fa1d356d4cdb907a25b82c27e4fb735375c1f532fbbdd43c415a27e603cf15ff7f00f1ac96f346c2dcc00ffeb682db175b912cb5356f7528a834fe84df2df7453e34dd01008a087e799c18656eefe3038a03c71803bbc0990cd50f4a413329e6f779107d57158e78886728a9fa039c385abc92e230179051a02727402e7a613364b48cd93b6a26cb6888d5c4cc1d3c6a39cb442c2de12bb1ad396205c9a10023d7edc951fc60a28813867f635c3d74d4206f398849e94750b98ba43c5faca8502bf46929e3c0debfac32fc4e4a09c2436a0590cd53c") + packetForBob.nextPacket + } + + // Carol decrypts the onion and relays to Dave, without knowing that a blinded path starts at Dave. + val onionForDave = { + val carolPriv = PrivateKey(hex"4343434343434343434343434343434343434343434343434343434343434343") + val dave = PublicKey(hex"032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991") + val add = UpdateAddHtlc(randomBytes32(), 1, 150_153_000 msat, paymentHash, CltvExpiry(800_060), onionForCarol, None, 1.0, None) + val Right(RelayToTrampolinePacket(_, payload, trampolinePayload, trampolineOnionForDave)) = decrypt(add, carolPriv, Features.empty) + assert(payload.amount == 150_153_000.msat) + assert(payload.expiry == CltvExpiry(800_060)) + assert(payload.paymentSecret == ByteVector32.fromValidHex("7494b65bc092b48a75465e43e29be807eb2cc535ce8aaba31012b8ff1ceac5da")) + assert(trampolinePayload.amountToForward == 150_150_500.msat) + assert(trampolinePayload.outgoingCltv == CltvExpiry(800_036)) + assert(trampolinePayload.outgoingNodeId == dave) + val sessionKey = PrivateKey(hex"e4acea94d5ddce1a557229bc39f8953ec1398171f9c2c6bb97d20152933be4c4") + val paymentSecret = ByteVector32.fromValidHex("d1818c04937ace92037fc46e57bafbfc7ec09521bf49ff1564b10832633c2a93") + val payloadDave = PaymentOnion.FinalPayload.Standard.createTrampolinePayload(150_150_500 msat, 150_150_500 msat, CltvExpiry(800_036), paymentSecret, trampolineOnionForDave, None) + assert(PaymentOnionCodecs.perHopPayloadCodec.encode(payloadDave.records).require.bytes == hex"fd0255 020408f31d64 04030c3524 0824d1818c04937ace92037fc46e57bafbfc7ec09521bf49ff1564b10832633c2a9308f31d64 14fd02200003868a4ec9883bc8332dcd63bafc80086fe046bf2795c070445381f98f5b4678efc8c102fee084102c1ffb91cae87bbbdce3ef59e68af26deae97af39879713b71df2c31e56acbddf7cc8f85214162785839e981a3abb51749d7cab6e09956a7e384fa658323e144293c7328f6f9097705b05eed2f11107cafdf4f6f6de7a53512e192276386c83f91809462f8f2737b8729d35ce145999770edae36808757db3aa3e77dbd8dc517fb0437e2660b16ef728fbcadf7d7f3cb4395924d1bb50a14ce8ba68635e73a7fa3d55f2a9fa796635a8a1dc6c1a3b72c491d4b1fd5fe642e6decb93d28223e79e4a69ffe71bc6e595b949e4071a2ffa65bd9099d6af7bf7f26065f032969ce33b78195cc741e2c97f801311368aee7e75159de00f6dc2b0b2b2e77c583ce8fe4ae61b774491dfefacc2aa3dfb99d6d00689a344def2086405caa2e2dc2126dc7b47750f3393f492c8b5c96bcd609e1c56a2d713ec9f6c0618a33ddfb20f2f3cbe355424292de47b6374bc012390a433e02f31cfa8a9817bf6a5597ac42b063e1cf3aaf6666b5d420600c8fc8ce689678bd802ac3815f9aaf6a48d0d3a7f940f621bd74d3e738b40c4c67f5b54b258e57d15584cf84ee2ad61c8a1fabb0e035fdb67f92f54f14797fd20bddee25d3a1ea9982757778c311f77dba90013d37780535acc4ef2281ebabf1736cb188fe7f08dc861d61a4135f295d85eb02e3a8f0015c6bcd206c7b5162f0696c1d69a06e42918dbe8fd9affb") + val onionForDave = OutgoingPaymentPacket.buildOnion(sessionKey, NodePayload(dave, payloadDave) :: Nil, paymentHash, Some(PaymentOnionCodecs.paymentOnionPayloadLength)).toOption.get.packet + val encoded = PaymentOnionCodecs.paymentOnionPacketCodec.encode(onionForDave).require.bytes + assert(encoded == hex"0003626d5fa7ed0b8706f975ca58ae2c645889514153568aaab7835325ecd3739a57cd1f65ed63fff058e29e81f660c134ea43937b9d93fb0caf3846b176734c292b7c7fd7a576391e63c0fe0023bdeabcc9971f4a44db079bf8203b0a52ec47c86653207ea6596c8d029793acd2327ff788dcf8a9ee424c2aa7798c95ce53fae6c9be85a0d276ff20c814c26f6e4f19a2152ffbd931a9d054976cafe556b00365ef54edc9a3f8021c73f17884ef209be13e7710df3b93efa8edd2b4eda306766f6dabb517ed720076cc3e7395373dc81e018f347109bcbc0265f0d5098489f83212a2e5c1583c8411dea509a17735713e8cc66d4a89cd56d0a35e0d3f59553b6b017eb5c9b6dcd724ca79c0d4aa5706a34503895655d2fc186ec772a17ba46e8961c37c61fe173f448d83efd1c6f78ce5f64fdfa352c8ab1c7d3aeddeeacc409a92fe87f86a07cfec33c5a7a2f3834d8dfba1291bed88271cb13c210fbfb1ced93440bee260f6335044a95d7aaa14bed06e1d3b9ec7c82db822543ffa4eff34e88a715ff8f8a23017982c6ae882ee18e0bdabba6f7d0e4285d034a2fd2a903b9ccfce534f9fba5acdc91f91d723ae359b9c2823b7e24dd2ee8d7ca3f6384976f5d319172785c948964426332c811682a643ce8c9575431e84c9af7fcf0dfdcdc67bc3e0b2415719e5a0da868dafef91be595c280bdb67e1d586c184c71617bddddfc653f4a1083658a96fe86cfa0eb93f9182fddccbb64f9e9e2b8f0c4b6edeb2aa81f3e16a24ffce789d183e0fa3689e437a1180b44dac0a5bb3bacd1ab0dab53c55194419c0e194f31ca683cf7c9b3ef304ff19dd490a6ad80233c1855b680f34e0eb2d252689ad5cd1750a793529a83b194e7d410f6cf027ae78f94b5d0405feeef397e272050d27581bb996eab562cd71d6aec3e4a793b93c950dcbadeb1d8cef4e9b5a466a06f1051f0eab1d08896c3eeb20d55118dec43ac5dcb8e90e1e3bcb4a67c419c4d825111ff450a6241b31087ef70f2112da8940a834441f2e0f7883eed5ef2dd09d57c23eec75ccf443ac02197f2f6cbf8c47fd8753cca90e1375c2c04dc985500f3fe147f72121bc1020f430fb199161897d38765bf0480e8d0505cb09eb6cefc842eb93edbf7d7a99c1f9f7f09db3e6dd3e5116f29d1b1497f940fc341b6ed90f187c68a14b00a845303a248187f7699ab9ebb0e6c9355ade7772703faa4380cfd88c9600f2147747b402d3621d2410f3a6c60367b3bed950186f966db77c58d83bb29305748b8fbf6da1e8d3d1d7251ce0812170e999fa35128cada2ff53d7cb42f37ebc3e758c6f571eb3c8e94f3535d422ebd1b11788c9ac75292d15b759a612250c97efa01fa869187cf8cdadf95ebccafa18ca9b40076828d459d7a295528fd3c77c5f3978d7fa6244466a056ebdf59902907b13bd6255904aee68cbbb46a81ce1f5cd541a3935a2229d0501f1c272624157501841eaaf6703b6b40a12a010e68bb563e2794ed7d3976ff9b59bb1f6719b6d06a0cd3d561bdcd0b2761ccbec1c4decb2faf13065ba05f633d114cdf8d61fd33c3d6f149319adc4b0367df11b77c92c75a57de20bb8775d0582be511e6139b27bea77e60e7180ba292a94944f87a91a20cabc8346ccde196e74b5bfde5a613ea5536292971cd3737980efac61d8189926b14cc1f5ea553b0f41afc2c3f6bc7d19f078cff09b2181d1b9dd068ac8a8116dd96a418c1e24f7cf22c54f7dd7c40812b7e36805d7adccbf5c8a703e3891c05caf6b4434192940ea2f164d0ae84355d1a33859d45107abcd41598da0afa4fe5f8c5a3ecb9b4857ac0e736fc76b4f51325391530a645618656a3dae74cba34a34bb7a3c0e9523f6db6b31694e8ceb9c67ae658af7db5a4c8de9e8322c3172fed09f27aa4420ae9a0a") + onionForDave + } + + // Dave decrypts the onion and blinded path data and relays to Eve. + val onionForEve = { + val davePriv = PrivateKey(hex"4444444444444444444444444444444444444444444444444444444444444444") + val add = UpdateAddHtlc(randomBytes32(), 3, 150_150_500 msat, paymentHash, CltvExpiry(800_036), onionForDave, None, 1.0, None) + val Right(RelayToBlindedTrampolinePacket(_, payload, innerPayload, trampolineOnionForEve)) = decrypt(add, davePriv, Features(Features.RouteBlinding -> FeatureSupport.Optional)) + assert(payload.amount == 150_150_500.msat) + assert(payload.expiry == CltvExpiry(800_036)) + assert(payload.paymentSecret == ByteVector32.fromValidHex("d1818c04937ace92037fc46e57bafbfc7ec09521bf49ff1564b10832633c2a93")) + assert(innerPayload.outgoing == Left(eve)) + assert(innerPayload.outgoingAmount(add.amountMsat) == 150_000_000.msat) + assert(innerPayload.outgoingExpiry(add.cltvExpiry) == CltvExpiry(800_000)) + val sessionKey = PrivateKey(hex"cfeb31c76b7b6905be8da966ce2d9a87e3abbb03d236d7346c2852862c87a4b8") + val paymentSecret = ByteVector32.fromValidHex("1221f15a9dece128347dac673d6171be13b3d92c9c77ff581506507045a1d2e8") + val payloadEve = PaymentOnion.FinalPayload.Standard.createTrampolinePayload(150_000_000 msat, 150_000_000 msat, CltvExpiry(800_000), paymentSecret, trampolineOnionForEve, Some(innerPayload.nextPathKey)) + assert(PaymentOnionCodecs.perHopPayloadCodec.encode(payloadEve.records).require.bytes == hex"fd0278 020408f0d180 04030c3500 08241221f15a9dece128347dac673d6171be13b3d92c9c77ff581506507045a1d2e808f0d180 0c2102c952268f1501cf108839f4f5d0fbb41a97de778a6ead8caf161c569bd4df1ad7 14fd022000038da50a45c30a086668ad1c34a23c11ee94bf0b4e7b8b8b184b7914645aef9e1ecf8f95df787c87965210644a84d1da8baf1731a02d0a292ae9c6e685a36e0e1679a8e0c38c27de47014966aaacfea446571ddaf7afcff7c3517e7bf57f87388720a1f226cc9ba1f670396435163a6872d39d2460adafdefb355bc5a89d51e62d427aac45e40b18d2b34587ca19753a8a0a7d704e38c190034b0c5b253bd566e20845a22e81d2d6a74071dfdfefe6fceb555f3d52a7f7d6b99e8e74a6cf4893f7374b473e28e62c9d99fc386ed220dd0ecc50274883d9f6a63e4aabdc1d6604827367dd3b3ddf233c2a8a7d577bf75736ca77c5d7d43f85db51c7cc6e33513225428e525ac0c22f6ef6c509e4ebbe4074f1fb726a8fd1e8643893e9fa38ae1eb6fd761e7fb12db8d3f20b5b26483b3fb92e6eb9fabd647870ddc39d61de48bdc39ce26eedf2f4d8da60adc13876844ddda3cc902792a8bd113980011279cddc625b9bcda8b0cc91cacaa4061d565a0b6e5daecf21ef3ce1be4d195c28ddc7337754e1d58908c4d8ffb45d0fbe936b83beb9851b88e57026c80e3e6d7b5b984785b4dd67498f86a9afcfc0548837b87ce07ef524696b68dc5a42312588dd051ea608f46dec1613c558e11d64e32c5cfd6b0e1c93691c724b257033d93dc7fffebca7f494d2b6391492985eac16d6919dcf60f1ab49e6ae216c90776b48ace0404128313220af7b6e546d1b89ab356cab83059301ae2d3a0eff524a610649c8") + val onionForEve = OutgoingPaymentPacket.buildOnion(sessionKey, NodePayload(eve, payloadEve) :: Nil, paymentHash, Some(PaymentOnionCodecs.paymentOnionPayloadLength)).toOption.get.packet + val encoded = PaymentOnionCodecs.paymentOnionPacketCodec.encode(onionForEve).require.bytes + assert(encoded == hex"000318a5a814e6e22fa5e938e671528091a27ff4883b81353560bb24d65cf9c245e880d563cfd8717f5ffae349d5ebc982b674d752a867e515855af157e325294ce929e8a5418faa5c2c7913f9440d4aba5087aee5e1499f239b392fac71a935636b3bbf6146aaa995db6e090c3216191f5c0662796277854c618bfadac769cacf34c89e05435b0f739f505f8b1020055497bdfafd88cd35d35ad2e0657aadfa8cc47647ccadae9dde5ddedd4e2b3d9c90074406449bf51e22fcae388bab417573e3f104a80913305bc1f3078e2badb8cf519b77ebd5433d5d12ccb9ff420ae6451b1368fa648213fc831b456a700fcd88a387c5cdf1816b9a88dd696ee2bc9035cf19998740813b50ae131920a2879026fbdda8caea065950ee3288e875d9001b80c0f915d8ef3666473f8a1a2233ffb349a8b5d292cef9011deada08d839ca2a2b0f4910edb68d6ae4d3ee08ef97143aee5a6afcac6036d5be5bac5591ce46dd645438340839fff8e7e7ad6ec20bca4335ef8dab6e0e1ddf861352e33bb63c6d5a3531a68abd0b185d0ebe1545cb10b0ef4461068b3e8e95a549ac9756ce44653593bb198241e4169c3f419c3f0903d36220ad849323495606366e1adb3dc7db0ac6cbe7a43f2f954534d1eb4d8f617369e7ea4f281bcac1f8ff7e13625f6518d4e255f3695528afa24cc237ba69c4452b0645b95ff6465cab30ca6afd21f1957f331df501c997f2eb17e6e3c17619dc40671d29f68ab4844926c1d513c3a1209c8da2e9b4bac4c740ae3bc6953f1afa406c6b5999a9e2d72e41073171246fd66f52397c83cb9027e59f0d940b01596f5c631e43fdb5b320142c0e595383e39d0a333535b0c6ff9b9f8d3f892797c992e582a60f974788ffcaf252c691c550c56e9d43b6ca0278da91ef8b84eaf640d4551b7cc94c1b78968d3df85798fef2382353854ebba9c11da5d541535e2aca9d4adcc60fe9b978b9b096e35e3ffb7f740fc27721dab063db98100d581259f20a79fe66629fbe92c3576dfef5f8e23724bd68689553c88065ac19af4cabd19a6e7566278d212407884cd294947973acfb872788e6f7c2372410db55b1d6d7713c639957e2161dfd35c487ec706290e85a0bd0f12ac7f20c1cbfc12e1d13049d105d421a260b1b095c161df77f00eb74fc451c1bc3fee1ba6bb5ac0236ae249f11ee5f7b21e3e41c84fc423c9692f60a2cb4ad707757104b058033e57c3b23a6ef920a2a14efd1c39171c3085cb4f7f27fe93b6b8cd374d92b615406ca2b3dfa860dcebc22fb55a2c8a92e706d84798bf4268656c8ffd10436897b2f26ad4e7389915d7c10f82d20075a02eb24af6ef6d5e823837470c0c1ccaf6ae43d64a7ce1e0427b89e571ec38ad1d718065f6896656018f5ddb6189d8f60ad7f4e9218473cd203c8341259ee6a2ae1446057a493be99132521a3ee26944a67642ba6ee9071e9b15f6d644c93bb82bc4543105a36284c459e91ec5519afcea79f5b8b9485e1cabf3b551f959bd9664b3c301ee6fe2f562ce378cf570ddf3da5c35ec0273c9fe5f6b86c54298aa77c1bee5f20b77ee97d1928fb3684939768364b28313dafb7fad9fa690a882e52ddef1e6ae6730b55a1267ff7b05a92fa4ad77b60439b4a7b549a6f22130867da882c25ff512d5949702a72477c1b0c2b4d919eb92858eb7e67cfacb0ee368e278898d4dff3b489345a314502ad852a7037a208f143f240a3315a5d432c51ae4510e343df0d111d689963b624b4628e8e0a1604704f1778084e07807496d00d94d529284f55a81ee8de5077229501e7c02e80b7f82ce3c649246672c6cab48f0407e0e09772135524204bede73e3ab870edc6c8346f152ae6667fa381bad766a3312a17ab41cb059ad20be93f01d8fd59741e8871f9c6d0b1bf8dbfd042") + onionForEve + } + + // Eve receives the payment. + val add = UpdateAddHtlc(randomBytes32(), 1, 150_000_000 msat, paymentHash, CltvExpiry(800_000), onionForEve, None, 1.0, None) + val Right(FinalPacket(_, payload)) = decrypt(add, evePriv, Features(Features.RouteBlinding -> FeatureSupport.Optional)) + assert(payload.isInstanceOf[FinalPayload.Blinded]) + assert(payload.asInstanceOf[FinalPayload.Blinded].pathId == pathId) + assert(payload.asInstanceOf[FinalPayload.Blinded].amount == 150_000_000.msat) + assert(payload.asInstanceOf[FinalPayload.Blinded].totalAmount == 150_000_000.msat) + assert(payload.asInstanceOf[FinalPayload.Blinded].expiry == CltvExpiry(800_000)) + } + + // See bolt04/trampoline-to-blinded-path-payment-onion-test.json + test("build outgoing trampoline payment to blinded paths (reference test vector)") { + val preimage = ByteVector32.fromValidHex("8bb624f63457695115152f4bf9950bbd14972a5f49d882cb1a68aa064742c057") + val paymentHash = Crypto.sha256(preimage) + assert(paymentHash == ByteVector32.fromValidHex("e89bc505e84aaca09613833fc58c9069078fb43bfbea0488f34eec9db99b5f82")) + val evePriv = PrivateKey(hex"4545454545454545454545454545454545454545454545454545454545454545") + val eve = evePriv.publicKey + assert(eve == PublicKey(hex"02edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145")) + + // Eve creates a blinded path to herself going through Dave. + val pathId = hex"0149792a42a127e421026a0c616e9490fb560d8fa5374a3d38d97aa618056a2ad70000000008f0d1808bb624f63457695115152f4bf9950bbd14972a5f49d882cb1a68aa064742c05702414343fd4a723942a86d5f60d2cfecb6c5e8a65595c9995332ec2dba8fe004a20000000000000001000000000000000068656c6c6f7bcdd1f21161675ee57f03e449abd395867d703a0fa3c1c92fe9111ad9da9fe216f8c170fc25726261af0195732366dad38384c0ab24060c7cd65c49d1de8411" + val (blindedPath, invoice) = { + val dave = PublicKey(hex"032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991") + val blindedPayloadEve = TlvStream[RouteBlindingEncryptedDataTlv](RouteBlindingEncryptedDataTlv.PathId(pathId)) + val blindedPayloadDave = TlvStream[RouteBlindingEncryptedDataTlv]( + RouteBlindingEncryptedDataTlv.OutgoingChannelId(ShortChannelId.fromCoordinates("572330x42x2465").get), + RouteBlindingEncryptedDataTlv.PaymentRelay(CltvExpiryDelta(36), 1000, 500 msat), + RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(850_000), 1 msat), + ) + assert(RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec.encode(blindedPayloadDave).require.bytes == hex"020808bbaa00002a09a1 0a080024000003e801f4 0c05000cf85001") + assert(RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec.encode(blindedPayloadEve).require.bytes == hex"06 bf 0149792a42a127e421026a0c616e9490fb560d8fa5374a3d38d97aa618056a2ad70000000008f0d1808bb624f63457695115152f4bf9950bbd14972a5f49d882cb1a68aa064742c05702414343fd4a723942a86d5f60d2cfecb6c5e8a65595c9995332ec2dba8fe004a20000000000000001000000000000000068656c6c6f7bcdd1f21161675ee57f03e449abd395867d703a0fa3c1c92fe9111ad9da9fe216f8c170fc25726261af0195732366dad38384c0ab24060c7cd65c49d1de8411") + val sessionKey = PrivateKey(hex"090a684b173ac8da6716859095a779208943cf88680c38c249d3e8831e2caf7e") + val blindedRouteDetails = Sphinx.RouteBlinding.create(sessionKey, Seq(dave, eve), Seq(blindedPayloadDave, blindedPayloadEve).map { p => + RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec.encode(p).require.bytes + }) + assert(blindedRouteDetails.route.firstNodeId == EncodedNodeId(dave)) + assert(blindedRouteDetails.lastPathKey == PublicKey(hex"02c952268f1501cf108839f4f5d0fbb41a97de778a6ead8caf161c569bd4df1ad7")) + assert(blindedRouteDetails.route.firstPathKey == PublicKey(hex"02988face71e92c345a068f740191fd8e53be14f0bb957ef730d3c5f76087b960e")) + val blindedNodes = Seq( + PublicKey(hex"0295d40514096a8be54859e7dfe947b376eaafea8afe5cb4eb2c13ff857ed0b4be"), + PublicKey(hex"020e2dbadcc2005e859819ddebbe88a834ae8a6d2b049233c07335f15cd1dc5f22"), + ) + assert(blindedNodes == blindedRouteDetails.route.blindedNodeIds) + val encryptedPayloads = Seq( + hex"0ae636dc5963bcfe2a4705538b3b6d2c5cd87dce29374d47cb64d16b3a0d95f21b1af81f31f61c01e81a86", + hex"bcd747ba974bc6ac175df8d5dbd462acb1dc4f3fa1de21da4c5774d233d8ecd9b84b7420175f9ec920f2ef261cdb83dc28cc3a0eeb970107b3306489bf771ef5b1213bca811d345285405861d08a655b6c237fa247a8b4491beee20c878a60e9816492026d8feb9dafa84585b253978db6a0aa2945df5ef445c61e801fb82f43d59347cc1c013a2351f094cdafb5e0d1f5ccb1055d6a5dd086a69cd75d34ea06067659cb7bb02dda9c2d89978dc725168f93ab2fe22dff354bce6017b60d0cc5b29b01540595e6d024f3812adda1960b4d", + ) + assert(encryptedPayloads == blindedRouteDetails.route.encryptedPayloads) + val paymentInfo = OfferTypes.PaymentInfo(500 msat, 1000, CltvExpiryDelta(36), 1 msat, 500_000_000 msat, Features.empty) + val paymentRoute = PaymentBlindedRoute(blindedRouteDetails.route, paymentInfo) + val offerFeatures = Features(Features.BasicMultiPartPayment -> FeatureSupport.Optional).bolt12Features() + val offer = OfferTypes.Offer(None, Some("bolt12"), eve, offerFeatures, Block.RegtestGenesisBlock.hash) + val alicePayerKey = PrivateKey(hex"40086168e170767e1c2587d503fea0eaa66ef21069c5858ec6e532503d6a4bd6") + val invoiceRequest = OfferTypes.InvoiceRequest(offer, 150_000_000 msat, 1, offerFeatures, alicePayerKey, Block.RegtestGenesisBlock.hash) + val invoice = Bolt12Invoice(invoiceRequest, preimage, Sphinx.RouteBlinding.derivePrivateKey(evePriv, blindedRouteDetails.lastPathKey), 60 seconds, offerFeatures, Seq(paymentRoute)) + (paymentRoute, invoice) + } + + // Alice creates a trampoline onion for Carol that includes Eve's blinded path. + val trampolineOnion = { + val carol = PublicKey(hex"027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007") + val trampolinePayload = PaymentOnion.IntermediatePayload.NodeRelay.ToBlindedPaths(150_000_000 msat, CltvExpiry(800_000), invoice) + assert(PaymentOnionCodecs.perHopPayloadCodec.encode(trampolinePayload.records).require.bytes == hex"fd01b5 020408f0d180 04030c3500 1503020000 16fd01a1032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e66868099102988face71e92c345a068f740191fd8e53be14f0bb957ef730d3c5f76087b960e020295d40514096a8be54859e7dfe947b376eaafea8afe5cb4eb2c13ff857ed0b4be002b0ae636dc5963bcfe2a4705538b3b6d2c5cd87dce29374d47cb64d16b3a0d95f21b1af81f31f61c01e81a86020e2dbadcc2005e859819ddebbe88a834ae8a6d2b049233c07335f15cd1dc5f2200d1bcd747ba974bc6ac175df8d5dbd462acb1dc4f3fa1de21da4c5774d233d8ecd9b84b7420175f9ec920f2ef261cdb83dc28cc3a0eeb970107b3306489bf771ef5b1213bca811d345285405861d08a655b6c237fa247a8b4491beee20c878a60e9816492026d8feb9dafa84585b253978db6a0aa2945df5ef445c61e801fb82f43d59347cc1c013a2351f094cdafb5e0d1f5ccb1055d6a5dd086a69cd75d34ea06067659cb7bb02dda9c2d89978dc725168f93ab2fe22dff354bce6017b60d0cc5b29b01540595e6d024f3812adda1960b4d000001f4000003e800240000000000000001000000001dcd65000000") + val sessionKey = PrivateKey(hex"a64feb81abd58e473df290e9e1c07dc3e56114495cadf33191f44ba5448ebe99") + val trampolineOnion = OutgoingPaymentPacket.buildOnion(sessionKey, NodePayload(carol, trampolinePayload) :: Nil, paymentHash, None).toOption.get.packet + val encoded = OnionRoutingCodecs.onionRoutingPacketCodec(trampolineOnion.payload.length.toInt).encode(trampolineOnion).require.bytes + assert(encoded == hex"0002bc59a9abc893d75a8d4f56a6572f9a3507323a8de22abe0496ea8d37da166a8b98b9bf5cf80f093ee323cbb0c5b0713b14779893b07e4cc60110ce2d2240f16be3fd3c23062491fb57d229dac4edbad7a3b26242cffc2a2e9d5a0eae187390d4e096699d093f5ac82d86abdf0fdaae01bf16b80261e30f6ffda635ea7662dc0d124e1137367ab0178d6ed0de8e307a5c94a213b0b5705efcc94440308f477a185f5b41ab698e4c2dd7adea3aa47cccb5f47548c9ec2fee9573d32042eee6851a4f17406b6f6d13e2b794b0bd1676d0c3b33e4ee102823bb9e55f0ec29fc7f9df3332be5f9c68d4482ff60c0183c17742844baf01821cc1a2dbed1f764d124a5696f290db7f43608ddad007da504a56d0c714a0d34eeeed848d08c846609d29123df3f82484a7ae994c37487add9c878a737bb9d6e314139329b2eed131906a5717516f7790f0ec78f3e1a6c9b9c0680221dd290e3e219146039cb02f28eec46b88d5eceae7738182d9b1be14130636943dfa95aee4cf0f81bcdb04b8f92e3c9841f9928a7b39c3c8861dd4b73bf736b1e1b0d9a22c3bf3c12cdb1580c343a129b93cbda9e58675a52cde759040718c25504ea28df3b6da73e832b5bd7b51054a5663d407871c4a90e76824eca922ccde0bdd30e81f1ce9bed788416cc9660b016adccab6a45e0ac23d11030f7076b88184c247da4586d4fa3102e44f882ae88a46cf4a4dd874a9466c31eb94c834ac6c9cfb4bb9a6ef6a6a") + trampolineOnion + } + + // Alice creates a payment onion for Carol (Alice -> Bob -> Carol). + val onionForBob = { + val sessionKey = PrivateKey(hex"4f777e8dac16e6dfe333066d9efb014f7a51d11762ff76eca4d3a95ada99ba3e") + val bob = PublicKey(hex"0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c") + val carol = PublicKey(hex"027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007") + val paymentSecret = ByteVector32.fromValidHex("7494b65bc092b48a75465e43e29be807eb2cc535ce8aaba31012b8ff1ceac5da") + val payloadBob = PaymentOnion.IntermediatePayload.ChannelRelay.Standard(ShortChannelId.fromCoordinates("572330x42x2821").get, 150_153_000 msat, CltvExpiry(800_060)) + assert(PaymentOnionCodecs.perHopPayloadCodec.encode(payloadBob.records).require.bytes == hex"15 020408f32728 04030c353c 060808bbaa00002a0b05") + val payloadCarol = PaymentOnion.FinalPayload.Standard.createTrampolinePayload(150_153_000 msat, 150_153_000 msat, CltvExpiry(800_060), paymentSecret, trampolineOnion, None) + assert(PaymentOnionCodecs.perHopPayloadCodec.encode(payloadCarol.records).require.bytes == hex"fd024f 020408f32728 04030c353c 08247494b65bc092b48a75465e43e29be807eb2cc535ce8aaba31012b8ff1ceac5da08f32728 14fd021a0002bc59a9abc893d75a8d4f56a6572f9a3507323a8de22abe0496ea8d37da166a8b98b9bf5cf80f093ee323cbb0c5b0713b14779893b07e4cc60110ce2d2240f16be3fd3c23062491fb57d229dac4edbad7a3b26242cffc2a2e9d5a0eae187390d4e096699d093f5ac82d86abdf0fdaae01bf16b80261e30f6ffda635ea7662dc0d124e1137367ab0178d6ed0de8e307a5c94a213b0b5705efcc94440308f477a185f5b41ab698e4c2dd7adea3aa47cccb5f47548c9ec2fee9573d32042eee6851a4f17406b6f6d13e2b794b0bd1676d0c3b33e4ee102823bb9e55f0ec29fc7f9df3332be5f9c68d4482ff60c0183c17742844baf01821cc1a2dbed1f764d124a5696f290db7f43608ddad007da504a56d0c714a0d34eeeed848d08c846609d29123df3f82484a7ae994c37487add9c878a737bb9d6e314139329b2eed131906a5717516f7790f0ec78f3e1a6c9b9c0680221dd290e3e219146039cb02f28eec46b88d5eceae7738182d9b1be14130636943dfa95aee4cf0f81bcdb04b8f92e3c9841f9928a7b39c3c8861dd4b73bf736b1e1b0d9a22c3bf3c12cdb1580c343a129b93cbda9e58675a52cde759040718c25504ea28df3b6da73e832b5bd7b51054a5663d407871c4a90e76824eca922ccde0bdd30e81f1ce9bed788416cc9660b016adccab6a45e0ac23d11030f7076b88184c247da4586d4fa3102e44f882ae88a46cf4a4dd874a9466c31eb94c834ac6c9cfb4bb9a6ef6a6a") + val onionForBob = OutgoingPaymentPacket.buildOnion(sessionKey, NodePayload(bob, payloadBob) :: NodePayload(carol, payloadCarol) :: Nil, paymentHash, Some(PaymentOnionCodecs.paymentOnionPayloadLength)).toOption.get.packet + val encoded = PaymentOnionCodecs.paymentOnionPacketCodec.encode(onionForBob).require.bytes + assert(encoded == hex"00025fd60556c134ae97e4baedba220a644037754ee67c54fd05e93bf40c17cbb73362fb9dee96001ff229945595b6edb59437a6bc14340622675e61ad0fd4e5e9473ea41567f4f7b0938040e2076c378a98409260c7234f87c58657bcf20af7e63d7192b46cf171e4f73cb11f9f603915389105d91ad630224bea95d735e3988add1e24b5bf28f1d7128db64284d930839ba340d088c74b1fb1bd21136b1809428ec5399c8649e9bdf92d2dcfc694deae5095f9ea212871fc9229a545ddd273635939ed304ba8a2c0a80a1a2ff7f95df532cde150bb304cd84abf88abe6e09b405d10e5e422f6d839a245fd2a300b2f6b95eedecf88479a3950727e6eeac46a34b2930aa9b0d7dd02d021d59800c3f7d5bae58eb45d03f31cce59f04715d7f7158fb2413f9ffe83b869c52019a54f6e0e194479e2eb546a6efe27cdb69863b5ff4e218e57b3e7aff727296036ed6b6756b6b98b22607b699190ced7484df2fd487fd679bf7b327322afd8c9ed658564a2d715cd86e0d270f3fad64980ef2926b82c415cdc537ff5d037b0a2986a44857ce430dfabce135748b4bd4daf3afaac064b1571fbce1369b7d7166c2638d426b6a3a418e5f017699373f614815de8275c74cd57bcfb9f3c5a11183cbb8f488bb255f7a0c3299df1306fdeeca785d81a7bcba5036a4891cd20d1b16c5436c51992d4797e124df65f2d71479739923b46d3daa3a0ecc75404c0475e5cd01665bf375e3897b3a57d2fa89ce1fa7d667ecfe0c097cfb7d94634c5a2b7c6ad5a3de7f9980a0779b66dff957389bed1e19d4681299fbe762a6ca0f9fc0726c203dc2021e74375559453ba0d2c2825142ed007cefb1e1466bb99303dbf4ceaba5eb76d18204910df11e3e3747d6d147c599edfbaf898fbd09d867558dec280e8d1c44d6e440a3c8d3b1afcfe7f3b1731a42bee7b6f7227e755bcc936952b696740f387c0ab93fd8b1d6bd4c74aebe535d6110014cd3d525394027dfe8fad19477d69dc1671d1133f5d8d21b55ddc7f3c76dabf718ca6f02da0d6445e4326b781c6d9041e9e330e44950d10d5dbed7f98b708d1681b75f8fe56c99c7a424899c6a06f36e5b29f2c3db0050bebeffee8b729351518644f98246c1db892ff3305b7610cfb09d5465f5a94da4812d35275c42f4b3a9cbfe626cee01e1818cdbe71565104e112d1c2c74450488d06de19c831d3c3e5203e67229bd9619166aab97fb579623c418b23c05fabf39e21ba0d7baf4aa67034b1ba8c4bd4542825471c7dfb37c24cdfd2d8d06d0e7ddaca01f001449195cc04201a7ae2da86e74d515e2feef3707e29508768f18eb5741ef22dc07f078cf751da83ee2fe9927c760474cdce266fce9b66959d391d51b204fa50cd9a8ff7b45fdd043679a20afa0b440938a721fef14badb97b68ad5e5494dfb2aea8edc1cdb69eb6f13b75bbd496c8eb35a48f229a080ae6744dec87f58058296c2969f0916685ac57a0a44efe4691eb06236f414334f5747a11b100e1d6272ff6082510fa79c64bcfaa58e43525f9fbbea025aa741feb7b18925e2dbd0da2a73748a6c30fe625afb497189d7f188869602989a53892ad24624807e1581eeca2db2cef855aa65af66c4573f9c637699bcbe8ae5f6d9f0713ffe52d453faa39b44be3108e940b322db0d1dc008aff99d4909345ffcf996a382359e7e5b4592522d453fffa9744e1e32a21a237fff4c8c55c1f46fdc5b2e8de267419a3052b33c6065119f690e972ac9b19921bab489a572df128494a1158650665bc875bbc02de3cac75963cee5c10075768d921edacb382044c74848af73092641a57c2050ea0e68dbb6c6121b1bf012073c8812d68fac75a06a8a35bec984c71ff951eb3ef18e96e1158f653a837a9fec2df21cdd816d36bf998ee108b562a60a6") + onionForBob + } + + // Bob decrypts the onion and relays to Carol. + val onionForCarol = { + val bobPriv = PrivateKey(hex"4242424242424242424242424242424242424242424242424242424242424242") + val add = UpdateAddHtlc(randomBytes32(), 1, 150_155_000 msat, paymentHash, CltvExpiry(800_100), onionForBob, None, 1.0, None) + val Right(packetForBob: ChannelRelayPacket) = decrypt(add, bobPriv, Features.empty) + assert(packetForBob.payload.outgoing.contains(ShortChannelId.fromCoordinates("572330x42x2821").get)) + assert(packetForBob.amountToForward == 150_153_000.msat) + assert(packetForBob.outgoingCltv == CltvExpiry(800_060)) + val encoded = PaymentOnionCodecs.paymentOnionPacketCodec.encode(packetForBob.nextPacket).require.bytes + assert(encoded == hex"0003dc6a4c9b34bdcd2191fc4dacfc1aeb20f71991acbd17847b9ab17d69579c1614da126cd18820e55534d7352839caa436aa79a6d5be26c6ecbd1c79f74442b1b7bf8ed8b739b736b9248769c2c422eebc85fb0d580e95618bcda1be3fd4cfa6ed0d839a2feb878acf686b112febaae9c1494a2ad20d97b2d2f7e54e6e9860e75e35671d5530ff9cf131b16b00a89337781aa37e5b867995d56578e69c031b7b272c4697727210e10bc8456e5cd58ae958d07e2811e2fb767b702c792b26fd6c352306b31f808a0e46d28ababda518d0d33c8f3a301adef4dd4f12fd2f78da4d548b7c12b0d890b6ab24e724e569106ae47b1acea4f5de055ba6d910bbe824810a11349ce7ea557abf02c5104740b52c910cd0bfea5d8666a41448703c054ed0612775e8617eda8df2fdccebf65193301738ba4308b61f447016a0b801de0eff2a7db374e6ccdadc9efbcb2fe0fb56c34fbaffd1bf87e5bf46cca75b77cdb7161532402fbc9323af57304e0b6cfa5082af683ef82a2731e89734b9e377184c647486c63ab57e18d4f42e9ddd55189a064cfc3a2800b8abd291043aab068c8c8ce57f17ef169945bba4d434d67bc883b68ba2c2c92dad42c788a209b4d7a2e00b375b811766ff67fc630ae047b8d2781e00291f6d1e31495a797a7e4ed135585da237cdcd067d37641e49f562f22dd619240bb2411fab802f834d96aa6451fd4b3f585dbde15bc78e692f49a491dcd8e44a8ef035bcbb4863462d7cf6066e0df516dcb6209674abe54e7d2faca26d17019bc2b6ec59ec94b51fd62064e7ff2230e73c375fadf7f305c307870a1d3dcb4eecddce6eeca54bff76b945823364ca823f7f3dc273c5eaa6d7aa3b510bf3bd274c8bd73570d15bc1ff0ba90c3bb65a8e1ee89cc6c44114d658fb89985c7ca8e8eeb32bc8be1e3ab951ff1a720bcc0d4c298ae4c1d06c164615c8af5bbda93f431d5d2be8bc40320c9c3a002bd9f2e39828abb6e7bfde83421d7faed6b16f355b9bd86d018fb3ec0f98ffdcaae8d521bd5003e93382459fc7957e2590409e5c8a88d7c1488884da0e148b01ec99aafe96d418d7cd76d7437d3c1d9d79e79386e3286210fac073eac6cd90031ac1c5b70b494d60e74d243ee44bfb8d0fcc57d3f8683aaadc5a2d346fce681a8d4a4931e932a39e2ab443141eb5c29a475679c5ee4e8b94e9d5de731f03963acaddd7301be90c7ccdfab314f70e843037a98656c31b22c822312719434f7a503bac9f18eb2f0cbc2c2790e93fc1664b82726eb1265a4ffa1e8e72d2898df1d8db9da1586675d242ff565aa008a35aad1c65b50c07ae6c0452bcdaa2f5410600acb3326e335971eba42c1dbac36005b5299ab7b852812717048aa51f272e8ec21c11e22a25b48ef60ed98540d879f5ae6820ac94cfa29e5d0aa74d91ca30ee28e97cd94968b4f246b3f93f36ecbf1c84f12844867f0738c3c775981a827cb05ddc5bebd288b6312b0b3f7d46f6eb4ddaf91e7c6a3afdbc291ac5a151675f3c4ae23ab301a9c3f5e1ba62aef64dc50cd977a34ffe58a78feda76c27cc3d5a3a1e05303e9cdd72d60ed17cc90c88b015f3c4891651537b52d837ef0d5f9a90b01e05a9339a623034aea961f7bdc148f129f61f7e12d4ebd1ed37565935cdaec4ea6b7020e62d5db3bad4a3b1141ec3c78d679498bbb348091f56279a3c01662db7694ba54efd8d8f1271f4b06cba94804c3197f92ea97e93bdef8fcb348a405792855e84c1c9625153187495825c5a293e1efc7b672ddb609aa90caca1e7182ba301313a17364b48cd93b6a26cb6888d5c4cc1d3c6a39cb442c2de12bb1ad396205c9a10023d7edc951fc60a28813867f635c3d74d4206f398849e65eb5a8d8fdeb952ae813073c3b617ed68c7bf1a18a6b9f9e3af316029be4dd8") + packetForBob.nextPacket + } + + // Carol decrypts the onion and relays to the blinded path's introduction node Dave. + val onionForDave = { + val carolPriv = PrivateKey(hex"4343434343434343434343434343434343434343434343434343434343434343") + val dave = PublicKey(hex"032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991") + val add = UpdateAddHtlc(randomBytes32(), 1, 150_153_000 msat, paymentHash, CltvExpiry(800_060), onionForCarol, None, 1.0, None) + val Right(RelayToBlindedPathsPacket(_, payload, trampolinePayload)) = decrypt(add, carolPriv, Features(Features.RouteBlinding -> FeatureSupport.Optional)) + assert(payload.amount == 150_153_000.msat) + assert(payload.expiry == CltvExpiry(800_060)) + assert(payload.paymentSecret == ByteVector32.fromValidHex("7494b65bc092b48a75465e43e29be807eb2cc535ce8aaba31012b8ff1ceac5da")) + assert(trampolinePayload.invoiceFeatures == Features(Features.BasicMultiPartPayment -> FeatureSupport.Optional).toByteVector) + assert(trampolinePayload.outgoingBlindedPaths.length == 1) + assert(trampolinePayload.amountToForward == 150_000_000.msat) + assert(trampolinePayload.outgoingCltv == CltvExpiry(800_000)) + val outgoingPath = trampolinePayload.outgoingBlindedPaths.head + assert(outgoingPath.paymentInfo == blindedPath.paymentInfo) + assert(outgoingPath.route == blindedPath.route) + val sessionKey = PrivateKey(hex"e4acea94d5ddce1a557229bc39f8953ec1398171f9c2c6bb97d20152933be4c4") + val payloadDave = PaymentOnion.OutgoingBlindedPerHopPayload.createIntroductionPayload(outgoingPath.route.encryptedPayloads.head, outgoingPath.route.firstPathKey) + assert(PaymentOnionCodecs.perHopPayloadCodec.encode(payloadDave.records).require.bytes == hex"50 0a2b0ae636dc5963bcfe2a4705538b3b6d2c5cd87dce29374d47cb64d16b3a0d95f21b1af81f31f61c01e81a86 0c2102988face71e92c345a068f740191fd8e53be14f0bb957ef730d3c5f76087b960e") + val payloadEve = PaymentOnion.OutgoingBlindedPerHopPayload.createFinalPayload(trampolinePayload.amountToForward, trampolinePayload.amountToForward, trampolinePayload.outgoingCltv, outgoingPath.route.encryptedPayloads.last) + assert(PaymentOnionCodecs.perHopPayloadCodec.encode(payloadEve.records).require.bytes == hex"e4 020408f0d180 04030c3500 0ad1bcd747ba974bc6ac175df8d5dbd462acb1dc4f3fa1de21da4c5774d233d8ecd9b84b7420175f9ec920f2ef261cdb83dc28cc3a0eeb970107b3306489bf771ef5b1213bca811d345285405861d08a655b6c237fa247a8b4491beee20c878a60e9816492026d8feb9dafa84585b253978db6a0aa2945df5ef445c61e801fb82f43d59347cc1c013a2351f094cdafb5e0d1f5ccb1055d6a5dd086a69cd75d34ea06067659cb7bb02dda9c2d89978dc725168f93ab2fe22dff354bce6017b60d0cc5b29b01540595e6d024f3812adda1960b4d 120408f0d180") + val onionForDave = OutgoingPaymentPacket.buildOnion(sessionKey, NodePayload(dave, payloadDave) :: NodePayload(outgoingPath.route.blindedNodeIds.last, payloadEve) :: Nil, paymentHash, Some(PaymentOnionCodecs.paymentOnionPayloadLength)).toOption.get.packet + val encoded = PaymentOnionCodecs.paymentOnionPacketCodec.encode(onionForDave).require.bytes + assert(encoded == hex"0003626d5fa7ed0b8706f975ca58ae2c645889514153568aaab7835325ecd3739a5760171be581c1df1ce5267cd012e06f45a97fdbc5d8fc0c140c7432d34027b9ed0f2ab09fd388fe47525309f9c4509a7b9d74d88c28e40b1b6598e42fe2975f857e28b224316f266decf170cbfb5019dea2dab2ee7f1db089d44d1f974d6974bd06e515510cc1c178cd46c2442f07b9a083b4e4c7e9dd8d728508353959497fb8d25ebe8db83c60488566952fb1725267088ccb98acace7147d3846388464aa9512fe94f1962a54d8896d94105b185a41201e0dacf51f755da8e666a78261bc478ca77bd0ef5576bd7a4b24bb1fee9a97618bac0dcc4f1a34d64f5623446e2458089299be2f07592d69619bd6048c37a0460062b194f6f05da8f4ac1c5ff19681067398fdde459c60b4f448d5b3c1152988f6e29dc73b3a5407f1a502dcf2d656bcdba5f05eb4a7ecd3a1373c495dbc23109912aa456f0d9c1460f99f8151886ec8a69af2ac3ed76823ce372fb46a3c20ff114c04ee4a16ff673382b1abedcfc5e8d6f6e77c893dc346cf01f323bb043840546f9728b060514ddc4359c3ebc818abe56c8219260e26c833ff6faef7c02a3e669026dcc0a96ca4f0f8240185422355e0a5c9529bf65e7c52b384cfbe2eeb3ba32c118cfb6961068362b7bf41b2b1580cfc85757fd294840154cb8b13df456c6b86957a33391fd78e3aec6ff2fb9dabeefc63dc4faadfc12016d9d9501381c9751c581256cc24f8d8fecec2a1efaa9579935d5f5f7d14edb64ce97ab9deacb5c9d4c111325c70493ad7921966369315a1ec09c320c3cdc7a65ce52ff8ba5e9a71326e57f8b30766e6e5c77747b07e351c3e91efdf736a31410c9e278a683dfe1ca9c35d7868f11e1e429cb7655ff126438c83f69c3db2c5257e03f7d4f95e8d49400ee36e8d7f1629ac79f0b63430b115349df21e8d286a69b41d52e52e36553b16edf4c77acfb9d4596abd5054daf076b06abb3f84ecbea3e6d324965c7667ec7f83388ee05583f53f258291a806c025e300d63c81f5a411447b3ab3ee47b2dae485b8a87129224ad16fcc043a2d1b89e5c4f35f02675efa79730f5ee07d2de9d6ab503aa329f201ad0c9040d8c3437efde15c53b9212e93e0ece4a3ee7ae99a18b3fd75e8d1ee0ce9c73bfd5c2bbe30a91a3f92169a05887069dd31edf575265425d09998e2466bdf86919cdbfbbdb55c718b046197028b4370dd850833853b969a37e31f2cce96020a1fb22959b4529ae501f44d989b3f7473aaa787899ba200468b070079e2b9a3cd6b04b3caf2de5956aed477e4b3a9f0c93ac3f1042d16ee6a36744460e6d86144522215eeed052daebc7861d7189abe78edb67dd7ae47224b9bdb5907fdca6e6573dfe4bcf24ce1c6a4dfbae6991a8ac6976d9ec8a81f08dbdc34bf1cb18d93aa2e9d876335e0fce0d7a7b6c7080a70b1fc9bef912e4550931005210da7c46c76cf63fb02202df35d332e9ad779ef5ee086fd9fa993852be315691cf84c7e588ec61726b9fe5200ad30b2d43b1684f1dcd8df3f1598ae3841125eadfd534b074d560fd8e0eb9660c93a478ccfdd2308f587a45d5b933af280d39a77e19cd72c170931e4c8e44028bb6db77ec1e9b77af225e39db67bfb80afc6a0efe9864a80222fbfd6c4b6ad9afd43c76f2b9fc0cd0a4b07939147b005be7e6418295830a9bb114cc2c40bdb715077ef4219643455f2675ba00c0e6464f612c32cffa39f49d80ff91cf1363e109101e368114537fbd94428b6ed1934f6d8cd3b6cf2ec736ddcacf63007481fcd6dd9fca8ee39d9a4bbdd06349a5e86af75d8723eeeddc6f84575516997f7db931b91007bcfd21f1b5a8dc69ee846492493054b012e5a4ff3707d5aae44a4ca65210eb1c14c8d138441170f2e5e2920c1e4") + onionForDave + } + + // Dave decrypts the onion and relays to Eve. + val onionForEve = { + val davePriv = PrivateKey(hex"4444444444444444444444444444444444444444444444444444444444444444") + val add = UpdateAddHtlc(randomBytes32(), 1, 150_150_500 msat, paymentHash, CltvExpiry(800_036), onionForDave, None, 1.0, None) + val Right(packetForDave: ChannelRelayPacket) = decrypt(add, davePriv, Features(Features.RouteBlinding -> FeatureSupport.Optional)) + assert(packetForDave.payload.isInstanceOf[PaymentOnion.IntermediatePayload.ChannelRelay.Blinded]) + assert(packetForDave.amountToForward == 150_000_000.msat) + assert(packetForDave.outgoingCltv == CltvExpiry(800_000)) + val encoded = PaymentOnionCodecs.paymentOnionPacketCodec.encode(packetForDave.nextPacket).require.bytes + assert(encoded == hex"0002580900a2090fc8b09d77f87781aa5a5964372ee968bd8488da62e04e3f1d68bda5d48cb395a094d2d60f43d8af709c5b44bba1c51f3d590c462829104a18ec68d5c36989a3d6af086f2f61e791e619fd62bf6fccdcdb1dc01bb1798bff5550d385a3ea26ce909d6d218eb12cfa089d11d33a1cb1299510a4c5ac1f767ee18230960b2a37994dc05378ca9d6ce8c29c61dc543f11b676f1ffd3c0c0fe7d43168ecaa1760b115d397b4886c17daaf8dabaac2c5ce3e57f7b5441130e828c5368eb605c841045d84d137197512d9a6efa3bb8fb05a70af7b14f5d01518a61932717ebd04e5022e6925767f07f33b63894bbaff907967999001d6b4cb4b3bc42a9057b8b1d269172f638275688915ae9c07d276fa8aaf037a59069c3d2121a79e8eb869ade6b1dc5073922145d7e1246baa202544d5045fca6fb2974dabc145257d32f0ab5afdbb121b9d93dc1b3d345038714d70ed941be5dec56d4c5cb0582ebcb2d4a78356f75bf1696f82deecec4a97a23746b440082e07ee7d5ebac4c098a48fe0d64de53b303b960b52aabe8df029b9cb5b6079ecac2a2841dd662a2099e1c5995176b9dbc90054b789a07cfbd35e93c0da58eefa7150f7c793b37f4934e2541d6002be6953a0dfda018a881b4d7458d04d4ece3d6d570f1ac46e2eb7ad29652adae7f56ca804d88a1a92ecc17bad4ab7879e93aa56782c46f8b0fca6068a5c3593cddcd372db066bbd7ea615a0fc8b01b61849930959d3ec7951d619b93fc9feecd07c91ec6206a8a489023a55349c1d0b6c294104190090c2c82f1e00c1cf4c9349b09544157aedfed527fa5725408d38d8026916f6baac3218ab5469e157bc91475a5117947efab4af7a64373694cc62b08e0b8bfe1a35ba2f80fac95e043a17e850590bbfbb59d4c190a4fa1642d790e3403a34522f33de66e839c3e3706f9bea9d95efca2f9c7b012bfc39cb9f3e7dad4a1c7b52a8d02151a1b3524a64033d2868e9c450d496f66d71c414870c15911dee4365f1aab8a20b3968a67d04dd724955b0396a00dbdbaa0c0037a2bb8202061f6cc653a10e6ed8ce98a5b1315d5efa96603e989ce1cf315cf2e300f12696c96e45efd397776f8a781d12b8d4e3f265e49c8932cf54525af20977ab1c5b5e0a4f929074baf6b0d4fde175d02a78e0fccd4e814c0a2139475ea16517c33389a41160014f537c43c818f70ba1b9503987885f634f93b995c04f7302d1ff85add09232a2d2be27fbbf98d754c6a0e2c32f66b2a2cd6d5feef4ad10b62303ce05049e862e96987defc569cf6406585fedcc4bf4981ad67cb6af242e25f9bf701e5236deb61305bd0c20c2bfa0d17d6519979f3085427dcad1677959fa40565e16f2feee4b4974de401123f4b3e0f0e740305cdecc8f4b65d638cdd5b1af0013d5806c9d6661b96954463adee45cbacf33c16e836d8e544cab9eb47f9f661d415772a9dae0d4c3ffb44015bc6921e05e6bd8c5159893fd7e5291f6e40c84db19266a35c666afb1ec16d8c4bc507b887df09a2c71a599dbcdf75ced11eb8cd9c65f05a14a3a381971e615bdece5946affe0dbdbbb54a777e5d996e9cb9a5163bc503b88b15b31cd0fa3a8206701aa9f4068e6baac2b2f342e02f94ed22f43f285a6790ff1e216c917af77b5af726e403ce8615959b31e6d051c0a17f737ffef28264ec31c3f0f690f0f142c0b16c88507a44714516fdaee00b697288fdfea823a30bf11fa6cf3ae2215eb42b98aae1e80444c6f2688a5f8f80f1236fb3d12584f33bdfc33beb8c5b7bfdfeb94e25ed4c1fdf69f4a28f6cbb7fa0fb9424927e195908d0a8894555d02f285962a53a984fca3f6b3fb843e4d559e5294c2e01dd1dce5692664881c4dec168d52e42981c6d72f0a84caa78ebf409cb62584ec539f89147c1") + packetForDave.nextPacket + } + + // Eve receives the payment. + val pathKey = PublicKey(hex"02c952268f1501cf108839f4f5d0fbb41a97de778a6ead8caf161c569bd4df1ad7") + val add = UpdateAddHtlc(randomBytes32(), 1, 150_000_000 msat, paymentHash, CltvExpiry(800_000), onionForEve, Some(pathKey), 1.0, None) + val Right(FinalPacket(_, payload)) = decrypt(add, evePriv, Features(Features.RouteBlinding -> FeatureSupport.Optional)) + assert(payload.isInstanceOf[FinalPayload.Blinded]) + assert(payload.asInstanceOf[FinalPayload.Blinded].pathId == pathId) + assert(payload.asInstanceOf[FinalPayload.Blinded].amount == 150_000_000.msat) + assert(payload.asInstanceOf[FinalPayload.Blinded].totalAmount == 150_000_000.msat) + assert(payload.asInstanceOf[FinalPayload.Blinded].expiry == CltvExpiry(800_000)) + } + test("build outgoing trampoline payment with non-trampoline recipient") { // simple trampoline route to e where e doesn't support trampoline: // .----. diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/PaymentOnionSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/PaymentOnionSpec.scala index 319d29b80d..2e27e1e1ef 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/PaymentOnionSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/PaymentOnionSpec.scala @@ -154,6 +154,26 @@ class PaymentOnionSpec extends AnyFunSuite { } } + test("encode/decode node relay blinded per-hop payload") { + val nextNodeId = PublicKey(hex"0221cd519eba9c8b840a5e40b65dc2c040e159a766979723ed770efceb97260ec8") + val blindedTlvs = TlvStream[RouteBlindingEncryptedDataTlv]( + RouteBlindingEncryptedDataTlv.OutgoingNodeId(nextNodeId), + RouteBlindingEncryptedDataTlv.PaymentRelay(CltvExpiryDelta(144), 100, 10 msat), + RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(1500), 1 msat), + ) + val testCases = Seq( + TlvStream[OnionPaymentPayloadTlv](EncryptedRecipientData(hex"deadbeef")), + TlvStream[OnionPaymentPayloadTlv](EncryptedRecipientData(hex"deadbeef"), PathKey(randomKey().publicKey)), + ) + testCases.foreach(tlvs => { + val Right(payload) = IntermediatePayload.NodeRelay.Blinded.validate(tlvs, blindedTlvs, randomKey().publicKey) + assert(payload.outgoing == Left(nextNodeId)) + assert(payload.outgoingAmount(10_000 msat) == 9990.msat) + assert(payload.outgoingExpiry(CltvExpiry(1000)) == CltvExpiry(856)) + assert(payload.paymentRelayData.allowedFeatures.isEmpty) + }) + } + test("encode/decode node relay to legacy per-hop payload") { val nodeId = PublicKey(hex"02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619") val features = hex"0a" @@ -346,6 +366,43 @@ class PaymentOnionSpec extends AnyFunSuite { } } + test("decode invalid node relay blinded per-hop payload") { + val outgoingNodeId = PublicKey(hex"0221cd519eba9c8b840a5e40b65dc2c040e159a766979723ed770efceb97260ec8") + val validBlindedTlvs = TlvStream[RouteBlindingEncryptedDataTlv]( + RouteBlindingEncryptedDataTlv.OutgoingNodeId(outgoingNodeId), + RouteBlindingEncryptedDataTlv.PaymentRelay(CltvExpiryDelta(144), 100, 10 msat), + RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(1500), 1 msat), + ) + + case class TestCase(err: InvalidTlvPayload, bin: ByteVector, blindedTlvs: TlvStream[RouteBlindingEncryptedDataTlv]) + + val testCases = Seq( + // Forbidden non-encrypted amount. + TestCase(ForbiddenTlv(UInt64(0)), hex"0e 02020231 0a080123456789abcdef", validBlindedTlvs), + // Forbidden non-encrypted expiry. + TestCase(ForbiddenTlv(UInt64(0)), hex"0d 04012a 0a080123456789abcdef", validBlindedTlvs), + // Forbidden outgoing channel id. + TestCase(ForbiddenTlv(UInt64(0)), hex"14 06080000000000000451 0a080123456789abcdef", validBlindedTlvs), + // Forbidden unknown tlv. + TestCase(ForbiddenTlv(UInt64(51)), hex"0e 0a080123456789abcdef 33020102", validBlindedTlvs), + // Missing encrypted data. + TestCase(MissingRequiredTlv(UInt64(10)), hex"23 0c21036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2", validBlindedTlvs), + // Missing encrypted outgoing node or outgoing channel. + TestCase(MissingRequiredTlv(UInt64(2)), hex"0a 0a080123456789abcdef", TlvStream(RouteBlindingEncryptedDataTlv.PaymentRelay(CltvExpiryDelta(144), 100, 10 msat), RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(1500), 1 msat))), + // Missing encrypted payment relay data. + TestCase(MissingRequiredTlv(UInt64(10)), hex"0a 0a080123456789abcdef", TlvStream(RouteBlindingEncryptedDataTlv.OutgoingNodeId(outgoingNodeId), RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(1500), 1 msat))), + // Missing encrypted payment constraint. + TestCase(MissingRequiredTlv(UInt64(12)), hex"0a 0a080123456789abcdef", TlvStream(RouteBlindingEncryptedDataTlv.OutgoingNodeId(outgoingNodeId), RouteBlindingEncryptedDataTlv.PaymentRelay(CltvExpiryDelta(144), 100, 10 msat))), + // Forbidden encrypted path id. + TestCase(ForbiddenTlv(UInt64(6)), hex"0a 0a080123456789abcdef", TlvStream(RouteBlindingEncryptedDataTlv.OutgoingNodeId(outgoingNodeId), RouteBlindingEncryptedDataTlv.PaymentRelay(CltvExpiryDelta(144), 100, 10 msat), RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(1500), 1 msat), RouteBlindingEncryptedDataTlv.PathId(hex"deadbeef"))), + ) + + for (testCase <- testCases) { + val decoded = perHopPayloadCodec.decode(testCase.bin.bits).require.value + assert(IntermediatePayload.NodeRelay.Blinded.validate(decoded, testCase.blindedTlvs, randomKey().publicKey) == Left(testCase.err)) + } + } + test("decode invalid final per-hop payload") { val testCases = Seq( (MissingRequiredTlv(UInt64(2)), hex"25 04012a 0820eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619"), // missing amount