op-3ds2: an unconverted foreign amount is not under an EUR SCA cap #287
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/openpay-sca-exemption-eur-caps"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The defect
low_value_eligiblecomparedintent.amount.minor_unitsagainst the RTS Article 16 caps of EUR 30 / EUR 100 whatever currency the intent was denominated in, under this comment:The substitution is not conservative. It is conservative only for currencies worth less than EUR per minor unit. A GBP 30.00 payment (
minor_units = 3000, about EUR 35) and a CHF 30.00 payment (about EUR 32) each satisfy3000 <= 3000, soevaluate()pushedLowValueTransactionand the orchestrator authorised with no SCA challenge above the Article 16 ceiling. Five such payments run to about EUR 175 under a cap documented as EUR 100.The FX conversion was simply not computed, and the uncomputed result was treated as a value inside the cap — while
op_core::Moneycarries the currency, so the information needed to refuse was present and discarded. The second clause compounded it by adding a possibly-non-EUR figure tocumulative_low_value_24h_eur_minor, a field documented "in EUR minor units", so the 24 h cap was summed across mixed units.The same hole in the TRA branch (not reported by the sweep that found this)
FraudRateBracket::tra_cap_minor_unitsis documented "Returns the TRA cap (EUR minor units)" and was compared to the raw minor-unit count too, at EUR 100 / 250 / 500. GBP 450.00 is about EUR 527 and passed theUpTo1Bpcap of EUR 500.Neither was visible because every fixture in the file is built by an
eur()helper, so no test ever presented a non-EUR amount.The fix
The unknown becomes unusable rather than permissive:
ExemptionContext::eur_equivalent_minor: Option<i64>carries the amount converted to EUR minor units, supplied by the caller that holds the rate. (The old comment pointed atop-fx, which is not even a dependency of this crate.)amount_in_eur_minor()returns the EUR figure for an EUR amount, the supplied conversion for anything else, andNonewhen there is none. Both EUR-denominated caps now take thatOptionand decline when it is absent: an unconverted figure is not a figure below a cap.ExemptionContextderivesDefaultand every construction site in the repo uses..Default::default(), so the new field is additive and defaults toNone— the direction that withholds the exemption.Tests
Both caps, on both sides of each:
an_unconverted_foreign_amount_is_not_low_valuea_supplied_conversion_decides_the_low_value_capan_eur_amount_needs_no_conversioneur_equivalent_minor: None→ still clearsan_unconverted_foreign_amount_is_not_under_the_tra_capa_cumulative_total_that_would_overflow_does_not_slip_under_the_capi64::MAXis not under EUR 100Falsification
Every test was shown to fail against a mutated implementation:
amount_in_eur_minor→ alwaysSome(minor_units))an_unconverted_foreign_amount_is_not_under_the_tra_capFAILED+on the cumulative totalN2 was run in both profiles, because the claim about release-mode wrapping is a claim about measured behaviour:
panicked ... attempt to add with overflowat the arithmetic site.N1 and N3 together show the two caps are fixed independently; N2 shows the saturation is load-bearing in the profile that ships.
Verification
On the build host (aarch64):
cargo test -p op-3ds2— exit status 0, 73 lib + 12 integration tests pass, including the pre-existingexemption_low_value_25_eurandexemption_100_eur_qualifies_for_tra_only_when_bracket_allows.Scope
op-3ds2ships no conformance vectors.ExemptionContextis constructed in three places outside this module (tests/integration.rs), all with..Default::default()and all with EUR amounts, so none changes behaviour.🤖 Generated with Claude Code
`low_value_eligible` compared `intent.amount.minor_units` against the RTS Article 16 caps of EUR 30 / EUR 100 whatever currency the intent was denominated in, under this comment: // RTS 16 thresholds. We treat all non-EUR currencies conservatively // by checking the minor-unit value against EUR limits assuming a // 1:1 EUR equivalency; production deployments convert via op-fx. The substitution is not conservative. It is conservative only for currencies worth *less* than EUR per minor unit. A GBP 30.00 payment (minor_units 3000, about EUR 35) and a CHF 30.00 payment (about EUR 32) each satisfy `3000 <= 3000`, so `evaluate()` pushed `LowValueTransaction` and the orchestrator authorised with no SCA challenge above the Article 16 ceiling. Five such payments run to about EUR 175 under a cap documented as EUR 100. The FX conversion was not computed, and the uncomputed result was treated as a value inside the cap — while `op_core::Money` carries the currency, so the information needed to refuse was present and discarded. The second clause compounded it by adding a possibly-non-EUR figure to `cumulative_low_value_24h_eur_minor`, a field documented "in EUR minor units", so the 24 h cap was summed across mixed units. The same hole sat in the TRA branch, which the sweep that found this did not report: `FraudRateBracket::tra_cap_minor_units` is documented "the TRA cap (EUR minor units)" and was compared to the raw minor-unit count too, at EUR 100 / 250 / 500. GBP 450.00 is about EUR 527 and passed the UpTo1Bp cap of EUR 500. Neither was visible because every fixture in the file is built by an `eur()` helper, so no test ever presented a non-EUR amount. The fix makes the unknown unusable rather than permissive: - `ExemptionContext::eur_equivalent_minor: Option<i64>` carries the amount converted to EUR minor units, supplied by the caller that holds the rate (the comment already pointed at `op-fx`, which is not even a dependency of this crate). - `amount_in_eur_minor()` returns the EUR figure for an EUR amount, the supplied conversion for anything else, and `None` when there is none. Both EUR-denominated caps now take that `Option` and decline when it is absent: an unconverted figure is not a figure below a cap. - The cumulative clause adds two EUR figures rather than two different units, and saturates. The total is caller-supplied, and a plain `+` overflowing i64 wraps negative in release and so slips under the cap, while panicking in debug. `ExemptionContext` derives `Default` and every construction site in the repo uses `..Default::default()`, so the new field is additive and defaults to `None` — the direction that withholds the exemption. Tests cover both caps on both sides: an unconverted GBP 30.00 is not low-value, the same intent with a supplied EUR 35.00 is not and with EUR 25.00 is, an EUR amount still needs no conversion, an unconverted GBP 450.00 is not under the TRA cap while EUR 460.00 is, and a 24 h total of i64::MAX does not slip under EUR 100. Co-Authored-By: Claude Opus 5 <[email protected]>