open-standards/openpay/crates/op-rails-crypto/src/gateway.rs
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

211 lines
7 KiB
Rust

//! The [`CryptoGateway`] trait and its request / response types.
//!
//! A gateway is the operator's bridge between `OpenPay`'s abstract
//! "send N tokens to this address" request and a concrete chain
//! client (Solana `RpcClient`, EVM `Provider`, Fireblocks API, ...).
//! Drivers implement [`CryptoGateway`]; the orchestrator routes via
//! the wrapping `CryptoAdapter` in `op-orchestrator`.
use op_core::{CryptoAddress, Money};
use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::token::TokenRef;
/// Request to send a stablecoin transfer.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CryptoTransferReq {
/// Token to send (chain + contract + decimals + symbol).
pub token: TokenRef,
/// Destination address. Must be on `token.chain`.
pub to: CryptoAddress,
/// Amount in the token's smallest unit (e.g. `1_000_000` =
/// `1.000000 USDC` since USDC has 6 decimals). Carried in a
/// `Money` so accounting downstream sees a single currency
/// system (with `token.symbol` as the currency code).
pub amount: Money,
/// Operator-supplied idempotency key. Drivers forward to their
/// chain client where supported (Solana's `recent_blockhash` +
/// signed message dedup is implicit; EVM drivers track keys in
/// their own DB).
pub idempotency_key: String,
/// Operator-supplied memo / reference. Solana accepts the
/// memo program; EVM has no standard memo (drivers may emit a
/// `transfer + log` or skip).
pub memo: Option<String>,
}
/// Request to query the status of a previously broadcast transfer.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusQueryReq {
/// Chain identifier (`"solana"`, `"base"`, ...). Drivers ignore
/// queries on chains they don't service (return
/// [`crate::Error::UnsupportedChain`]).
pub chain: String,
/// Chain transaction hash / signature.
pub tx_hash: String,
/// Operator-side reference — drivers may use it to look up
/// state in their own DB if the chain query is slow.
pub idempotency_key: Option<String>,
}
/// What the gateway returns.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CryptoDecision {
/// Normalized status.
pub status: CryptoStatus,
/// Chain transaction hash / signature (Solana: 88-char base58
/// signature; EVM: `0x` + 64-char hex). `None` only on
/// transient failures *before* broadcast.
pub tx_hash: Option<String>,
/// Confirmation depth at decision time. For chains with
/// instant finality (Solana's "finalized" commitment) this is
/// 1; for probabilistic-finality chains the driver reports the
/// observed depth.
pub confirmations: u32,
/// Settled amount, if the gateway could read it back from the
/// receipt. Usually equal to requested.
pub settled_amount: Option<Money>,
/// Raw chain-side status code, preserved for diagnostics.
pub raw_status: Option<String>,
/// Reason text for rejections.
pub reason: Option<String>,
}
/// Normalized crypto-transfer status. Tracks the chain-lifecycle
/// stages from broadcast to operator-acceptable finality.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CryptoStatus {
/// Submitted but not yet observed in a block.
Pending,
/// In a block but below operator confirmation threshold.
Confirming,
/// Confirmation depth meets the operator's finality threshold.
/// Funds are settled.
Finalized,
/// Chain definitively rejected (revert, simulation failure,
/// invalid signature). Funds did not move.
Rejected,
/// Transient network / RPC issue. The transfer may or may not
/// have been broadcast; caller queries status with the same
/// idempotency key to find out.
Transient,
}
impl CryptoStatus {
/// True iff settlement is confirmed.
#[must_use]
pub const fn funds_moved(self) -> bool {
matches!(self, Self::Finalized)
}
/// True iff a retry of the same request is safe.
#[must_use]
pub const fn is_retryable(self) -> bool {
matches!(self, Self::Transient)
}
/// True iff the chain definitively refused.
#[must_use]
pub const fn is_failure(self) -> bool {
matches!(self, Self::Rejected)
}
/// True iff the caller should poll status.
#[must_use]
pub const fn needs_polling(self) -> bool {
matches!(self, Self::Pending | Self::Confirming)
}
}
/// The crypto-rail gateway interface. Each driver implements this
/// for a specific `(chain, token)` deployment.
pub trait CryptoGateway: Send + Sync {
/// Driver name (`"usdc-solana"`, `"usdc-base"`, ...). The
/// orchestrator's `PolicyRouter` keys off this.
fn name(&self) -> &'static str;
/// The chain this gateway services.
fn chain(&self) -> &str;
/// The token this gateway services.
fn token(&self) -> &TokenRef;
/// True iff this gateway can settle to `to`. A correct impl
/// returns `false` for any address whose chain doesn't match
/// the gateway's [`Self::chain`].
fn supports(&self, to: &CryptoAddress) -> bool {
to.chain == self.chain()
}
/// Broadcast a transfer.
///
/// # Errors
/// See [`crate::Error`].
fn submit_transfer(&self, req: &CryptoTransferReq) -> Result<CryptoDecision>;
/// Query the status of a previously broadcast transfer.
///
/// # Errors
/// See [`crate::Error`].
fn query_status(&self, req: &StatusQueryReq) -> Result<CryptoDecision>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_predicates_disjoint() {
for s in [
CryptoStatus::Pending,
CryptoStatus::Confirming,
CryptoStatus::Finalized,
CryptoStatus::Rejected,
CryptoStatus::Transient,
] {
assert!(!(s.funds_moved() && s.is_failure()));
assert!(!(s.funds_moved() && s.is_retryable()));
assert!(!(s.is_failure() && s.is_retryable()));
}
}
#[test]
fn only_finalized_moves_funds() {
assert!(CryptoStatus::Finalized.funds_moved());
for s in [
CryptoStatus::Pending,
CryptoStatus::Confirming,
CryptoStatus::Rejected,
CryptoStatus::Transient,
] {
assert!(!s.funds_moved());
}
}
#[test]
fn only_transient_is_retryable() {
assert!(CryptoStatus::Transient.is_retryable());
for s in [
CryptoStatus::Pending,
CryptoStatus::Confirming,
CryptoStatus::Finalized,
CryptoStatus::Rejected,
] {
assert!(!s.is_retryable());
}
}
#[test]
fn polling_only_for_pending_states() {
assert!(CryptoStatus::Pending.needs_polling());
assert!(CryptoStatus::Confirming.needs_polling());
for s in [
CryptoStatus::Finalized,
CryptoStatus::Rejected,
CryptoStatus::Transient,
] {
assert!(!s.needs_polling());
}
}
}