Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ members = [
"crates/ingest",
"crates/api",
"crates/resilience",
"crates/chain",
"bin/server",
"bin/migrate-keys",
"bin/backfill-operation-index",
Expand Down Expand Up @@ -59,6 +60,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "1"
anyhow = "1"
async-trait = "0.1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
rand = "0.8"
Expand All @@ -73,6 +75,7 @@ octo-email = { path = "crates/email" }
octo-ingest = { path = "crates/ingest" }
octo-api = { path = "crates/api" }
octo-resilience = { path = "crates/resilience" }
octo-chain = { path = "crates/chain" }

[profile.release]
lto = "thin"
Expand Down
20 changes: 20 additions & 0 deletions crates/chain/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]
name = "octo-chain"
description = "Chain-agnostic boundary between Octo's business logic and any specific blockchain: the ChainAdapter trait, CAIP-2 chain identity, and a capability model."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true

[dependencies]
# Stellar is the first adapter; it forwards to wallet-core, never reimplements it.
octo-wallet-core.workspace = true
async-trait.workspace = true
thiserror.workspace = true
serde.workspace = true

[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] }

88 changes: 88 additions & 0 deletions crates/chain/src/adapter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//! The [`ChainAdapter`] trait: the boundary between Octo's business logic and any one chain.
//!
//! What belongs in an adapter vs. in business logic (see also `docs/architecture.md`):
//! - **Adapter**: chain identity, address grammar (validate/normalize), how a customer deposit
//! address is shaped for this chain, and how to turn a chain-native failure code into a
//! sentence a merchant can read. An adapter holds no business state — it is stateless per call
//! (any config it needs, e.g. which network, is fixed at construction).
//! - **Business logic** (`octo-api`, `octo-ingest`): what to *do* with a validated address or a
//! deposit — allocate a customer id, persist a row, fire a webhook, decide whether to credit a
//! deposit yet. None of that is chain-specific, so none of it belongs behind this trait.
//! - **Never in an adapter**: raw key material. Adapters call out to a chain's own signing crate
//! (e.g. `octo-wallet-core` for Stellar) exactly like business logic would — `octo-chain` itself
//! never depends on `octo-crypto` and never sees a seed or private key.

use crate::capabilities::ChainCapabilities;
use crate::error::ChainError;
use crate::id::ChainId;
use async_trait::async_trait;

/// Both forms of a customer deposit address for one chain adapter.
///
/// This generalises Stellar's muxed (`M...`) + `G...`-plus-memo pair
/// ([`octo_wallet_core::DepositAddress`]) to any chain. A chain without
/// [`ChainCapabilities::supports_muxed_addresses`] has no `fallback` — see that field's docs.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DepositAddress {
/// The address to hand to the customer by default.
pub primary: String,
/// A fallback destination + memo for senders that can't address `primary` directly (e.g. an
/// exchange that can't send to a Stellar muxed address). `None` on chains with no such
/// fallback — those chains must derive a distinct address per customer instead.
pub fallback: Option<DepositFallback>,
}

/// The base-address-plus-memo fallback half of a [`DepositAddress`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DepositFallback {
/// The chain's plain (non-muxed) base address.
pub address: String,
/// The numeric memo/tag that attributes a payment to `address` back to the same customer id
/// as `DepositAddress::primary`.
pub memo: String,
}

/// The trait boundary between Octo's business logic and one specific chain.
///
/// Object-safe by construction (`async_trait`, no generic methods) so it can be stored as
/// `Arc<dyn ChainAdapter>` — required because `AppState` is cloned across every Axum handler and
/// `octo-ingest`'s supervisor spawns one task per wallet, both of which need `Send + Sync +
/// 'static` shared ownership.
///
/// Implementations must be pure with respect to business state: an adapter call may talk to its
/// own chain (or, for Stellar today, simply forward to `octo-wallet-core`), but must never read
/// or write Octo's store directly.
#[async_trait]
pub trait ChainAdapter: Send + Sync + 'static {
/// This adapter's CAIP-2 chain identity (AD-1). Fixed at construction — never varies per call.
fn chain_id(&self) -> &ChainId;

/// What this adapter's chain can and cannot do. Fixed at construction.
fn capabilities(&self) -> ChainCapabilities;

/// Check that `address` is a well-formed address on this chain, in any form the chain
/// accepts (e.g. for Stellar, both plain `G...` and muxed `M...`). Must return quickly and
/// without I/O — this checks grammar, not on-chain existence.
async fn validate_address(&self, address: &str) -> Result<(), ChainError>;

/// Reduce `address` to its canonical base form, whether given in any address form the chain
/// accepts. Two addresses that name the same underlying account/key must normalize to the
/// same string — callers that compare destinations (e.g. a withdrawal allowlist) rely on
/// this. Generalises [`octo_wallet_core::to_base_account`].
async fn normalize_address(&self, address: &str) -> Result<String, ChainError>;

