open-standards/openpay/docs/25-crypto-rail-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

193 lines
8.5 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 25 — Crypto rail: stablecoin settlement at sub-cent cost
**Status**: Draft v0.25
**Date**: 2026-05-22
## Why
The mission has always been "bring margin back to the vendors."
Card networks charge 100–300 basis points per transaction. A USDC
transfer on Base or Solana costs **single-cents or fractions of a
cent**. For a $1M/month vendor that's the difference between
$30,000 and $5 in fees. That is the entire point.
Phase 25 adds the crypto / stablecoin rail to OpenPay. First of
three sequenced phases (25 → 26 → 27): crypto first because it's
the most direct path to the margin win, then persistent backends
(deployable), then subscriptions (feature surface).
## What shipped
| # | Item | Where |
|--:|---|---|
| 1 | `RailKind::Crypto` variant in op-core | `op-core/src/rails.rs` |
| 2 | `PaymentMethod::Crypto(CryptoAddress)` variant in op-core | `op-core/src/method.rs` |
| 3 | `CryptoAddress { chain, address }` type with chain-agnostic constructor | `op-core/src/method.rs` |
| 4 | `op-rails-crypto` crate — `CryptoGateway` trait, `CryptoTransferReq` / `CryptoDecision` / `CryptoStatus`, `StableToken` curated catalog | `crates/op-rails-crypto/` |
| 5 | `op-orchestrator::CryptoAdapter` — wraps any `CryptoGateway` as a `RailAdapter` | `op-orchestrator/src/adapters/crypto.rs` |
| 6 | `PolicyRouter::with_crypto_drivers` + `rail_order` carve-out (Crypto is method-exclusive) | `op-orchestrator/src/router.rs` |
| 7 | `DeterministicCryptoGateway` in op-driver-sdk — per-key overrides, amount rules, transport-error mode, history | `op-driver-sdk/src/crypto.rs` |
| 8 | `conformance::run_crypto` harness — `CryptoMissingTxHashOnFinalized`, `CryptoCrossChainAccepted` checks | `op-driver-sdk/src/conformance.rs` |
| 9 | Orchestrator integration: crypto-routed approve + reject flows, all-three-rails conformance | `op-driver-sdk/tests/orchestrator_integration.rs` |
| 10 | `RailKind` codec in op-graph rail_telemetry covers `Crypto` | `op-graph/src/rail_telemetry.rs` |
Workspace at the end of Phase 25:
| Check | Result |
|---|---|
| `cargo build --workspace --all-targets` | **0 errors, 0 warnings** |
| `cargo test --workspace` | **957 passing, 0 failing** (+27 vs Phase 24) |
| `cargo clippy --workspace --all-targets` | **0 warnings** |
Test-count delta: op-rails-crypto +7 (token / status), op-orchestrator
adapters/crypto +7, op-driver-sdk +11 (8 mock + 3 conformance), e2e +2.
## Architecture
```text
on-chain
▲
PaymentIntent ──► PolicyRouter ──► CryptoAdapter ──► CryptoGateway ──► chain client
│ (driver-owned) │
│ ▼
└──Idempotency key flows through unchanged──► signer / RPC / Fireblocks
```
Three trait layers, mirroring the existing card / A2A pattern:
1. **`CryptoGateway`** (in `op-rails-crypto`) — the only thing
driver authors implement. Methods: `submit_transfer`,
`query_status`, plus accessors (`name`, `chain`, `token`,
`supports`).
2. **`CryptoAdapter`** (in `op-orchestrator`) — wraps any
`CryptoGateway` as a `RailAdapter` the orchestrator can route
through. Status → outcome mapping:
`Finalized → Success`, `Rejected → HardDecline`,
`Pending|Confirming → SoftFailure`, `Transient → SoftFailure`.
3. **`DeterministicCryptoGateway`** (in `op-driver-sdk`) —
programmable mock for operator tests + driver-author reference.
## Why no chain SDK dependency
The reference stack does NOT pull in `solana-sdk` or `ethers-rs`.
Three reasons:
1. **Footprint.** `ethers-rs` alone is ~250 crates.
2. **Signing security.** Operators sign with HSMs, KMS, multisig,
Fireblocks. Baking in a software keystore would be a footgun.
3. **Chain evolution.** New chains and L2s appear quarterly; the
reference stack stays neutral by exposing only the trait
surface.
Operators wire their preferred client behind a `CryptoGateway`
impl — a `RpcClient` for Solana, an `EthersProvider` for EVM,
Fireblocks for custody, whatever fits their security posture.
## StableToken catalog
Curated `(chain, contract, decimals, symbol)` constants for the
common deployments:
| Constructor | Chain | Symbol | Decimals |
|---|---|---|---|
| `StableToken::UsdcSolana` | solana | USDC | 6 |
| `StableToken::UsdcBase` | base | USDC | 6 |
| `StableToken::UsdcEthereum` | ethereum | USDC | 6 |
| `StableToken::UsdcPolygon` | polygon | USDC | 6 |
| `StableToken::UsdcArbitrum` | arbitrum | USDC | 6 |
| `StableToken::EurcBase` | base | EURC | 6 |
| `StableToken::PyusdEthereum` | ethereum | PYUSD | 6 |
Drivers **must** re-validate contract addresses against the
issuer's official docs before broadcasting — token contracts have
been upgraded / re-deployed in the past, and minting a transfer to
a stale contract burns funds.
## CryptoStatus taxonomy
```rust
Pending // submitted, not yet in a block
Confirming // in a block, below operator confirmation threshold
Finalized // meets operator finality threshold — settled
Rejected // chain refused (revert, sim failure, invalid sig)
Transient // RPC/network — may or may not have broadcast
```
`funds_moved()` is true only for `Finalized`. Operator-acceptable
finality is driver-side (1 confirmation on Solana, 12 on Ethereum
mainnet, varies on L2s).
## Routing changes
`PolicyRouter::rail_order` short-circuits: any
`PaymentMethod::Crypto(_)` routes exclusively to crypto drivers.
No fallback to card or A2A — those rails physically can't fulfill
a crypto destination, so the orchestrator doesn't try.
```rust
let router = PolicyRouter::new(vec![], vec![])
.with_crypto_drivers(vec!["usdc-base".to_owned(), "usdc-solana".to_owned()]);
```
The chain-level routing (which `(chain, token)` driver) is the
operator's choice — they register one adapter per pair and rely on
`supports()` to filter by destination chain.
## Conformance additions
```rust
let gateway = MyChainGateway::sandbox(rpc_url, signer);
let report = op_driver_sdk::conformance::run_crypto(&gateway);
assert!(report.is_clean(), "{:?}", report.failures);
```
New failure modes:
- `CryptoMissingTxHashOnFinalized` — finalized without a tx_hash;
callers have no handle for reconciliation.
- `CryptoCrossChainAccepted { gateway_chain, address_chain }` —
driver accepted a destination on a chain it doesn't service. A
Solana driver shouldn't broadcast EVM addresses.
## Honest concerns (carry-forward)
- **No on-chain signing.** That's operator-side by design (see
"Why no chain SDK dependency" above).
- **No bridging.** Cross-chain transfers (USDC@Base → USDC@Solana)
are out of scope. Operators handle multi-chain by registering
one driver per `(chain, token)` and routing on destination chain.
- **Confirmation polling is operator-side.** A transfer that
comes back `Confirming` is reported as such; the operator drives
the polling loop via `gateway.query_status(...)` and decides
when depth meets their finality threshold.
- **Fiat ⇄ stablecoin conversion is operator-side.** The intent's
`Money` flows through unchanged. Operators quoting in USD and
settling in USDC (1:1 by definition) handle the 2-decimal vs
6-decimal scaling in their quote layer.
- **Memo support varies by chain.** Solana has the memo program;
EVM has no standard memo. Drivers either emit an indexed event
log or skip memos entirely.
- **Reorg handling is operator-side.** If a chain reorgs and a
previously-finalized tx un-confirms, the operator's polling
loop is the right layer to detect it and post a compensating
ledger entry.
- **No L2-specific routing logic.** Drivers register per `(chain,
token)`; cost-optimization across L2s (route Base when fees are
low, Arbitrum when not) is a higher-layer concern operators
build on top.
## Test totals
```
op-rails-crypto 7 (token catalog 3, status predicates 4)
op-orchestrator/crypto 7 (adapter status mapping + idempotency + chain check)
op-driver-sdk crypto mock 8 (default / overrides / amount / transport / xchain / etc.)
op-driver-sdk conformance 3 (deterministic passes, missing tx_hash, cross-chain accept)
e2e integration 2 (crypto routed through Orchestrator, rejection path)
----
+27 net
```
`cargo test --workspace`: **957 passing, 0 failing.**
`cargo build --workspace --all-targets`: **0 warnings.**
`cargo clippy --workspace --all-targets`: **0 warnings.**