From 1b48f85faea44094e66cd3700c3327baad212b5f Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 29 Nov 2024 12:12:43 +0100 Subject: [PATCH 1/7] Fix offer description documentation And remove the `currency` fields as we have no short-term plans to support currency conversion in `eclair`. --- .../eclair/wire/protocol/OfferTypes.scala | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala index c5b420a4e6..4afdbbe761 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala @@ -223,7 +223,7 @@ object OfferTypes { // Invoice request TLVs are in the range [0, 159] or [1000000000, 2999999999]. tlv.tag <= UInt64(159) || (tlv.tag >= UInt64(1000000000) && tlv.tag <= UInt64(2999999999L)) - def filterOfferFields(tlvs: TlvStream[InvoiceRequestTlv]): TlvStream[OfferTlv] = + private def filterOfferFields(tlvs: TlvStream[InvoiceRequestTlv]): TlvStream[OfferTlv] = TlvStream[OfferTlv](tlvs.records.collect { case tlv: OfferTlv => tlv }, tlvs.unknown.filter(isOfferTlv)) def filterInvoiceRequestFields(tlvs: TlvStream[InvoiceTlv]): TlvStream[InvoiceRequestTlv] = @@ -238,11 +238,7 @@ object OfferTypes { case class Offer(records: TlvStream[OfferTlv]) { val chains: Seq[BlockHash] = records.get[OfferChains].map(_.chains).getOrElse(Seq(Block.LivenetGenesisBlock.hash)) val metadata: Option[ByteVector] = records.get[OfferMetadata].map(_.data) - val currency: Option[String] = records.get[OfferCurrency].map(_.iso4217) - val amount: Option[MilliSatoshi] = currency match { - case Some(_) => None // TODO: add exchange rates - case None => records.get[OfferAmount].map(_.amount) - } + val amount: Option[MilliSatoshi] = records.get[OfferAmount].map(_.amount) val description: Option[String] = records.get[OfferDescription].map(_.description) val features: Features[Bolt12Feature] = records.get[OfferFeatures].map(_.features.bolt12Features()).getOrElse(Features.empty) val expiry: Option[TimestampSecond] = records.get[OfferAbsoluteExpiry].map(_.absoluteExpiry) @@ -267,11 +263,11 @@ object OfferTypes { val hrp = "lno" /** - * @param amount_opt amount if it can be determined at offer creation time. - * @param description description of the offer. - * @param nodeId the nodeId to use for this offer, which should be different from our public nodeId if we're hiding behind a blinded route. - * @param features invoice features. - * @param chain chain on which the offer is valid. + * @param amount_opt amount if it can be determined at offer creation time. + * @param description_opt description of the offer (optional if the offer doesn't include an amount). + * @param nodeId the nodeId to use for this offer, which should be different from our public nodeId if we're hiding behind a blinded route. + * @param features invoice features. + * @param chain chain on which the offer is valid. */ def apply(amount_opt: Option[MilliSatoshi], description_opt: Option[String], From 7bdac7ce8db2c82f33ac63b4469ec259fc2adcd5 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 29 Nov 2024 13:58:17 +0100 Subject: [PATCH 2/7] Relax `payment_constraints` requirement in final blinded payload We don't always need to include a `payment_constraints` field for ourselves: it's fine to accept payment that don't contain one as long as we created the `encrypted_recipient_data`, which we can verify using the `path_id`. We were too restrictive for no good reason. --- .../main/scala/fr/acinq/eclair/payment/PaymentPacket.scala | 4 ++-- .../scala/fr/acinq/eclair/wire/protocol/PaymentOnion.scala | 2 +- .../scala/fr/acinq/eclair/wire/protocol/RouteBlinding.scala | 1 - .../src/test/scala/fr/acinq/eclair/crypto/SphinxSpec.scala | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) 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 bcc1142294..92204c69f2 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 @@ -213,8 +213,8 @@ object IncomingPaymentPacket { private def validateBlindedFinalPayload(add: UpdateAddHtlc, payload: TlvStream[OnionPaymentPayloadTlv], blindedPayload: TlvStream[RouteBlindingEncryptedDataTlv]): Either[FailureMessage, FinalPacket] = { FinalPayload.Blinded.validate(payload, blindedPayload).left.map(_.failureMessage).flatMap { - case payload if add.amountMsat < payload.paymentConstraints.minAmount => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) - case payload if add.cltvExpiry > payload.paymentConstraints.maxCltvExpiry => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) + case payload if payload.paymentConstraints_opt.exists(c => add.amountMsat < c.minAmount) => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) + case payload if payload.paymentConstraints_opt.exists(c => c.maxCltvExpiry < add.cltvExpiry) => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) case payload if !Features.areCompatible(Features.empty, payload.allowedFeatures) => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) case payload if add.amountMsat < payload.amount => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) case payload if add.cltvExpiry < payload.expiry => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) 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 1b2925b349..1c684d2f2b 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 @@ -453,7 +453,7 @@ object PaymentOnion { override val expiry = records.get[OutgoingCltv].get.cltv val pathKey_opt: Option[PublicKey] = records.get[PathKey].map(_.publicKey) val pathId = blindedRecords.get[RouteBlindingEncryptedDataTlv.PathId].get.data - val paymentConstraints = blindedRecords.get[RouteBlindingEncryptedDataTlv.PaymentConstraints].get + val paymentConstraints_opt = blindedRecords.get[RouteBlindingEncryptedDataTlv.PaymentConstraints] val allowedFeatures = blindedRecords.get[RouteBlindingEncryptedDataTlv.AllowedFeatures].map(_.features).getOrElse(Features.empty) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RouteBlinding.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RouteBlinding.scala index c01bd7b454..460353f234 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RouteBlinding.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RouteBlinding.scala @@ -126,7 +126,6 @@ object BlindedRouteData { def validPaymentRecipientData(records: TlvStream[RouteBlindingEncryptedDataTlv]): Either[InvalidTlvPayload, TlvStream[RouteBlindingEncryptedDataTlv]] = { if (records.get[PathId].isEmpty) return Left(MissingRequiredTlv(UInt64(6))) - if (records.get[PaymentConstraints].isEmpty) return Left(MissingRequiredTlv(UInt64(12))) Right(records) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/SphinxSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/SphinxSpec.scala index a76b67b72b..4a051cdf1e 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/SphinxSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/SphinxSpec.scala @@ -561,7 +561,7 @@ class SphinxSpec extends AnyFunSuite { val Right(decryptedPayloadEve) = RouteBlindingEncryptedDataCodecs.decode(eve, pathKeyForEve, tlvsEve.get[OnionPaymentPayloadTlv.EncryptedRecipientData].get.data) val Right(payloadEve) = PaymentOnion.FinalPayload.Blinded.validate(tlvsEve, decryptedPayloadEve.tlvs) assert(payloadEve.pathId == hex"c9cf92f45ade68345bc20ae672e2012f4af487ed4415") - assert(payloadEve.paymentConstraints == RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(750000), 50 msat)) + assert(payloadEve.paymentConstraints_opt.contains(RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(750000), 50 msat))) assert(payloadEve.allowedFeatures.isEmpty) assert(Seq(onionPayloadAlice, onionPayloadBob, onionPayloadCarol, onionPayloadDave, onionPayloadEve) == payloads) From 0540b1f7ace4b26ab2c9fab7ad5a5ee4cd28126f Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 29 Nov 2024 14:02:28 +0100 Subject: [PATCH 3/7] Allow omitting `total_amount` in blinded payments If the `total_amount` field isn't provided, we can safely default to using the `amount`, which saves space in the onion. Note that we keep always encoding it in the outgoing payments we send, we're simply more permissive when receiving payments. --- .../scala/fr/acinq/eclair/wire/protocol/PaymentOnion.scala | 3 +-- .../scala/fr/acinq/eclair/wire/protocol/PaymentOnionSpec.scala | 1 - .../fr/acinq/eclair/wire/protocol/RouteBlindingSpec.scala | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) 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 1c684d2f2b..23ba853d48 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 @@ -449,7 +449,7 @@ object PaymentOnion { */ case class Blinded(records: TlvStream[OnionPaymentPayloadTlv], blindedRecords: TlvStream[RouteBlindingEncryptedDataTlv]) extends FinalPayload { override val amount = records.get[AmountToForward].get.amount - override val totalAmount = records.get[TotalAmount].get.totalAmount + override val totalAmount = records.get[TotalAmount].map(_.totalAmount).getOrElse(amount) override val expiry = records.get[OutgoingCltv].get.cltv val pathKey_opt: Option[PublicKey] = records.get[PathKey].map(_.publicKey) val pathId = blindedRecords.get[RouteBlindingEncryptedDataTlv.PathId].get.data @@ -462,7 +462,6 @@ object PaymentOnion { if (records.get[AmountToForward].isEmpty) return Left(MissingRequiredTlv(UInt64(2))) if (records.get[OutgoingCltv].isEmpty) return Left(MissingRequiredTlv(UInt64(4))) if (records.get[EncryptedRecipientData].isEmpty) return Left(MissingRequiredTlv(UInt64(10))) - if (records.get[TotalAmount].isEmpty) return Left(MissingRequiredTlv(UInt64(18))) // Bolt 4: MUST return an error if the payload contains other tlv fields than `encrypted_recipient_data`, `current_path_key`, `amt_to_forward`, `outgoing_cltv_value` and `total_amount_msat`. if (records.unknown.nonEmpty) return Left(ForbiddenTlv(records.unknown.head.tag)) records.records.find { 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 3444c54eae..c0dc9bf499 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 @@ -353,7 +353,6 @@ class PaymentOnionSpec extends AnyFunSuite { (MissingRequiredTlv(UInt64(2)), hex"11 04012a 0a080123456789abcdef 12020451"), // missing amount (MissingRequiredTlv(UInt64(4)), hex"12 02020231 0a080123456789abcdef 12020451"), // missing expiry (MissingRequiredTlv(UInt64(10)), hex"0b 02020231 04012a 12020451"), // missing encrypted data - (MissingRequiredTlv(UInt64(18)), hex"11 02020231 04012a 0a080123456789abcdef"), // missing total amount (ForbiddenTlv(UInt64(0)), hex"1f 02020231 04012a 06080000000000000451 0a080123456789abcdef 12020451"), // forbidden outgoing_channel_id (ForbiddenTlv(UInt64(0)), hex"39 02020231 04012a 0822eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f2836866190451 0a080123456789abcdef 12020451"), // forbidden payment_data (ForbiddenTlv(UInt64(0)), hex"1b 02020231 04012a 0a080123456789abcdef 1004deadbeef 12020451"), // forbidden payment_metadata diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/RouteBlindingSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/RouteBlindingSpec.scala index 304909ea37..50b41c4e77 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/RouteBlindingSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/RouteBlindingSpec.scala @@ -126,7 +126,6 @@ class RouteBlindingSpec extends AnyFunSuiteLike { val testCases = Seq( hex"0c08000b35702d0fa9d2" -> MissingRequiredTlv(UInt64(6)), // missing path id - hex"0603010203" -> MissingRequiredTlv(UInt64(12)), // missing payment constraints ) for ((bin, expected) <- testCases) { From ab897e41c76ed9c2daa7507e155e6f4f4934d3e1 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 29 Nov 2024 14:11:11 +0100 Subject: [PATCH 4/7] Refactor `decryptEncryptedRecipientData` We extract a helper method for decrypting encrypted recipient data which will be used when decrypting trampoline blinded paths. --- .../acinq/eclair/payment/PaymentPacket.scala | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) 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 92204c69f2..a23ea69a88 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 @@ -85,20 +85,25 @@ object IncomingPaymentPacket { if (add.pathKey_opt.isDefined && payload.get[OnionPaymentPayloadTlv.PathKey].isDefined) { Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) } else { - add.pathKey_opt.orElse(payload.get[OnionPaymentPayloadTlv.PathKey].map(_.publicKey)) match { - case Some(pathKey) => RouteBlindingEncryptedDataCodecs.decode(privateKey, pathKey, encryptedRecipientData) match { - case Left(_) => - // There are two possibilities in this case: - // - the path key is invalid: the sender or the previous node is buggy or malicious - // - the encrypted data is invalid: the sender, the previous node or the recipient must be buggy or malicious - Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) - case Right(decoded) => Right(DecodedEncryptedRecipientData(decoded.tlvs, decoded.nextPathKey)) - } - case None => - // The sender is trying to use route blinding, but we didn't receive the path key used to derive - // the decryption key. The sender or the previous peer is buggy or malicious. + val pathKey_opt = add.pathKey_opt.orElse(payload.get[OnionPaymentPayloadTlv.PathKey].map(_.publicKey)) + decryptEncryptedRecipientData(add, privateKey, pathKey_opt, encryptedRecipientData) + } + } + + private def decryptEncryptedRecipientData(add: UpdateAddHtlc, privateKey: PrivateKey, pathKey_opt: Option[PublicKey], encryptedRecipientData: ByteVector): Either[FailureMessage, DecodedEncryptedRecipientData] = { + pathKey_opt match { + case Some(pathKey) => RouteBlindingEncryptedDataCodecs.decode(privateKey, pathKey, encryptedRecipientData) match { + case Left(_) => + // There are two possibilities in this case: + // - the path key is invalid: the sender or the previous node is buggy or malicious + // - the encrypted data is invalid: the sender, the previous node or the recipient must be buggy or malicious Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) + case Right(decoded) => Right(DecodedEncryptedRecipientData(decoded.tlvs, decoded.nextPathKey)) } + case None => + // The sender is trying to use route blinding, but we didn't receive the path key used to derive + // the decryption key. The sender or the previous peer is buggy or malicious. + Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) } } From 7ad996c23bebb9b77543a231c5ae9838720c3080 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 29 Nov 2024 14:22:18 +0100 Subject: [PATCH 5/7] Use relay methods in `PaymentOnion.IntermediatePayload.NodeRelay` In order to support blinded trampoline payments, we won't have access to a direct `amount_to_forward` field, but will use a `payment_relay` TLV instead, which only allows calculating the outgoing amount from the incoming amount (same thing for the expiry). We refactor this to simplify the diff when introducing blinded trampoline payments. --- .../eclair/payment/relay/NodeRelay.scala | 31 +++++++++++++------ .../eclair/wire/protocol/PaymentOnion.scala | 27 ++++++++++++++-- .../wire/protocol/PaymentOnionSpec.scala | 2 +- 3 files changed, 47 insertions(+), 13 deletions(-) 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 43db330efb..ce949050ca 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 @@ -116,15 +116,21 @@ object NodeRelay { } } + private def outgoingAmount(upstream: Upstream.Hot.Trampoline, payloadOut: IntermediatePayload.NodeRelay): MilliSatoshi = payloadOut.outgoingAmount(upstream.amountIn) + + private def outgoingExpiry(upstream: Upstream.Hot.Trampoline, payloadOut: IntermediatePayload.NodeRelay): CltvExpiry = payloadOut.outgoingExpiry(upstream.expiryIn) + private def validateRelay(nodeParams: NodeParams, upstream: Upstream.Hot.Trampoline, payloadOut: IntermediatePayload.NodeRelay): Option[FailureMessage] = { - val fee = nodeFee(nodeParams.relayParams.minTrampolineFees, payloadOut.amountToForward) - if (upstream.amountIn - payloadOut.amountToForward < fee) { + val amountOut = outgoingAmount(upstream, payloadOut) + val expiryOut = outgoingExpiry(upstream, payloadOut) + val fee = nodeFee(nodeParams.relayParams.minTrampolineFees, amountOut) + if (upstream.amountIn - amountOut < fee) { Some(TrampolineFeeInsufficient()) - } else if (upstream.expiryIn - payloadOut.outgoingCltv < nodeParams.channelConf.expiryDelta) { + } else if (upstream.expiryIn - expiryOut < nodeParams.channelConf.expiryDelta) { Some(TrampolineExpiryTooSoon()) - } else if (payloadOut.outgoingCltv <= CltvExpiry(nodeParams.currentBlockHeight)) { + } else if (expiryOut <= CltvExpiry(nodeParams.currentBlockHeight)) { Some(TrampolineExpiryTooSoon()) - } else if (payloadOut.amountToForward <= MilliSatoshi(0)) { + } else if (amountOut <= MilliSatoshi(0)) { Some(InvalidOnionPayload(UInt64(2), 0)) } else { None @@ -174,8 +180,9 @@ object NodeRelay { * should return upstream. */ private def translateError(nodeParams: NodeParams, failures: Seq[PaymentFailure], upstream: Upstream.Hot.Trampoline, nextPayload: IntermediatePayload.NodeRelay): Option[FailureMessage] = { + val amountOut = outgoingAmount(upstream, nextPayload) val routeNotFound = failures.collectFirst { case f@LocalFailure(_, _, RouteNotFound) => f }.nonEmpty - val routingFeeHigh = upstream.amountIn - nextPayload.amountToForward >= nodeFee(nodeParams.relayParams.minTrampolineFees, nextPayload.amountToForward) * 5 + val routingFeeHigh = upstream.amountIn - amountOut >= nodeFee(nodeParams.relayParams.minTrampolineFees, amountOut) * 5 failures match { case Nil => None case LocalFailure(_, _, BalanceTooLow) :: Nil if routingFeeHigh => @@ -320,12 +327,14 @@ class NodeRelay private(nodeParams: NodeParams, /** Relay the payment to the next identified node: this is similar to sending an outgoing payment. */ private def relay(upstream: Upstream.Hot.Trampoline, recipient: Recipient, walletNodeId_opt: Option[PublicKey], recipientFeatures_opt: Option[Features[InitFeature]], payloadOut: IntermediatePayload.NodeRelay, packetOut_opt: Option[OnionRoutingPacket]): Behavior[Command] = { - context.log.debug("relaying trampoline payment (amountIn={} expiryIn={} amountOut={} expiryOut={} isWallet={})", upstream.amountIn, upstream.expiryIn, payloadOut.amountToForward, payloadOut.outgoingCltv, walletNodeId_opt.isDefined) + val amountOut = outgoingAmount(upstream, payloadOut) + val expiryOut = outgoingExpiry(upstream, payloadOut) + context.log.debug("relaying trampoline payment (amountIn={} expiryIn={} amountOut={} expiryOut={} isWallet={})", upstream.amountIn, upstream.expiryIn, amountOut, expiryOut, walletNodeId_opt.isDefined) val confidence = (upstream.received.map(_.add.endorsement).min + 0.5) / 8 // We only make one try when it's a direct payment to a wallet. val maxPaymentAttempts = if (walletNodeId_opt.isDefined) 1 else nodeParams.maxPaymentAttempts val paymentCfg = SendPaymentConfig(relayId, relayId, None, paymentHash, recipient.nodeId, upstream, None, None, storeInDb = false, publishEvent = false, recordPathFindingMetrics = true, confidence) - val routeParams = computeRouteParams(nodeParams, upstream.amountIn, upstream.expiryIn, payloadOut.amountToForward, payloadOut.outgoingCltv) + val routeParams = computeRouteParams(nodeParams, upstream.amountIn, upstream.expiryIn, amountOut, expiryOut) // If the next node is using trampoline, we assume that they support MPP. val useMultiPart = recipient.features.hasFeature(Features.BasicMultiPartPayment) || packetOut_opt.nonEmpty val payFsmAdapters = { @@ -393,6 +402,8 @@ class NodeRelay private(nodeParams: NodeParams, /** We couldn't forward the payment, but the next node may accept on-the-fly funding. */ private def attemptOnTheFlyFunding(upstream: Upstream.Hot.Trampoline, walletNodeId: PublicKey, recipient: Recipient, nextPayload: IntermediatePayload.NodeRelay, failures: Seq[PaymentFailure], startedAt: TimestampMilli): Behavior[Command] = { + val amountOut = outgoingAmount(upstream, nextPayload) + val expiryOut = outgoingExpiry(upstream, nextPayload) // We create a payment onion, using a dummy channel hop between our node and the wallet node. val dummyEdge = Invoice.ExtraEdge(nodeParams.nodeId, walletNodeId, Alias(0), 0 msat, 0, CltvExpiryDelta(0), 1 msat, None) val dummyHop = ChannelHop(Alias(0), nodeParams.nodeId, walletNodeId, HopRelayParams.FromHint(dummyEdge)) @@ -401,7 +412,7 @@ class NodeRelay private(nodeParams: NodeParams, case _: SpontaneousRecipient => None case r: BlindedRecipient => r.blindedHops.headOption } - val dummyRoute = Route(nextPayload.amountToForward, Seq(dummyHop), finalHop_opt) + val dummyRoute = Route(amountOut, Seq(dummyHop), finalHop_opt) OutgoingPaymentPacket.buildOutgoingPayment(Origin.Hot(ActorRef.noSender, upstream), paymentHash, dummyRoute, recipient, 1.0) match { case Left(f) => context.log.warn("could not create payment onion for on-the-fly funding: {}", f.getMessage) @@ -411,7 +422,7 @@ class NodeRelay private(nodeParams: NodeParams, case Right(nextPacket) => val forwardNodeIdFailureAdapter = context.messageAdapter[Register.ForwardNodeIdFailure[Peer.ProposeOnTheFlyFunding]](_ => WrappedOnTheFlyFundingResponse(Peer.ProposeOnTheFlyFundingResponse.NotAvailable("peer not found"))) val onTheFlyFundingResponseAdapter = context.messageAdapter[Peer.ProposeOnTheFlyFundingResponse](WrappedOnTheFlyFundingResponse) - val cmd = Peer.ProposeOnTheFlyFunding(onTheFlyFundingResponseAdapter, nextPayload.amountToForward, paymentHash, nextPayload.outgoingCltv, nextPacket.cmd.onion, nextPacket.cmd.nextPathKey_opt, upstream) + val cmd = Peer.ProposeOnTheFlyFunding(onTheFlyFundingResponseAdapter, amountOut, paymentHash, expiryOut, nextPacket.cmd.onion, nextPacket.cmd.nextPathKey_opt, upstream) register ! Register.ForwardNodeId(forwardNodeIdFailureAdapter, walletNodeId, cmd) Behaviors.receiveMessagePartial { rejectExtraHtlcPartialFunction orElse { 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 23ba853d48..f028ed9fa8 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 @@ -289,14 +289,23 @@ object PaymentOnion { } sealed trait NodeRelay extends IntermediatePayload { - val amountToForward = records.get[AmountToForward].get.amount - val outgoingCltv = records.get[OutgoingCltv].get.cltv + // @formatter:off + def outgoingAmount(incomingAmount: MilliSatoshi): MilliSatoshi + def outgoingExpiry(incomingCltv: CltvExpiry): CltvExpiry + // @formatter:on } object NodeRelay { case class Standard(records: TlvStream[OnionPaymentPayloadTlv]) extends NodeRelay { + val amountToForward = records.get[AmountToForward].get.amount + val outgoingCltv = records.get[OutgoingCltv].get.cltv val outgoingNodeId = records.get[OutgoingNodeId].get.nodeId val isAsyncPayment: Boolean = records.get[AsyncPayment].isDefined + + // @formatter:off + override def outgoingAmount(incomingAmount: MilliSatoshi): MilliSatoshi = amountToForward + override def outgoingExpiry(incomingCltv: CltvExpiry): CltvExpiry = outgoingCltv + // @formatter:on } object Standard { @@ -321,6 +330,8 @@ object PaymentOnion { /** 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 + val outgoingCltv = records.get[OutgoingCltv].get.cltv val outgoingNodeId = records.get[OutgoingNodeId].get.nodeId val totalAmount = records.get[PaymentData].map(_.totalAmount match { case MilliSatoshi(0) => amountToForward @@ -330,6 +341,11 @@ object PaymentOnion { val paymentMetadata = records.get[PaymentMetadata].map(_.data) val invoiceFeatures = records.get[InvoiceFeatures].map(_.features).getOrElse(ByteVector.empty) val invoiceRoutingInfo = records.get[InvoiceRoutingInfo].map(_.extraHops).get + + // @formatter:off + override def outgoingAmount(incomingAmount: MilliSatoshi): MilliSatoshi = amountToForward + override def outgoingExpiry(incomingCltv: CltvExpiry): CltvExpiry = outgoingCltv + // @formatter:on } object ToNonTrampoline { @@ -360,8 +376,15 @@ object PaymentOnion { /** We relay to a payment recipient that doesn't support trampoline, but hides its identity using blinded paths. */ case class ToBlindedPaths(records: TlvStream[OnionPaymentPayloadTlv]) extends NodeRelay { + val amountToForward = records.get[AmountToForward].get.amount + val outgoingCltv = records.get[OutgoingCltv].get.cltv val outgoingBlindedPaths = records.get[OutgoingBlindedPaths].get.paths val invoiceFeatures = records.get[InvoiceFeatures].get.features + + // @formatter:off + override def outgoingAmount(incomingAmount: MilliSatoshi): MilliSatoshi = amountToForward + override def outgoingExpiry(incomingCltv: CltvExpiry): CltvExpiry = outgoingCltv + // @formatter:on } object ToBlindedPaths { 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 c0dc9bf499..8e98bdcff1 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 @@ -307,7 +307,7 @@ class PaymentOnionSpec extends AnyFunSuite { // Missing encrypted payment relay data. TestCase(MissingRequiredTlv(UInt64(10)), hex"0a 0a080123456789abcdef", TlvStream(RouteBlindingEncryptedDataTlv.OutgoingChannelId(ShortChannelId(42)), RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(1500), 1 msat))), // Missing encrypted payment constraint. - TestCase(MissingRequiredTlv(UInt64(12)), hex"0a 0a080123456789abcdef", TlvStream(RouteBlindingEncryptedDataTlv.OutgoingChannelId(ShortChannelId(42)), RouteBlindingEncryptedDataTlv.PaymentRelay(CltvExpiryDelta(144), 100, 10 msat))), // Forbidden encrypted path id. + TestCase(MissingRequiredTlv(UInt64(12)), hex"0a 0a080123456789abcdef", TlvStream(RouteBlindingEncryptedDataTlv.OutgoingChannelId(ShortChannelId(42)), RouteBlindingEncryptedDataTlv.PaymentRelay(CltvExpiryDelta(144), 100, 10 msat))), // Forbidden encrypted path id. TestCase(ForbiddenTlv(UInt64(6)), hex"0a 0a080123456789abcdef", TlvStream(RouteBlindingEncryptedDataTlv.OutgoingChannelId(ShortChannelId(42)), RouteBlindingEncryptedDataTlv.PaymentRelay(CltvExpiryDelta(144), 100, 10 msat), RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(1500), 1 msat), RouteBlindingEncryptedDataTlv.PathId(hex"deadbeef"))), ) From 67c55e469f6f0adc69f9c794a318fddc174521bd Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 29 Nov 2024 14:31:09 +0100 Subject: [PATCH 6/7] Leftovers from `path_key` renaming --- .../main/scala/fr/acinq/eclair/crypto/Sphinx.scala | 2 +- eclair-core/src/test/resources/offers-test.json | 8 ++++---- .../scala/fr/acinq/eclair/crypto/SphinxSpec.scala | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scala b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scala index 4005209382..5e91e3f338 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scala @@ -385,7 +385,7 @@ object Sphinx extends Logging { * @param sessionKey this node's session key. * @param publicKeys public keys of each node on the route, starting from the introduction point. * @param payloads payloads that should be encrypted for each node on the route. - * @return a blinded route and the blinding tweak of the last node. + * @return a blinded route and the path key for the last node. */ def create(sessionKey: PrivateKey, publicKeys: Seq[PublicKey], payloads: Seq[ByteVector]): BlindedRouteDetails = { require(publicKeys.length == payloads.length, "a payload must be provided for each node in the blinded path") diff --git a/eclair-core/src/test/resources/offers-test.json b/eclair-core/src/test/resources/offers-test.json index 891ed5673f..a032d94180 100644 --- a/eclair-core/src/test/resources/offers-test.json +++ b/eclair-core/src/test/resources/offers-test.json @@ -311,7 +311,7 @@ ] }, { - "description": "with blinded path via Bob (0x424242...), blinding 020202...", + "description": "with blinded path via Bob (0x424242...), path_key 020202...", "valid": true, "bolt12": "lno1pgx9getnwss8vetrw3hhyucs5ypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zyg3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x11*8]", @@ -357,7 +357,7 @@ ] }, { - "description": "with no issuer_id and blinded path via Bob (0x424242...), blinding 020202...", + "description": "with no issuer_id and blinded path via Bob (0x424242...), path_key 020202...", "valid": true, "bolt12": "lno1pgx9getnwss8vetrw3hhyucs5ypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zygs", "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x11*8]", @@ -375,7 +375,7 @@ ] }, { - "description": "... and with second blinded path via 1x2x3 (direction 1), blinding 020202...", + "description": "... and with second blinded path via 1x2x3 (direction 1), path_key 020202...", "valid": true, "bolt12": "lno1pgx9getnwss8vetrw3hhyucsl5qj5qeyv5l2cs6y3qqzesrth7mlzrlp3xg7xhulusczm04x6g6nms9trspqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqqsqqqqqqqqqqqqqqqqqqqqqqqqqqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsqpqg3zyg3zyg3zygpqqqqzqqqqgqqxqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqqgqqqqqqqqqqqqqqqqqqqqqqqqqqqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqqsg3zyg3zyg3zygtzzqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxry", "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x22*8]", @@ -524,7 +524,7 @@ "bolt12": "lno1pgz5znzfgdz3qqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqspqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" }, { - "description": "Malformed: bad blinding in blinded_path", + "description": "Malformed: bad path_key in blinded_path", "valid": false, "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcpqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" }, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/SphinxSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/SphinxSpec.scala index 4a051cdf1e..3ba3890df2 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/SphinxSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/SphinxSpec.scala @@ -577,14 +577,14 @@ class SphinxSpec extends AnyFunSuite { } test("invalid blinded route") { - val encryptedPayloads = RouteBlinding.create(sessionKey, publicKeys, routeBlindingPayloads).route.encryptedPayloads + val path = RouteBlinding.create(sessionKey, publicKeys, routeBlindingPayloads).route + assert(RouteBlinding.decryptPayload(privKeys(0), path.firstPathKey, path.encryptedPayloads(0)).isSuccess) // Invalid node private key: - val ephKey0 = sessionKey.publicKey - assert(RouteBlinding.decryptPayload(privKeys(1), ephKey0, encryptedPayloads(0)).isFailure) - // Invalid unblinding ephemeral key: - assert(RouteBlinding.decryptPayload(privKeys(0), randomKey().publicKey, encryptedPayloads(0)).isFailure) + assert(RouteBlinding.decryptPayload(privKeys(1), path.firstPathKey, path.encryptedPayloads(0)).isFailure) + // Invalid path key: + assert(RouteBlinding.decryptPayload(privKeys(0), randomKey().publicKey, path.encryptedPayloads(0)).isFailure) // Invalid encrypted payload: - assert(RouteBlinding.decryptPayload(privKeys(0), ephKey0, encryptedPayloads(1)).isFailure) + assert(RouteBlinding.decryptPayload(privKeys(0), path.firstPathKey, path.encryptedPayloads(1)).isFailure) } } From 1b88ebb3e8cd3e2e6f6c7965d66efc1edd988221 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 29 Nov 2024 16:50:28 +0100 Subject: [PATCH 7/7] Reject offers with currency And revert changes to spec test vector. --- .../fr/acinq/eclair/wire/protocol/OfferTypes.scala | 2 ++ eclair-core/src/test/resources/offers-test.json | 8 ++++---- .../acinq/eclair/wire/protocol/OfferTypesSpec.scala | 13 +++++++++---- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala index 4afdbbe761..eea4b8aa68 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/OfferTypes.scala @@ -308,6 +308,8 @@ object OfferTypes { def validate(records: TlvStream[OfferTlv]): Either[InvalidTlvPayload, Offer] = { if (records.get[OfferDescription].isEmpty && records.get[OfferAmount].nonEmpty) return Left(MissingRequiredTlv(UInt64(10))) if (records.get[OfferNodeId].isEmpty && records.get[OfferPaths].forall(_.paths.isEmpty)) return Left(MissingRequiredTlv(UInt64(22))) + // Currency conversion isn't supported yet. + if (records.get[OfferCurrency].nonEmpty) return Left(ForbiddenTlv(UInt64(6))) if (records.unknown.exists(!isOfferTlv(_))) return Left(ForbiddenTlv(records.unknown.find(!isOfferTlv(_)).get.tag)) Right(Offer(records)) } diff --git a/eclair-core/src/test/resources/offers-test.json b/eclair-core/src/test/resources/offers-test.json index a032d94180..891ed5673f 100644 --- a/eclair-core/src/test/resources/offers-test.json +++ b/eclair-core/src/test/resources/offers-test.json @@ -311,7 +311,7 @@ ] }, { - "description": "with blinded path via Bob (0x424242...), path_key 020202...", + "description": "with blinded path via Bob (0x424242...), blinding 020202...", "valid": true, "bolt12": "lno1pgx9getnwss8vetrw3hhyucs5ypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zyg3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x11*8]", @@ -357,7 +357,7 @@ ] }, { - "description": "with no issuer_id and blinded path via Bob (0x424242...), path_key 020202...", + "description": "with no issuer_id and blinded path via Bob (0x424242...), blinding 020202...", "valid": true, "bolt12": "lno1pgx9getnwss8vetrw3hhyucs5ypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zygs", "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x11*8]", @@ -375,7 +375,7 @@ ] }, { - "description": "... and with second blinded path via 1x2x3 (direction 1), path_key 020202...", + "description": "... and with second blinded path via 1x2x3 (direction 1), blinding 020202...", "valid": true, "bolt12": "lno1pgx9getnwss8vetrw3hhyucsl5qj5qeyv5l2cs6y3qqzesrth7mlzrlp3xg7xhulusczm04x6g6nms9trspqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqqsqqqqqqqqqqqqqqqqqqqqqqqqqqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsqpqg3zyg3zyg3zygpqqqqzqqqqgqqxqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqqgqqqqqqqqqqqqqqqqqqqqqqqqqqqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqqsg3zyg3zyg3zygtzzqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxry", "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x22*8]", @@ -524,7 +524,7 @@ "bolt12": "lno1pgz5znzfgdz3qqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqspqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" }, { - "description": "Malformed: bad path_key in blinded_path", + "description": "Malformed: bad blinding in blinded_path", "valid": false, "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcpqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" }, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala index 7973b33b70..9228e1e917 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/OfferTypesSpec.scala @@ -303,15 +303,20 @@ class OfferTypesSpec extends AnyFunSuite { case class TestVector(description: String, valid: Boolean, bolt12: String) - implicit val formats: DefaultFormats.type = DefaultFormats - test("spec test vectors") { + implicit val formats: DefaultFormats.type = DefaultFormats + val src = Source.fromFile(new File(getClass.getResource(s"/offers-test.json").getFile)) val testVectors = JsonMethods.parse(src.mkString).extract[Seq[TestVector]] src.close() for (vector <- testVectors) { - val offer = Offer.decode(vector.bolt12) - assert((offer.isSuccess && offer.get.features.unknown.forall(_.bitIndex % 2 == 1)) == vector.valid, vector.description) + if (vector.description == "with currency") { + // We don't support currency conversion yet. + assert(Offer.decode(vector.bolt12).isFailure) + } else { + val offer = Offer.decode(vector.bolt12) + assert((offer.isSuccess && offer.get.features.unknown.forall(_.bitIndex % 2 == 1)) == vector.valid, vector.description) + } } } }