/// Build a customer deposit address from this wallet's chain-specific base identity (for
/// Stellar, the master account's `G...` address) and a per-customer `id`. Deterministic: the
/// same `(base_identity, id)` pair must always produce the same [`DepositAddress`].
/// Generalises [`octo_wallet_core::deposit_address`].
async fn derive_deposit_address(
&self,
base_identity: &str,
customer_id: u64,
) -> Result<DepositAddress, ChainError>;

/// Map a chain-specific result/error code into a sentence a merchant dashboard can show
/// verbatim. Must never fail or panic — an unrecognised code still gets a generic-but-honest
/// explanation. Generalises `explain_code` (`crates/api/src/routes/submit.rs`).
async fn explain_failure(&self, code: &str) -> String;
}
44 changes: 44 additions & 0 deletions crates/chain/src/capabilities.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//! What kind of chain an adapter speaks for, and what it can and cannot do.
//!
//! [`ChainAdapter`](crate::ChainAdapter) is deliberately a small required core plus a
//! capability description — not the union of every field Stellar and EVM chains need. Callers
//! branch on `capabilities()`, not on `kind()`, wherever the behaviour difference is about a
//! capability (e.g. "does this chain have memos") rather than the chain family itself.

/// Which chain family an adapter implements.
///
/// Business logic should prefer branching on [`ChainCapabilities`] over this enum — `kind()`
/// exists for diagnostics, metrics, and the rare case where behaviour genuinely depends on the
/// chain family rather than a specific capability.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ChainKind {
/// Stellar and Stellar-compatible networks (pubnet, testnet, standalone).
Stellar,
/// EVM-compatible chains (Ethereum L1 and its L2s), identified via CAIP-2 `eip155:*`.
Evm,
}

/// What an adapter's chain can and cannot do.
///
/// Fields describe capabilities, not chain identity — a new field should only be added when a
/// behaviour genuinely varies per-chain and callers need to branch on it. Do not add fields that
/// are always true for one `ChainKind` and always false for another; that's what `kind()` is for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ChainCapabilities {
/// The chain family this capability set describes.
pub kind: ChainKind,
/// Whether the chain supports an out-of-band numeric memo alongside a plain address as a
/// deposit-attribution fallback (Stellar: yes, via `MEMO_ID`; EVM: no equivalent).
pub supports_memo: bool,
/// Whether the chain has an address format that embeds a sub-account id in a single account
/// (Stellar: muxed `M...` addresses). When `false`,
/// [`ChainAdapter::derive_deposit_address`](crate::ChainAdapter::derive_deposit_address)
/// must derive a distinct on-chain address per customer instead of a shared base + id.
pub supports_muxed_addresses: bool,
/// Whether a transaction the chain reports as final can later be reversed by a reorg.
/// Stellar: `false` (instant finality). EVM: `true` — ingest must gate crediting on
/// confirmation depth.
pub has_reorgs: bool,
/// Decimal places of the chain's native asset (Stellar stroops: 7; ETH wei: 18).
pub native_decimals: u8,
}
160 changes: 160 additions & 0 deletions crates/chain/src/conformance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//! A reusable [`ChainAdapter`] conformance harness.
//!
//! Every adapter — Stellar today, EVM in a future issue — must behave consistently with its own
//! declared [`ChainCapabilities`] and satisfy the same basic contracts (idempotent normalization,
//! deterministic derivation, no panics on garbage input). Rather than each adapter crate
//! reinventing those checks, it calls [`chain_conformance_suite`] with chain-specific test
//! vectors. This is deliberately not gated behind `cfg(test)` — a downstream adapter crate (e.g.
//! the future `octo-chain-evm`) needs to call it from its own tests, which only sees this crate's
//! public, non-test API.

use crate::adapter::ChainAdapter;
use crate::capabilities::ChainCapabilities;

/// Chain-specific fixtures for [`chain_conformance_suite`].
pub struct ConformanceVectors<'a> {
/// A well-formed address on this chain, in its most common form.
pub valid_address: &'a str,
/// The same underlying address/account as `valid_address`, but in a different valid form
/// (e.g. Stellar's muxed `M...` vs. plain `G...`), if the chain has more than one. `None` for
/// chains with a single canonical address form.
pub valid_address_alt_form: Option<&'a str>,
/// A string that is not a valid address on this chain in any form.
pub invalid_address: &'a str,
/// The chain-specific base identity to derive a deposit address from (for Stellar, a `G...`
/// master account).
pub base_identity: &'a str,
/// A customer id to derive a deposit address for.
pub customer_id: u64,
/// A failure/result code the adapter is not expected to recognise.
pub unknown_failure_code: &'a str,
}

