open-standards/openpay/docs/29-fx-progress.md
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
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.
2026-05-23 07:58:09 -04:00

199 lines
8.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```text
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
```rust
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:
1. **Quote's `valid_until_unix_secs`** — when the rate stops being
usable on the wire. Set by the provider (`StaticQuoteProvider`
defaults to 5 minutes; live providers come from the feed).
2. **`CachedQuoteProvider` TTL** — 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, &quote, 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
```rust
// 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-fx` has 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_ppm` they hand to the provider; `op-fx` doesn't have a
separate `spread_ppm` field. 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`'s `HashMap` grows
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 a
`HashMap` → `lru::LruCache` swap).
- **No quote-source attestation.** A quote's `source_name` is a
free-form string; nothing signs it. Operators with stricter
audit requirements should hash the upstream feed's response and
carry the digest in `source_name` or extend `Quote` with 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_units` correctly (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.**