From c2797b276cdac7281d37e86c1e4a8d38f235358e Mon Sep 17 00:00:00 2001 From: "Nuem.dev" <84929587+Manuel1234477@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:10:14 +0000 Subject: [PATCH] feat(chain): add ChainAdapter trait and Stellar adapter Introduces octo-chain as the boundary between business logic and chain-specific behaviour, with CAIP-2 chain identity and a capability model rather than a union-of-chains interface. Stellar becomes the first adapter, forwarding to octo-wallet-core with no behaviour change; the existing suite passes unmodified. Refs #213 --- Cargo.lock | 11 ++ Cargo.toml | 3 + crates/chain/Cargo.toml | 20 +++ crates/chain/src/adapter.rs | 88 ++++++++++++ crates/chain/src/capabilities.rs | 44 ++++++ crates/chain/src/conformance.rs | 160 ++++++++++++++++++++++ crates/chain/src/error.rs | 32 +++++ crates/chain/src/id.rs | 223 ++++++++++++++++++++++++++++++ crates/chain/src/lib.rs | 29 ++++ crates/chain/src/registry.rs | 137 +++++++++++++++++++ crates/chain/src/stellar.rs | 227 +++++++++++++++++++++++++++++++ docs/architecture.md | 55 ++++++++ 12 files changed, 1029 insertions(+) create mode 100644 crates/chain/Cargo.toml create mode 100644 crates/chain/src/adapter.rs create mode 100644 crates/chain/src/capabilities.rs create mode 100644 crates/chain/src/conformance.rs create mode 100644 crates/chain/src/error.rs create mode 100644 crates/chain/src/id.rs create mode 100644 crates/chain/src/lib.rs create mode 100644 crates/chain/src/registry.rs create mode 100644 crates/chain/src/stellar.rs diff --git a/Cargo.lock b/Cargo.lock index c35d45e..16a0b47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1753,6 +1753,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "octo-chain" +version = "0.1.0" +dependencies = [ + "async-trait", + "octo-wallet-core", + "serde", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "octo-crypto" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index f8fb362..83e0856 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/ingest", "crates/api", "crates/resilience", + "crates/chain", "bin/server", "bin/migrate-keys", "bin/backfill-operation-index", @@ -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" @@ -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" diff --git a/crates/chain/Cargo.toml b/crates/chain/Cargo.toml new file mode 100644 index 0000000..a9bc0a7 --- /dev/null +++ b/crates/chain/Cargo.toml @@ -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"] } + diff --git a/crates/chain/src/adapter.rs b/crates/chain/src/adapter.rs new file mode 100644 index 0000000..e96ecdb --- /dev/null +++ b/crates/chain/src/adapter.rs @@ -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, +} + +/// 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` — 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; + + /// 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; + + /// 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; +} diff --git a/crates/chain/src/capabilities.rs b/crates/chain/src/capabilities.rs new file mode 100644 index 0000000..7b6e8b1 --- /dev/null +++ b/crates/chain/src/capabilities.rs @@ -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, +} diff --git a/crates/chain/src/conformance.rs b/crates/chain/src/conformance.rs new file mode 100644 index 0000000..76347cf --- /dev/null +++ b/crates/chain/src/conformance.rs @@ -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" + ); +} diff --git a/crates/chain/src/error.rs b/crates/chain/src/error.rs new file mode 100644 index 0000000..ba4ebc0 --- /dev/null +++ b/crates/chain/src/error.rs @@ -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), +} diff --git a/crates/chain/src/id.rs b/crates/chain/src/id.rs new file mode 100644 index 0000000..cad3320 --- /dev/null +++ b/crates/chain/src/id.rs @@ -0,0 +1,223 @@ +//! CAIP-2 chain identity (`namespace:reference`), e.g. `stellar:pubnet`, `eip155:1`. +//! +//! See (AD-1 in `docs/ethereum-expansion-issues.md`). +//! Octo uses this as the sole chain-identity format across the workspace, so a `ChainId` is +//! always validated on construction — nothing downstream needs to re-check the grammar. + +use crate::error::ChainError; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +/// Namespace length bounds per the CAIP-2 spec regex `[-a-z0-9]{3,8}`. +const NAMESPACE_LEN: std::ops::RangeInclusive = 3..=8; +/// Reference length bounds per the CAIP-2 spec regex `[-_a-zA-Z0-9]{1,32}`. +const REFERENCE_LEN: std::ops::RangeInclusive = 1..=32; + +/// A validated CAIP-2 chain identifier, e.g. `"stellar:pubnet"` or `"eip155:1"`. +/// +/// Construction always validates against the CAIP-2 grammar — there is no way to build a +/// `ChainId` holding a malformed slug. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ChainId(String); + +impl ChainId { + /// Octo's Stellar public (mainnet) network. + pub const STELLAR_PUBNET: &'static str = "stellar:pubnet"; + /// Octo's Stellar test network. + pub const STELLAR_TESTNET: &'static str = "stellar:testnet"; + /// Octo's local Stellar standalone (quickstart) network. + pub const STELLAR_STANDALONE: &'static str = "stellar:standalone"; + + /// Parse and validate a CAIP-2 chain id string. + /// + /// Rejects: no `:` separator, an empty or over/under-length namespace, an over-length + /// reference, and any character outside the CAIP-2 alphabets (namespace: lowercase ASCII + /// letters, digits, `-`; reference: ASCII letters, digits, `-`, `_`). + pub fn parse(s: &str) -> Result { + let (namespace, reference) = s + .split_once(':') + .ok_or_else(|| ChainError::InvalidChainId(s.to_string()))?; + + let namespace_ok = NAMESPACE_LEN.contains(&namespace.len()) + && namespace + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + let reference_ok = REFERENCE_LEN.contains(&reference.len()) + && reference + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); + + if !namespace_ok || !reference_ok { + return Err(ChainError::InvalidChainId(s.to_string())); + } + + Ok(ChainId(s.to_string())) + } + + /// The full `namespace:reference` slug. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The CAIP-2 namespace (e.g. `"stellar"`, `"eip155"`). + pub fn namespace(&self) -> &str { + // Always present and non-empty: guaranteed by `parse`'s split_once + length check. + self.0.split_once(':').map(|(ns, _)| ns).unwrap_or("") + } + + /// The CAIP-2 reference (e.g. `"pubnet"`, `"1"`). + pub fn reference(&self) -> &str { + self.0.split_once(':').map(|(_, r)| r).unwrap_or("") + } +} + +impl fmt::Display for ChainId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl FromStr for ChainId { + type Err = ChainError; + + fn from_str(s: &str) -> Result { + ChainId::parse(s) + } +} + +impl TryFrom for ChainId { + type Error = ChainError; + + fn try_from(s: String) -> Result { + ChainId::parse(&s) + } +} + +impl TryFrom<&str> for ChainId { + type Error = ChainError; + + fn try_from(s: &str) -> Result { + ChainId::parse(s) + } +} + +impl From for String { + fn from(id: ChainId) -> String { + id.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Official CAIP-2 examples: https://chainagnostic.org/CAIPs/caip-2 + #[test] + fn accepts_caip2_spec_examples() { + for valid in [ + "eip155:1", + "bip122:000000000019d6689c085ae165831e93", + "bip122:12a765e31ffd4059bada1e25190f6e98", + "cosmos:cosmoshub-3", + "cosmos:Binance-Chain-Tigris", + "polkadot:b0a8d493285c2df73290dfb7e61f870f", + "chainstd:8c3444cf8970a9e41a706fab93e7a6c4", + ] { + let id = ChainId::parse(valid).unwrap_or_else(|e| panic!("{valid:?} rejected: {e}")); + assert_eq!(id.as_str(), valid); + } + } + + #[test] + fn accepts_octo_stellar_ids() { + for valid in [ + ChainId::STELLAR_PUBNET, + ChainId::STELLAR_TESTNET, + ChainId::STELLAR_STANDALONE, + ] { + assert!(ChainId::parse(valid).is_ok(), "{valid} should be valid"); + } + } + + #[test] + fn namespace_and_reference_split_correctly() { + let id = ChainId::parse("eip155:8453").unwrap(); + assert_eq!(id.namespace(), "eip155"); + assert_eq!(id.reference(), "8453"); + } + + #[test] + fn rejects_missing_separator() { + assert!(matches!( + ChainId::parse("nocolonatall"), + Err(ChainError::InvalidChainId(_)) + )); + assert!(ChainId::parse("").is_err()); + } + + #[test] + fn rejects_empty_namespace() { + assert!(ChainId::parse(":1").is_err()); + } + + #[test] + fn rejects_under_length_namespace() { + // Namespace must be at least 3 chars. + assert!(ChainId::parse("ab:1").is_err()); + } + + #[test] + fn rejects_over_length_namespace() { + // Namespace must be at most 8 chars — this one is 9. + assert!(ChainId::parse("toolongns:1").is_err()); + } + + #[test] + fn rejects_over_length_reference() { + // Reference must be at most 32 chars — this one is 33. + let over = "a".repeat(33); + assert!(ChainId::parse(&format!("eip155:{over}")).is_err()); + // Exactly 32 is still valid. + let max = "a".repeat(32); + assert!(ChainId::parse(&format!("eip155:{max}")).is_ok()); + } + + #[test] + fn rejects_empty_reference() { + assert!(ChainId::parse("eip155:").is_err()); + } + + #[test] + fn rejects_invalid_characters() { + // Uppercase is not in the namespace alphabet. + assert!(ChainId::parse("EIP155:1").is_err()); + // Space is in neither alphabet. + assert!(ChainId::parse("eip155:has space").is_err()); + // A second colon puts ':' into what would be the reference — not in its alphabet. + assert!(ChainId::parse("eip155:1:2").is_err()); + // Underscore is not valid in the namespace alphabet (only reference allows it). + assert!(ChainId::parse("eip_155:1").is_err()); + } + + #[test] + fn display_and_fromstr_roundtrip() { + let id: ChainId = "stellar:pubnet".parse().unwrap(); + assert_eq!(id.to_string(), "stellar:pubnet"); + assert_eq!(id, ChainId::parse("stellar:pubnet").unwrap()); + } + + #[test] + fn ordering_and_hashing_are_by_slug() { + use std::collections::HashSet; + let a = ChainId::parse("eip155:1").unwrap(); + let b = ChainId::parse("stellar:pubnet").unwrap(); + assert!(a < b, "eip155:1 sorts before stellar:pubnet lexically"); + + let mut set = HashSet::new(); + set.insert(a.clone()); + assert!(set.contains(&a)); + assert!(!set.contains(&b)); + } +} diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs new file mode 100644 index 0000000..cb20600 --- /dev/null +++ b/crates/chain/src/lib.rs @@ -0,0 +1,29 @@ +//! The boundary between Octo's business logic and any specific blockchain. +//! +//! `octo-api` and `octo-ingest` should depend on [`ChainAdapter`] and [`ChainId`], never on a +//! chain-specific crate or format (Stellar's XDR, muxed addresses, EVM's checksum addresses, ...) +//! directly. Stellar is the first adapter ([`stellar::StellarAdapter`]); it is a thin forwarding +//! layer over `octo-wallet-core` — this crate reimplements none of Stellar's address or signing +//! logic. +//! +//! See `docs/architecture.md` for the full adapter-vs-business-logic boundary, and +//! [`ChainAdapter`]'s own docs for what each method's caller may assume. +//! +//! Security: this crate never depends on `octo-crypto` and never touches raw key material — +//! adapters hold secrets (by delegating to a chain's own signing crate), this crate only defines +//! shapes. +#![forbid(unsafe_code)] + +mod adapter; +mod capabilities; +pub mod conformance; +mod error; +mod id; +mod registry; +pub mod stellar; + +pub use adapter::{ChainAdapter, DepositAddress, DepositFallback}; +pub use capabilities::{ChainCapabilities, ChainKind}; +pub use error::ChainError; +pub use id::ChainId; +pub use registry::ChainRegistry; diff --git a/crates/chain/src/registry.rs b/crates/chain/src/registry.rs new file mode 100644 index 0000000..c53e41f --- /dev/null +++ b/crates/chain/src/registry.rs @@ -0,0 +1,137 @@ +//! A lookup table from [`ChainId`] to the adapter that handles it. + +use crate::adapter::ChainAdapter; +use crate::error::ChainError; +use crate::id::ChainId; +use std::collections::HashMap; +use std::sync::Arc; + +/// Maps each configured [`ChainId`] to its [`ChainAdapter`]. +/// +/// `Arc` (not a bare adapter) so the registry, and each adapter it hands out, +/// can be cloned cheaply into `AppState` and per-wallet ingest tasks alike. +#[derive(Clone, Default)] +pub struct ChainRegistry { + adapters: HashMap>, +} + +impl ChainRegistry { + /// An empty registry. + pub fn new() -> Self { + Self::default() + } + + /// Register `adapter` under its own [`ChainAdapter::chain_id`]. Replaces any adapter + /// previously registered for that id, returning it. + pub fn register(&mut self, adapter: Arc) -> Option> { + self.adapters.insert(adapter.chain_id().clone(), adapter) + } + + /// Look up the adapter for `id`. + /// + /// Never panics on a miss — a chain id from external input (e.g. an API request) that isn't + /// configured is an ordinary, expected error, not a bug. + pub fn get(&self, id: &ChainId) -> Result, ChainError> { + self.adapters + .get(id) + .cloned() + .ok_or_else(|| ChainError::UnsupportedChain(id.to_string())) + } + + /// Every chain id currently registered. + pub fn chain_ids(&self) -> impl Iterator { + self.adapters.keys() + } + + /// How many adapters are registered. + pub fn len(&self) -> usize { + self.adapters.len() + } + + /// Whether no adapters are registered. + pub fn is_empty(&self) -> bool { + self.adapters.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adapter::DepositAddress; + use crate::capabilities::{ChainCapabilities, ChainKind}; + use async_trait::async_trait; + + struct StubAdapter(ChainId); + + #[async_trait] + impl ChainAdapter for StubAdapter { + fn chain_id(&self) -> &ChainId { + &self.0 + } + fn capabilities(&self) -> ChainCapabilities { + ChainCapabilities { + kind: ChainKind::Stellar, + supports_memo: true, + supports_muxed_addresses: true, + has_reorgs: false, + native_decimals: 7, + } + } + async fn validate_address(&self, _address: &str) -> Result<(), ChainError> { + Ok(()) + } + async fn normalize_address(&self, address: &str) -> Result { + Ok(address.to_string()) + } + async fn derive_deposit_address( + &self, + _base_identity: &str, + _customer_id: u64, + ) -> Result { + unimplemented!("not exercised by registry tests") + } + async fn explain_failure(&self, code: &str) -> String { + code.to_string() + } + } + + #[test] + fn lookup_returns_registered_adapter() { + let mut registry = ChainRegistry::new(); + let id = ChainId::parse("stellar:testnet").unwrap(); + registry.register(Arc::new(StubAdapter(id.clone()))); + + let found = registry.get(&id).unwrap(); + assert_eq!(found.chain_id(), &id); + } + + #[test] + fn lookup_of_unregistered_chain_errors_not_panics() { + let registry = ChainRegistry::new(); + let id = ChainId::parse("eip155:1").unwrap(); + assert!(matches!( + registry.get(&id), + Err(ChainError::UnsupportedChain(s)) if s == "eip155:1" + )); + } + + #[test] + fn registering_same_chain_id_replaces_and_returns_previous() { + let mut registry = ChainRegistry::new(); + let id = ChainId::parse("stellar:testnet").unwrap(); + let first = Arc::new(StubAdapter(id.clone())); + let second = Arc::new(StubAdapter(id.clone())); + + assert!(registry.register(first).is_none()); + let replaced = registry.register(second); + assert!(replaced.is_some()); + assert_eq!(registry.len(), 1); + } + + #[test] + fn empty_registry_reports_empty() { + let registry = ChainRegistry::new(); + assert!(registry.is_empty()); + assert_eq!(registry.chain_ids().count(), 0); + } +} diff --git a/crates/chain/src/stellar.rs b/crates/chain/src/stellar.rs new file mode 100644 index 0000000..232e709 --- /dev/null +++ b/crates/chain/src/stellar.rs @@ -0,0 +1,227 @@ +//! The Stellar [`ChainAdapter`] — a thin forwarding layer over `octo-wallet-core`. +//! +//! Every method here delegates to an existing `octo-wallet-core` function; none reimplements +//! Stellar address/muxed-address logic. That is deliberate (see the parent issue): this crate +//! must not fork behaviour that already lives, tested, in `wallet-core`. + +use crate::adapter::{ChainAdapter, DepositAddress, DepositFallback}; +use crate::capabilities::{ChainCapabilities, ChainKind}; +use crate::error::ChainError; +use crate::id::ChainId; +use async_trait::async_trait; +use octo_wallet_core::StellarNetwork; + +/// Stellar's native asset (XLM) is denominated in stroops: 1 XLM = 10^7 stroops. +const STELLAR_NATIVE_DECIMALS: u8 = 7; + +/// The Stellar [`ChainAdapter`]. Stateless beyond its fixed [`ChainId`] — every method forwards +/// to the corresponding free function in `octo_wallet_core`. +pub struct StellarAdapter { + chain_id: ChainId, +} + +impl StellarAdapter { + /// Build the adapter for `network`. The chain id is derived once, at construction, from + /// [`StellarNetwork`] — callers never pass a chain id string themselves. + pub fn new(network: StellarNetwork) -> Self { + let slug = match network { + StellarNetwork::Public => ChainId::STELLAR_PUBNET, + StellarNetwork::Testnet => ChainId::STELLAR_TESTNET, + StellarNetwork::Standalone => ChainId::STELLAR_STANDALONE, + }; + // The three slugs above are fixed, workspace-wide constants already covered by + // crate::id's own parse tests — a parse failure here would be a programming error in + // this crate, not bad external input, so unwrap is appropriate. + #[allow(clippy::expect_used)] + let chain_id = + ChainId::parse(slug).expect("Octo's own Stellar chain-id slugs are valid CAIP-2"); + Self { chain_id } + } +} + +#[async_trait] +impl ChainAdapter for StellarAdapter { + fn chain_id(&self) -> &ChainId { + &self.chain_id + } + + fn capabilities(&self) -> ChainCapabilities { + ChainCapabilities { + kind: ChainKind::Stellar, + supports_memo: true, + supports_muxed_addresses: true, + has_reorgs: false, + native_decimals: STELLAR_NATIVE_DECIMALS, + } + } + + async fn validate_address(&self, address: &str) -> Result<(), ChainError> { + if octo_wallet_core::is_valid_account(address) + || octo_wallet_core::decode_muxed(address).is_ok() + { + Ok(()) + } else { + Err(ChainError::InvalidAddress) + } + } + + async fn normalize_address(&self, address: &str) -> Result { + octo_wallet_core::to_base_account(address).map_err(|_| ChainError::InvalidAddress) + } + + async fn derive_deposit_address( + &self, + base_identity: &str, + customer_id: u64, + ) -> Result { + let addr = octo_wallet_core::deposit_address(base_identity, customer_id) + .map_err(|_| ChainError::InvalidAddress)?; + Ok(DepositAddress { + primary: addr.muxed_address, + fallback: Some(DepositFallback { + address: addr.base_address, + memo: addr.memo_id.to_string(), + }), + }) + } + + /// Ported verbatim from `explain_code` in `crates/api/src/routes/submit.rs` — see that + /// module for the Horizon result-code reference this mirrors. + async fn explain_failure(&self, code: &str) -> String { + match code { + "op_underfunded" => "Insufficient balance to cover this amount.".into(), + "op_low_reserve" => { + "Not enough XLM to satisfy the base reserve (each trustline/subentry reserves 0.5 XLM)." + .into() + } + "op_no_destination" => "The destination account does not exist on this network.".into(), + "op_no_trust" => { + "The destination has no trustline for this asset — they must add one first.".into() + } + "op_no_issuer" => "The asset issuer does not exist on this network.".into(), + "op_invalid_limit" => "The trust limit is invalid.".into(), + "op_line_full" => "The destination's trustline limit would be exceeded.".into(), + "tx_bad_seq" => { + "Stale sequence number — refresh signing info and rebuild the transaction.".into() + } + "tx_bad_auth" => { + "Signature verification failed — the transaction was not signed by this wallet's key." + .into() + } + "tx_insufficient_fee" => "The network fee was too low; rebuild with a higher fee.".into(), + other => format!("Transaction failed ({other})."), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conformance::{chain_conformance_suite, ConformanceVectors}; + + // The SEP-0005 Test 1 vector account, also used in crates/wallet-core/src/address.rs and + // crates/wallet-core/src/derive.rs — kept identical so this adapter's tests stay anchored to + // the same fixture as the code it forwards to. + const BASE: &str = "GDRXE2BQUC3AZNPVFSCEZ76NJ3WWL25FYFK6RGZGIEKWE4SOOHSUJUJ6"; + + #[test] + fn chain_id_matches_network() { + assert_eq!( + StellarAdapter::new(StellarNetwork::Public) + .chain_id() + .as_str(), + "stellar:pubnet" + ); + assert_eq!( + StellarAdapter::new(StellarNetwork::Testnet) + .chain_id() + .as_str(), + "stellar:testnet" + ); + assert_eq!( + StellarAdapter::new(StellarNetwork::Standalone) + .chain_id() + .as_str(), + "stellar:standalone" + ); + } + + #[test] + fn capabilities_reflect_stellar() { + let caps = StellarAdapter::new(StellarNetwork::Testnet).capabilities(); + assert_eq!(caps.kind, ChainKind::Stellar); + assert!(caps.supports_memo); + assert!(caps.supports_muxed_addresses); + assert!(!caps.has_reorgs); + assert_eq!(caps.native_decimals, 7); + } + + /// Byte-identical to calling `octo_wallet_core::deposit_address` directly — the adapter must + /// add zero behaviour of its own. + #[tokio::test] + async fn derive_deposit_address_matches_wallet_core_directly() { + let adapter = StellarAdapter::new(StellarNetwork::Testnet); + let direct = octo_wallet_core::deposit_address(BASE, 42).unwrap(); + + let via_adapter = adapter.derive_deposit_address(BASE, 42).await.unwrap(); + + assert_eq!(via_adapter.primary, direct.muxed_address); + let fallback = via_adapter + .fallback + .expect("Stellar always has a memo fallback"); + assert_eq!(fallback.address, direct.base_address); + assert_eq!(fallback.memo, direct.memo_id.to_string()); + } + + #[tokio::test] + async fn validate_and_normalize_match_wallet_core_directly() { + let adapter = StellarAdapter::new(StellarNetwork::Testnet); + let muxed = octo_wallet_core::encode_muxed(BASE, 7).unwrap(); + + assert!(adapter.validate_address(BASE).await.is_ok()); + assert!(adapter.validate_address(&muxed).await.is_ok()); + assert!(adapter.validate_address("not-an-address").await.is_err()); + + assert_eq!( + adapter.normalize_address(&muxed).await.unwrap(), + octo_wallet_core::to_base_account(&muxed).unwrap() + ); + assert_eq!( + adapter.normalize_address(BASE).await.unwrap(), + octo_wallet_core::to_base_account(BASE).unwrap() + ); + } + + #[tokio::test] + async fn explain_failure_matches_known_and_unknown_codes() { + let adapter = StellarAdapter::new(StellarNetwork::Testnet); + assert_eq!( + adapter.explain_failure("op_underfunded").await, + "Insufficient balance to cover this amount." + ); + assert_eq!( + adapter.explain_failure("op_totally_made_up").await, + "Transaction failed (op_totally_made_up)." + ); + } + + /// The reusable adapter-conformance harness #217's EVM adapter will also be required to + /// pass. Running it here proves the Stellar adapter itself is honest. + #[tokio::test] + async fn passes_the_chain_conformance_suite() { + let adapter = StellarAdapter::new(StellarNetwork::Testnet); + let muxed = octo_wallet_core::encode_muxed(BASE, 1).unwrap(); + chain_conformance_suite( + &adapter, + ConformanceVectors { + valid_address: BASE, + valid_address_alt_form: Some(&muxed), + invalid_address: "not-a-real-address", + base_identity: BASE, + customer_id: 99, + unknown_failure_code: "totally_unrecognized_code", + }, + ) + .await; + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 281c910..a9e0776 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,6 +12,9 @@ crates/ - SEP-0005 (SLIP-0010 ed25519) derivation: m/44'/148'/' - muxed address (M...) encode/decode - build + sign fee-bump envelopes, then zeroize + chain/ Chain-agnostic boundary: the ChainAdapter trait, CAIP-2 ChainId, and a capability + model. Stellar is the first adapter — a thin forwarding layer over wallet-core. + See "The ChainAdapter boundary" below. resilience/ Retry with backoff + circuit breaker for outbound Horizon calls. store/ Postgres models + migrations (sqlx). webhooks/ HMAC-SHA256 signed outbound webhooks with retry + delivery log. @@ -77,3 +80,55 @@ server, and it is confined to one crate: Keys are never written to disk or logs and are never persisted in derived form. Worst-case exposure of this key is the gas budget — never customer balances. + +## The ChainAdapter boundary + +octo started Stellar-only, which let chain-specific concepts leak directly to callers: +`crates/api/src/state.rs` imports `StellarNetwork`, `crates/ingest/src/lib.rs` calls +`decode_muxed`, and route handlers reason about XDR. That's fine for one chain; it does not scale +to a second one (see `docs/ethereum-expansion-issues.md`) without a `match` on chain kind spreading +into every call site. `octo-chain` is the fix: a `ChainAdapter` trait that `octo-api` and +`octo-ingest` are meant to depend on instead of any one chain's crate or wire format. + +**What belongs in an adapter:** +- Chain identity (`ChainId`, CAIP-2 — see AD-1 in `docs/ethereum-expansion-issues.md`) and what + the chain can do (`ChainCapabilities`: memos, muxed addresses, reorgs, native decimals). +- Address grammar: validating a string is a well-formed address, and normalizing the different + valid forms of "the same" address to one canonical form. +- Shaping a customer deposit address for that chain (`DepositAddress`): a single muxed-style + address for chains that support it, or the seam where a chain without that capability must + instead derive a distinct on-chain address per customer. +- Translating a chain-native failure/result code into a sentence a merchant dashboard can show. +- Calling out to that chain's own signing/derivation crate (for Stellar, `octo-wallet-core`) to do + the above — never reimplementing chain logic that already exists elsewhere. + +**What belongs in business logic (`octo-api`, `octo-ingest`), not an adapter:** +- What to *do* with a validated address or a derived deposit address: allocating a customer id, + persisting a row, firing a webhook, deciding when a deposit is safe to credit. None of that + varies by chain in a way the adapter needs to know about. +- Anything involving Octo's own store, webhooks, or email — an adapter never touches `octo-store` + directly. + +**What must never be in `octo-chain`:** raw key material, or a dependency on `octo-crypto`. An +adapter holds secrets the same way business logic would — by delegating to a chain's own signing +crate — never by embedding key handling in this crate. `octo-chain` defines shapes; adapters +supply behaviour. + +The trait is a small required core plus a capability description +(`fn capabilities(&self) -> ChainCapabilities`), not the union of every field Stellar and EVM need +— Stellar has muxed addresses and no reorgs, EVM has reorgs and no memos, and a trait that unions +both would rot as more chains are added. It is `Send + Sync + 'static` and object-safe +(`Arc`), since `AppState` is cloned across every Axum handler and +`octo-ingest`'s supervisor spawns one task per wallet. A `ChainRegistry` maps each configured +`ChainId` to its adapter, returning `ChainError::UnsupportedChain` rather than panicking on an +unconfigured chain. + +`crates/chain/src/conformance.rs` is a reusable test harness (`chain_conformance_suite`) that +checks an adapter behaves consistently with its own declared capabilities — deterministic +derivation, idempotent normalization, no panics on garbage input. Every adapter, including the +future EVM one, is expected to pass it. + +This issue lands the trait and the Stellar adapter only, as a pure refactor — `octo-api` and +`octo-ingest` still call `octo-wallet-core` directly today. Wiring `AppState` and the ingest +supervisor through a `ChainRegistry` is follow-up work once a second adapter exists to prove the +boundary is right, not something to guess at with only one chain implemented.