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.
6.6 KiB
Phase 4 — op-rails-card Complete
Status: Draft v0.4 Date: 2026-05-17
What shipped
op-rails-card: a pluggable PSP abstraction plus a complete Hyperswitch
driver. Every PSP — Hyperswitch, Stripe, Adyen, Finix, Moov, future ones —
implements one trait. The orchestrator holds Box<dyn CardAcquirer> and
never knows which one it has.
Plus a working end-to-end example (headless-server) that takes a payment
through the full Created → Authorized → Captured → Refunded flow using
both the op-core typestate machine and the Hyperswitch driver.
Modules
| File | Responsibility |
|---|---|
acquirer.rs |
CardAcquirer trait, AuthRequest/AuthDecision types, AuthStatus (9-variant normalized outcome). |
error.rs |
Sealed Error enum: Transport, PspRejected, MissingField, Parse, UnknownStatus, UnsupportedMethod, Core, DriverValidation. |
hyperswitch/wire.rs |
JSON request/response types matching Hyperswitch V1 API. Eight serde-verified shapes against canonical docs examples. |
hyperswitch/status_map.rs |
Maps the 17-variant Hyperswitch status enum to OpenPay's 9-variant AuthStatus. |
hyperswitch/client.rs |
HTTP client using ureq (sync, rustls TLS, no tokio). Implements CardAcquirer. |
tests/hyperswitch.rs |
13 integration tests using httpmock — full lifecycle without network. |
Verified ground truth
All endpoint paths, request shapes, and response status enums were extracted directly from the Hyperswitch V1 API reference fetched live during construction:
| Claim | Verification |
|---|---|
POST /payments request shape {amount, currency} minimal |
Verbatim from api-reference.hyperswitch.io/v1/payments/payments--create |
amount is int64 minor units (e.g. 6540 = $65.40) |
Same source |
capture_method ∈ {automatic, manual, manual_multiple} |
Same |
authentication_type ∈ {three_ds, no_three_ds} |
Same |
Idempotency via merchant-supplied payment_id, 30 chars |
Same |
Auth header api-key only, server-side |
"Make sure to never share your API key with your client application" — Hyperswitch docs |
POST /payments/{id}/capture body {amount_to_capture} |
Manual Capture quickstart, hyperswitch docs |
POST /payments/{id}/cancel body {cancellation_reason} |
Verified via Hyperswitch hex-docs and the Cancel API reference |
POST /refunds body {payment_id, amount, reason, merchant_refund_id} |
Refund API reference |
| 17 documented status enum values | Status enum on payments--create response schema |
requires_capture follows manual auth |
Manual Capture quickstart: "On successful authorization, the payment would transition to 'requires_capture' status" |
succeeded after capture |
Same |
partially_captured / partially_captured_and_capturable distinction |
"When capture method is manual & multiple captures are supported - partially_captured_and_capturable" — Manual Capture docs |
Error envelope {error: {type, message, code}} |
Hyperswitch error_codes reference |
next_action.redirect_to_url for 3DS |
Confirm API docs: "transition to a requires_customer_action status with a next_action block" |
Architecture decisions
-
Sync HTTP, not async. We use
ureqfor the network layer. Async-fn-in-traits stabilized in Rust 1.95 but doesn't work cleanly throughdyn Traitwithoutreturn_type_notation. Sync keeps the trait simple and lets the FFI layer call drivers from any thread. When async + dyn stabilizes cleanly we'll migrate. -
Box<dyn CardAcquirer>from day one. The orchestrator never knows which PSP it's calling. Adding Stripe/Adyen/Finix is a new file, not a refactor. -
Unknown statuses error rather than guess. Hyperswitch adds new status values periodically. If we see one we don't recognize, we return
Error::UnknownStatus(s)instead of falling through to a default — silently treating an unknown status as "approved" could move funds incorrectly. -
EMV TLV forwarded via
connector_metadata.emv_tlv_hex. Hyperswitch V1 doesn't have a first-class Tap-to-Pay payload field yet. We hex-encode the TLV blob and put it inconnector_metadatawhere connectors that support card-present mode can pick it up. Tested: the testemv_payload_forwarded_in_connector_metadataverifies the wire shape. -
No raw PAN ever. Driver's
supports()accepts onlyVault,Wallet,Emv— the three opaque-reference variants fromop-core. Raw PAN can't even reach the driver without thepci-scopefeature flag inop-core.
Test coverage
| File | Tests | Notes |
|---|---|---|
acquirer.rs |
4 | AuthStatus classification invariants |
hyperswitch/wire.rs |
10 | JSON round-trip for every request/response type |
hyperswitch/status_map.rs |
11 | All 17 documented statuses mapped; unknown errors |
hyperswitch/client.rs |
8 | URL construction, supports(), currency fallbacks |
tests/hyperswitch.rs |
13 | Full lifecycle via httpmock: authorize, capture, void, refund, error paths, EMV forwarding |
| Phase 4 total | 46 | |
| Cumulative (Phases 1–4) | 158 |
What was independently verified by Python before being asserted in Rust
currency_from_code("zzz")fallback path: lowercase rejected bytry_new, returnsunwrap_or(USD)→ USD. Confirmed by tracing the byte-validation logic.- EMV blob hex encoding
[0x9F, 0x02, 0x06, 0×5, 0x01, 0x00] → "9f0206000000000100". Confirmed. - Canonical Hyperswitch response body parses with all expected fields present.
What's NOT yet implemented (deferred)
- Live sandbox tests. Feature-gated under
live-sandbox. They need a realHYPERSWITCH_API_KEYand run againsthttps://sandbox.hyperswitch.io. Theheadless-serverexample is the entry point. - Async migration. Once
return_type_notationstabilizes, drivers move toasync fnin trait. - Stripe, Adyen, Finix, Moov drivers. The trait is the abstraction; each new driver is a new module with its own wire types and status map. Will be added in Phase 4.1+ as separate sub-phases.
- Retry/backoff layer. Live PSPs occasionally return 5xx. A wrapper that retries
Transientstatuses with exponential backoff sits one level above the driver, not inside it. - Webhook ingestion. When a customer completes a 3DS challenge, Hyperswitch fires a webhook. The receiver lives in
op-orchestrator(later phase) and uses ourstatus_map::mapdirectly.
Next: Phase 5 — op-rails-a2a
The non-card rails: FedNow direct (the prize, lowest fees), then RTP, PIX,
UPI, SEPA Instant. Each driver builds an ISO 20022 pacs.008 via the
verified op-iso20022 builder from Phase 2 and sends it over the
appropriate transport (mTLS for PIX, FedLine for FedNow, RT1/TIPS for SEPA).