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
Owner

The defect

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 simply 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 in the TRA branch (not reported by the sweep that found this)

FraudRateBracket::tra_cap_minor_units is 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 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

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 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 now adds two EUR figures rather than two different units, and saturates.

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

Both caps, on both sides of each:

test asserts
an_unconverted_foreign_amount_is_not_low_value GBP 30.00, no rate → not low-value
a_supplied_conversion_decides_the_low_value_cap same intent: EUR 35.00 → no, EUR 25.00 → yes
an_eur_amount_needs_no_conversion EUR 25.00 with eur_equivalent_minor: None → still clears
an_unconverted_foreign_amount_is_not_under_the_tra_cap GBP 450.00 no rate → no TRA; EUR 527 → no; EUR 460 → yes
a_cumulative_total_that_would_overflow_does_not_slip_under_the_cap 24 h total of i64::MAX is not under EUR 100

Falsification

Every test was shown to fail against a mutated implementation:

mutation result
N1 restore the 1:1 substitution (amount_in_eur_minor → always Some(minor_units)) the 3 currency tests FAILED; the EUR-path and overflow tests still passed
N3 revert only the TRA branch, leaving low-value fixed only an_unconverted_foreign_amount_is_not_under_the_tra_cap FAILED
N2 restore the plain + on the cumulative total see below

N2 was run in both profiles, because the claim about release-mode wrapping is a claim about measured behaviour:

  • debug — panicked ... attempt to add with overflow at the arithmetic site.
  • release — no arithmetic panic; the assertion fires instead, i.e. the exemption was granted. The total wrapped negative and slipped under the cap, as claimed.

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-existing exemption_low_value_25_eur and exemption_100_eur_qualifies_for_tra_only_when_bracket_allows.

Scope

op-3ds2 ships no conformance vectors. ExemptionContext is 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

## The defect `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: ```rust // 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 simply 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 in the TRA branch (not reported by the sweep that found this) `FraudRateBracket::tra_cap_minor_units` is 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 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 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 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 now adds **two EUR figures rather than two different units**, and **saturates**. `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 Both caps, on both sides of each: | test | asserts | |---|---| | `an_unconverted_foreign_amount_is_not_low_value` | GBP 30.00, no rate → not low-value | | `a_supplied_conversion_decides_the_low_value_cap` | same intent: EUR 35.00 → no, EUR 25.00 → yes | | `an_eur_amount_needs_no_conversion` | EUR 25.00 with `eur_equivalent_minor: None` → still clears | | `an_unconverted_foreign_amount_is_not_under_the_tra_cap` | GBP 450.00 no rate → no TRA; EUR 527 → no; EUR 460 → yes | | `a_cumulative_total_that_would_overflow_does_not_slip_under_the_cap` | 24 h total of `i64::MAX` is not under EUR 100 | ### Falsification Every test was shown to fail against a mutated implementation: | mutation | result | |---|---| | **N1** restore the 1:1 substitution (`amount_in_eur_minor` → always `Some(minor_units)`) | the **3** currency tests FAILED; the EUR-path and overflow tests still passed | | **N3** revert **only** the TRA branch, leaving low-value fixed | **only** `an_unconverted_foreign_amount_is_not_under_the_tra_cap` FAILED | | **N2** restore the plain `+` on the cumulative total | see below | N2 was run in **both profiles**, because the claim about release-mode wrapping is a claim about measured behaviour: - **debug** — `panicked ... attempt to add with overflow` at the arithmetic site. - **release** — no arithmetic panic; the *assertion* fires instead, i.e. the exemption **was granted**. The total wrapped negative and slipped under the cap, as claimed. 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-existing `exemption_low_value_25_eur` and `exemption_100_eur_qualifies_for_tra_only_when_bracket_allows`. ## Scope `op-3ds2` ships no conformance vectors. `ExemptionContext` is 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](https://claude.com/claude-code)
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
53d57dc5c2
`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]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!287
No description provided.