/// Run the shared adapter-honesty checks against `adapter` using `vectors`.
///
/// Panics (via `assert!`) on the first violation, so callers just invoke this from a
/// `#[tokio::test]` and let a failure surface as an ordinary test failure.
pub async fn chain_conformance_suite(adapter: &dyn ChainAdapter, vectors: ConformanceVectors<'_>) {
// chain_id: must be present and its Display form must round-trip through ChainId::parse
// (guaranteed by construction, but a smoke check catches an adapter that somehow bypassed it).
let id = adapter.chain_id();
assert!(!id.as_str().is_empty(), "chain_id must not be empty");
assert_eq!(
crate::ChainId::parse(id.as_str()).as_ref(),
Ok(id),
"chain_id() must itself be a valid CAIP-2 id"
);

// capabilities: must be a coherent, non-panicking call. native_decimals has no hard bound to
// assert beyond "the call succeeds" — different chains legitimately vary widely (Stellar: 7,
// ETH: 18).
let caps: ChainCapabilities = adapter.capabilities();

// validate_address: the valid vector must pass, the invalid vector must fail, and doing so
// must not panic on adversarial-looking input either.
assert!(
adapter
.validate_address(vectors.valid_address)
.await
.is_ok(),
"valid_address vector must validate"
);
assert!(
adapter
.validate_address(vectors.invalid_address)
.await
.is_err(),
"invalid_address vector must be rejected"
);
for garbage in ["", " ", "\0", "not valid at all!!"] {
// Must return an error, not panic — the assertion is just that this line is reached.
let _ = adapter.validate_address(garbage).await;
}
if let Some(alt) = vectors.valid_address_alt_form {
assert!(
adapter.validate_address(alt).await.is_ok(),
"valid_address_alt_form vector must validate"
);
}

// normalize_address: idempotent, and both forms of the same address must normalize to the
// same canonical string.
let normalized = adapter
.normalize_address(vectors.valid_address)
.await
.expect("valid_address must normalize");
let normalized_again = adapter
.normalize_address(&normalized)
.await
.expect("an already-normalized address must still normalize");
assert_eq!(
normalized, normalized_again,
"normalize_address must be idempotent"
);
if let Some(alt) = vectors.valid_address_alt_form {
let alt_normalized = adapter
.normalize_address(alt)
.await
.expect("valid_address_alt_form must normalize");
assert_eq!(
normalized, alt_normalized,
"two forms of the same address must normalize to the same canonical form"
);
}
assert!(
adapter
.normalize_address(vectors.invalid_address)
.await
.is_err(),
"normalize_address must reject an invalid address rather than passing it through"
);

// derive_deposit_address: deterministic, and shaped consistently with the adapter's own
// declared capabilities.
let first = adapter
.derive_deposit_address(vectors.base_identity, vectors.customer_id)
.await
.expect("derive_deposit_address must succeed for the given vectors");
let second = adapter
.derive_deposit_address(vectors.base_identity, vectors.customer_id)
.await
.expect("derive_deposit_address must succeed for the given vectors");
assert_eq!(
first, second,
"derive_deposit_address must be deterministic for the same inputs"
);
assert!(
!first.primary.is_empty(),
"primary address must not be empty"
);
if caps.supports_muxed_addresses {
assert!(
first.fallback.is_some(),
"a chain that supports muxed addresses must provide a base+memo fallback"
);
}
if let Some(fallback) = &first.fallback {
assert!(
!fallback.address.is_empty(),
"fallback address must not be empty"
);
assert!(!fallback.memo.is_empty(), "fallback memo must not be empty");
}
// Two distinct customer ids must not collide on the same primary address.
let other = adapter
.derive_deposit_address(vectors.base_identity, vectors.customer_id.wrapping_add(1))
.await
.expect("derive_deposit_address must succeed for a second customer id");
assert_ne!(
first.primary, other.primary,
"distinct customer ids must not derive the same primary address"
);

// explain_failure: never panics, always returns something non-empty, and an unrecognised
// code still gets an honest (non-empty) explanation rather than silently succeeding.
let explanation = adapter.explain_failure(vectors.unknown_failure_code).await;
assert!(
!explanation.is_empty(),
"explain_failure must not return an empty explanation, even for an unknown code"
);
}
32 changes: 32 additions & 0 deletions crates/chain/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//! Error type shared by [`crate::ChainId`] parsing and every [`crate::ChainAdapter`] method.

use thiserror::Error;

/// Errors returned by `octo-chain` types and adapters.
///
/// Like [`octo_wallet_core::WalletError`], variants describe the *kind* of failure without
/// carrying secret material — this crate never sees any.
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ChainError {
/// A [`crate::ChainId`] string did not conform to the CAIP-2 grammar
/// (`namespace:reference`, `namespace` = `[-a-z0-9]{3,8}`, `reference` = `[-_a-zA-Z0-9]{1,32}`).
#[error("invalid CAIP-2 chain id: {0:?}")]
InvalidChainId(String),

/// [`crate::ChainRegistry::get`] was asked for a chain id no adapter is registered for.
#[error("unsupported chain: {0}")]
UnsupportedChain(String),

/// An address string was not valid for the adapter's chain.
#[error("invalid address")]
InvalidAddress,

/// A signature failed to parse or did not verify.
#[error("invalid signature")]
InvalidSignature,

/// The adapter could not complete the request for a reason specific to its chain (e.g. a
/// malformed RPC response). Carries a short, non-secret description.
#[error("chain adapter error: {0}")]
Adapter(String),
}
Loading