Transaction Science open standards monorepo. Each subdirectory is a self-contained reference protocol with its own README and licence. - openpay/ Apache-2.0 payment-acceptance stack (Rust, 26 crates) - smart-byte/ CC-BY-4.0 content-addressed value carrier (spec) - eoc/ CC-BY-4.0 energy-optimised compute (spec) The wire format and the right to fork are public. The optional hosted services are the offer; the protocols are not.
8.3 KiB
Phase 29 — Multi-currency / FX
Status: Draft v0.29 Date: 2026-05-22
Why
OpenPay was single-currency end-to-end: each Money carried a
Currency, settlement batches enforced same-currency, no
primitive existed for "accept in EUR, pay out in USD." That's a
hard wall for any vendor with global customers — and "global
customers" is most operators today.
Phase 29 adds the FX primitive: a pluggable [QuoteProvider]
trait, integer-exact conversion with deterministic rounding, two
reference providers (static + cached), and HTTP endpoints to
quote/convert. Operators wire their FX feed (Wise, Open Exchange
Rates, bank tariff, internal mid-market) behind the trait — the
reference stack stays free of HTTP clients, same architectural
position as op-webhook (Phase 13) and op-rails-crypto (Phase 25).
Second of three sequenced phases (28 → 29 → 30). Next: 3DS / SCA resume primitive.
What shipped
| # | Item | Where |
|---|---|---|
| 1 | op-fx crate — Quote, QuoteProvider, convert, rounding, providers |
crates/op-fx/ |
| 2 | Quote type — (source, target, rate_ppm, fetched_at, valid_until, source_name) with inverse() helper |
quote.rs |
| 3 | RoundingMode — HalfEven (default, banker's), Down, Up |
convert.rs |
| 4 | convert(money, quote, mode, now) — integer-exact via i128 intermediate; rejects same-currency, mismatched currency, zero rate, expired quotes |
convert.rs |
| 5 | QuoteProvider trait — get_quote(source, target, now) -> Result<Quote> + name() |
provider.rs |
| 6 | StaticQuoteProvider — operator-fixed (source, target) → rate_ppm table with configurable validity window |
provider.rs |
| 7 | CachedQuoteProvider<P> — TTL wrapper that respects both cache-age and quote-validity invariants |
provider.rs |
| 8 | AppState.fx: Arc<dyn QuoteProvider> + with_fx_provider(...) builder |
op-server/src/state.rs |
| 9 | HTTP GET /v1/fx/quote?from=X&to=Y and POST /v1/fx/convert |
op-server/src/handlers/fx.rs |
| 10 | From<op_fx::Error> for ApiError mapping — NoQuote → 404, QuoteExpired → 409, CurrencyMismatch/InvalidRate/SameCurrency → 400, Overflow → 500 |
op-server/src/error.rs |
Workspace at the end of Phase 29:
| Check | Result |
|---|---|
cargo build --workspace --all-targets |
0 errors, 0 warnings |
cargo test --workspace |
1047 passing, 0 failing (+20 vs Phase 28) |
cargo clippy --workspace --all-targets |
0 warnings |
Test-count delta: op-fx 17 (convert 10, provider 4, quote 3) + op-server 3 (FX endpoints).
Why integer math (parts-per-million)
FX is the canonical "don't use floats for money" example. A
0.0001 rounding error on $10M is $1000 someone cares about.
Rates: u64 parts per million (1.000000 = 1_000_000). Six
decimal places is the FX industry standard for spot rates;
carrying more is meaningless given bid/ask spreads.
Conversion:
target_minor = round( source_minor × rate_ppm / 1_000_000 )
via i128 intermediate so an i64::MAX minor amount times a
large rate doesn't overflow before the explicit i64::try_from
guard at the end. No floats anywhere in the conversion pipeline.
Rounding semantics
RoundingMode::HalfEven // banker's; default; statistically unbiased
RoundingMode::Down // truncate toward zero; biased toward whoever benefits
RoundingMode::Up // away from zero; biased toward the other side
Half-even (banker's rounding) is the GAAP/IFRS default for
financial systems — eliminates the systematic up-bias of "round
half up." The crate uses it as the Default impl.
Quote validity
Two distinct timing constraints:
- Quote's
valid_until_unix_secs— when the rate stops being usable on the wire. Set by the provider (StaticQuoteProviderdefaults to 5 minutes; live providers come from the feed). CachedQuoteProviderTTL — how long the cache reuses a previously-fetched quote before re-asking the inner provider.
The cache respects both: a cached quote that's still inside its
TTL but past its valid_until is refetched, not served. Phase 29
ships an explicit test for this (cached_provider_bypasses_cache_after_quote_expires).
convert(money, "e, mode, now) checks valid_until and
returns Error::QuoteExpired if now is past it — operators
can't accidentally settle against a stale rate.
HTTP surface
GET /v1/fx/quote?from=EUR&to=USD
POST /v1/fx/convert # body: {from, to, amount_minor, rounding?}
Responses are JSON, same error envelope ({code, message, details}) as the rest of op-server.
The reference binary defaults AppState.fx to an empty
StaticQuoteProvider — all /v1/fx/quote requests return 404 not_found until the operator wires real rates. That's the
explicit "you haven't configured this yet" failure mode.
Operator wiring
// Test/demo: fixed rate table.
let provider: Arc<dyn QuoteProvider> = Arc::new(
StaticQuoteProvider::new()
.with_rate(Currency::EUR, Currency::USD, 1_082_500)
.with_rate(Currency::USD, Currency::EUR, 923_787)
);
// Production: operator's live feed, cached for 30 seconds.
let live = Arc::new(MyWiseProvider::new(api_key));
let cached: Arc<dyn QuoteProvider> = Arc::new(CachedQuoteProvider::new(live, 30));
let state = AppState::with_graph_path("/var/lib/openpay/data.graph")?
.with_fx_provider(cached);
The trait surface is one method (get_quote) plus a name string —
implementing it against any feed is small.
What the crate deliberately does NOT do
- No HTTP client.
op-fxhas zero networking dependencies. Operator-supplied provider impls do the I/O. - No spread modeling. A bank's tariff includes a margin
above mid-market. Operators bake the margin into the
rate_ppmthey hand to the provider;op-fxdoesn't have a separatespread_ppmfield. Keeps the type small and avoids opinions about pricing. - No triangulation. USD/JPY is a direct pair; the crate doesn't auto-route via USD/EUR + EUR/JPY when a direct rate is missing. Operators who need cross-rates assemble them themselves and feed them in as a single quote.
- No hedging. Quote validity is an advisory window, not a forward lock. Forward contracts are an operator-side concern.
Settlement integration (deferred)
The settlement batch is still single-currency. Wiring "open a USD
acquiring batch, convert to EUR at close, pay out in EUR" is the
natural next step but adds a Holdback.payout_currency_amount: Option<(Money, Quote)> field and changes the NACHA / pacs.008
generators. Phase 29 ships the conversion primitive; the
settlement-layer integration is intentionally a separate wedge
(operators can compose it externally today by quoting before they
close).
Honest concerns (carry-forward)
- No real HTTP-backed provider in the workspace. Operators
implement their own — same intentional gap as
op-webhook's HTTP transport. - Cache is unbounded.
CachedQuoteProvider'sHashMapgrows with every unique pair seen; no eviction beyond TTL-based skip on read. For ≤100 pairs this is fine; operators with a wild long-tail of currencies want LRU eviction (drop-in via aHashMap→lru::LruCacheswap). - No quote-source attestation. A quote's
source_nameis a free-form string; nothing signs it. Operators with stricter audit requirements should hash the upstream feed's response and carry the digest insource_nameor extendQuotewith a signature field. - No settlement-layer carve-out. A batch is still one currency. Multi-currency settlement is a follow-up that builds on top of this primitive.
- Negative amounts work. Conversion handles negative
minor_unitscorrectly (refund/dispute reversal flows), but operators reading the conversion result for outbound transfer-amount construction need their own sign-check on the rail side — many rails reject negative-amount messages outright.
Test totals
op-fx 17 (convert 10, provider 4, quote 3)
op-server +3 (quote, convert, 404 on unknown pair)
----
+20 net
cargo test --workspace: 1047 passing, 0 failing.
cargo build --workspace --all-targets: 0 warnings.
cargo clippy --workspace --all-targets: 0 warnings.