op-3ds2: an unconverted foreign amount is not under an EUR SCA cap #287

Merged
dcharlot merged 1 commit from fix/openpay-sca-exemption-eur-caps into main 2026-10-17 09:24:11 -04:00

2026-10-17

Transaction Science
op-3ds2: an unconverted foreign amount is not under an EUR SCA cap
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 18s
standards workspaces build + test / macOS energy backends (pull_request) Has been skipped
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 3m36s
standards workspaces build + test / openpay (openpay, openpay, linux-arm64) (pull_request) Successful in 5m57s
`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]>
2026-10-17 04:27:27 -04:00