diff --git a/.env.example b/.env.example index 1d7367c..8ef1836 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,21 @@ # Copy to `.env` and fill in. NEVER commit `.env`. -# --- Network --- +# --- Chains --- +# Multi-chain configuration precedence (see octo.chains.example.toml and +# docs/architecture.md's "Per-chain configuration" section for the full picture): +# 1. CHAIN_CONFIG_PATH, if set, MUST name an existing TOML file (a typo'd path aborts boot, +# it does not silently fall back) describing one or more [[chains]]. +# 2. Else, if ./octo.chains.toml exists in the working directory, it's used automatically. +# 3. Else, the single-chain NETWORK/HORIZON_URL/FRIENDBOT_URL/HORIZON_* vars below build one +# implicit Stellar chain — this is what today's single-chain deployments keep using +# unmodified. +# In all cases, a chain's `rpc_url` can be overridden per-deploy-environment (e.g. to inject a +# secret without putting it in the config file) via OCTO_CHAIN__RPC_URL, where +# is the chain's id upper-cased with every non-alphanumeric character replaced by `_` +# (e.g. chain id "stellar:testnet" -> OCTO_CHAIN_STELLAR_TESTNET_RPC_URL). +# CHAIN_CONFIG_PATH=octo.chains.toml + +# --- Network (legacy single-chain fallback; ignored when a chain config file is in effect) --- # Which Stellar network the server operates on: "testnet" or "mainnet". NETWORK=testnet diff --git a/Cargo.lock b/Cargo.lock index c35d45e..7e76b24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1745,6 +1745,7 @@ dependencies = [ "stellar-strkey 0.0.16", "thiserror 1.0.69", "tokio", + "toml", "tower", "tower-http", "tracing", @@ -2060,7 +2061,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.5+spec-1.1.0", ] [[package]] @@ -2637,6 +2638,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3293,6 +3303,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "1.0.1+spec-1.1.0" @@ -3302,6 +3333,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.5+spec-1.1.0" @@ -3309,9 +3354,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.0.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -3320,9 +3365,15 @@ version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ - "winnow", + "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -4017,6 +4068,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index f8fb362..e6ec639 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", # --- serde / utility --- serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.8" uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } thiserror = "1" diff --git a/README.md b/README.md index d8f4684..1707d12 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,12 @@ curl -s -X POST localhost:8080/v1/wallets//submit-signed \ See [docs/non-custodial-flow.md](docs/non-custodial-flow.md) for the full build → sign → relay sequence. +By default the server runs a single Stellar chain from `.env`. To run more than one chain (e.g. +Stellar mainnet alongside a future EVM chain), point `CHAIN_CONFIG_PATH` at a TOML file like +[`octo.chains.example.toml`](octo.chains.example.toml) instead — see the "Deployment: per-chain +configuration" section of [docs/architecture.md](docs/architecture.md) for the full precedence +rules and `GET /health/chains` for per-chain reachability. + ## Security architecture octo is non-custodial: user wallet keys are generated and held **client-side** (browser/SDK), so diff --git a/bin/server/src/main.rs b/bin/server/src/main.rs index ac5a8bc..a671f5b 100644 --- a/bin/server/src/main.rs +++ b/bin/server/src/main.rs @@ -6,6 +6,8 @@ #![forbid(unsafe_code)] use anyhow::{Context, Result}; +use octo_api::chain_config::{AppConfig, ChainConfig, ChainKind, RedactedUrl}; +use octo_api::chain_registry::ChainRegistry; use octo_api::{build_router, AppState}; use octo_email::EmailSender; use octo_ingest::Supervisor; @@ -13,8 +15,12 @@ use octo_resilience::ResilienceConfig; use octo_store::Store; use octo_wallet_core::StellarNetwork; use octo_webhooks::WebhookSender; +use std::sync::Arc; use std::time::Duration; +/// How long the startup liveness probe waits per chain before treating it as unreachable. +const LIVENESS_PROBE_TIMEOUT: Duration = Duration::from_secs(5); + #[tokio::main] async fn main() -> Result<()> { // Load .env if present (no-op in production where env is set directly). @@ -22,7 +28,17 @@ async fn main() -> Result<()> { init_tracing(); let cfg = Config::from_env()?; - tracing::info!(network = cfg.network.as_str(), bind = %cfg.bind_addr, "starting octo-server"); + + let app_config = load_chain_config(&cfg)?; + for chain in &app_config.chains { + tracing::info!( + chain_id = %chain.chain_id, + kind = ?chain.kind, + enabled = chain.enabled, + rpc_url = ?chain.rpc_url, // redacted Debug impl — never the raw URL. + "configured chain" + ); + } // Database. let store = Store::connect(&cfg.database_url) @@ -31,29 +47,25 @@ async fn main() -> Result<()> { store.migrate().await.context("run migrations")?; tracing::info!("database connected and migrated"); - // Resilience config (shared between API Horizon client and ingest HorizonPayments client). - let resilience = cfg.resilience.clone(); - tracing::info!( - max_attempts = resilience.max_attempts, - base_delay_ms = resilience.base_delay_ms, - max_delay_ms = resilience.max_delay_ms, - cb_failure_threshold = resilience.cb_failure_threshold, - cb_reset_timeout_secs = resilience.cb_reset_timeout_secs, - "horizon resilience config" - ); + // Resolve into the runtime registry, then fail fast and loudly if any enabled chain's RPC + // doesn't answer — a bad endpoint must abort boot, not surface lazily on the first deposit. + let registry = ChainRegistry::new(&app_config).context("build chain registry")?; + registry + .probe_liveness(LIVENESS_PROBE_TIMEOUT) + .await + .context("chain liveness probe failed at startup")?; + tracing::info!("all enabled chains passed the startup liveness probe"); + let registry = Arc::new(registry); - // Shared state (includes the API's Horizon client wired with resilience). + // Shared state (includes the API's Horizon client(s), wired with each chain's own + // resilience config). let email = EmailSender::new(cfg.resend_api_key.clone(), cfg.email_from_address.clone()); - let mut state = AppState::new_with_resilience( + let mut state = AppState::from_chain_registry( store.clone(), cfg.master_key, - cfg.network, - cfg.horizon_url.clone(), - cfg.friendbot_url.clone(), + registry.clone(), cfg.public_app_url.clone(), email, - resilience.retry_policy(), - resilience.circuit_breaker(), ) .with_jwt_secret(cfg.jwt_secret.clone()); // MASTER_KEY_NEXT, when set, activates zero-downtime key rotation: already-migrated rows @@ -63,31 +75,25 @@ async fn main() -> Result<()> { state = state.with_master_key_next(next); } - // Ingest supervisor (background task) — uses its own HorizonPayments client with the same - // resilience config (separate circuit-breaker instance so ingest and API failures are counted - // independently). - let ingest_retry = cfg.resilience.retry_policy(); - let ingest_circuit = cfg.resilience.circuit_breaker(); - let supervisor = Supervisor::new_with_resilience( - store.clone(), - cfg.horizon_url.clone(), - WebhookSender::new(store.clone()), - cfg.network.as_str(), - ingest_retry, - ingest_circuit, - ); - tokio::spawn(async move { - supervisor - .run( - Duration::from_secs(cfg.ingest_interval_secs), - cfg.ingest_page_limit, - ) - .await; - }); - tracing::info!( - interval_secs = cfg.ingest_interval_secs, - "deposit ingest supervisor started" - ); + // One ingest supervisor per enabled Stellar-kind chain, each with that chain's OWN retry + // policy and circuit breaker — a degraded RPC on one chain must never open another chain's + // circuit. (Today there is at most one; the loop is structural so a second Stellar network, + // or a future non-Stellar adapter, slots in without changing this shape.) + for chain in app_config.chains.iter().filter(|c| c.enabled) { + match chain.kind { + ChainKind::Stellar => { + spawn_stellar_ingest(®istry, &store, chain, cfg.ingest_page_limit) + } + // `ChainKind` is `#[non_exhaustive]` so this crate compiles unchanged when a future + // kind (EVM) is added elsewhere — until an adapter exists for it, an enabled chain of + // an unknown kind gets no ingest supervisor, loudly, rather than silently. + other => tracing::warn!( + chain_id = %chain.chain_id, + kind = ?other, + "no ingest adapter for this chain kind yet; chain is configured but will not be polled" + ), + } + } // REST API. let app = build_router(state); @@ -116,6 +122,10 @@ fn init_tracing() { /// Server configuration, read from environment variables. struct Config { database_url: String, + /// Path to a `[[chains]]` TOML file (see [`load_chain_config`]). `None` means no file was + /// found and the legacy flat env vars below (`network`/`horizon_url`/`friendbot_url`/ + /// `resilience`) build a single implicit Stellar chain instead. + chain_config_path: Option, network: StellarNetwork, horizon_url: String, friendbot_url: Option, @@ -150,6 +160,25 @@ impl Config { fn from_env() -> Result { let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL is required")?; + // Precedence: CHAIN_CONFIG_PATH if set (the file MUST exist — a typo'd path is a startup + // error, not a silent fallback); else `octo.chains.toml` in the working directory, if + // present; else `None`, meaning `main` builds one implicit chain from the legacy env vars + // read below (NETWORK/HORIZON_URL/FRIENDBOT_URL/HORIZON_*). + let chain_config_path = match std::env::var("CHAIN_CONFIG_PATH") { + Ok(path) => { + if !std::path::Path::new(&path).is_file() { + anyhow::bail!("CHAIN_CONFIG_PATH={path} does not exist"); + } + Some(path) + } + Err(_) => { + let default_path = "octo.chains.toml"; + std::path::Path::new(default_path) + .is_file() + .then(|| default_path.to_string()) + } + }; + let network_str = std::env::var("NETWORK").unwrap_or_else(|_| "testnet".to_string()); // Accepted values: "mainnet" | "public", "testnet" | "test", "standalone". let network = StellarNetwork::parse(&network_str) @@ -205,6 +234,7 @@ impl Config { Ok(Config { database_url, + chain_config_path, network, horizon_url, friendbot_url, @@ -221,3 +251,83 @@ impl Config { }) } } + +/// Resolve the chain configuration: a TOML file if [`Config::chain_config_path`] names one (with +/// each chain's `rpc_url` overridable via `OCTO_CHAIN__RPC_URL`), else a single implicit +/// Stellar chain built from the legacy flat env vars. Either way the result passes through +/// [`AppConfig::new`]'s validation (unique/non-empty chain ids, at least one enabled). +fn load_chain_config(cfg: &Config) -> Result { + let mut app_config = match &cfg.chain_config_path { + Some(path) => { + let toml_str = std::fs::read_to_string(path) + .with_context(|| format!("read chain config file {path}"))?; + AppConfig::from_toml_str(&toml_str) + .with_context(|| format!("parse chain config file {path}"))? + } + None => { + let chain = ChainConfig { + chain_id: cfg.network.as_str().to_string(), + kind: ChainKind::Stellar, + rpc_url: RedactedUrl::new(cfg.horizon_url.clone()), + enabled: true, + confirmation_depth: 1, + poll_interval: Duration::from_secs(cfg.ingest_interval_secs), + retry: cfg.resilience.retry_policy(), + circuit: cfg.resilience.circuit_breaker(), + faucet_url: cfg.friendbot_url.clone(), + }; + AppConfig::new(vec![chain]).context("build legacy single-chain config")? + } + }; + app_config.apply_env_overrides(|key| std::env::var(key).ok()); + Ok(app_config) +} + +/// Spawn the ingest supervisor loop for one enabled Stellar-kind chain, using that chain's own +/// `retry`/`circuit` (never a shared process-global one — a degraded RPC on one chain must not +/// open another chain's breaker) and recording each successful poll into the shared registry so +/// `/health/chains` can report it. +fn spawn_stellar_ingest( + registry: &Arc, + store: &Store, + chain: &ChainConfig, + page_limit: u32, +) { + let Some(network) = registry.chain_stellar_network(&chain.chain_id) else { + tracing::warn!(chain_id = %chain.chain_id, "no resolved Stellar network for chain; skipping ingest"); + return; + }; + let supervisor = Supervisor::new_with_resilience( + store.clone(), + chain.rpc_url.expose_secret().to_string(), + WebhookSender::new(store.clone()), + network.as_str(), + chain.retry.clone(), + chain.circuit.clone(), + ); + let registry = registry.clone(); + let chain_id = chain.chain_id.clone(); + let interval = chain.poll_interval; + + tokio::spawn(async move { + loop { + match supervisor.tick(page_limit).await { + Ok(n) => { + if n > 0 { + tracing::debug!(chain_id = %chain_id, processed = n, "ingest poll"); + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or(0); + registry.record_chain_poll_success(&chain_id, now); + } + Err(e) => { + tracing::warn!(chain_id = %chain_id, error = ?e, "ingest supervisor tick failed; will retry") + } + } + tokio::time::sleep(interval).await; + } + }); + tracing::info!(chain_id = %chain.chain_id, "deposit ingest supervisor started"); +} diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 2f37fd0..ebc6da4 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -24,6 +24,7 @@ tokio.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true +toml.workspace = true uuid.workspace = true chrono.workspace = true thiserror.workspace = true diff --git a/crates/api/src/chain_config.rs b/crates/api/src/chain_config.rs new file mode 100644 index 0000000..74b76a7 --- /dev/null +++ b/crates/api/src/chain_config.rs @@ -0,0 +1,425 @@ +//! Per-chain configuration: `ChainConfig` / `AppConfig`, TOML parsing, env-var override, and +//! validation. +//! +//! # Precedence +//! +//! 1. A TOML file (path from `CHAIN_CONFIG_PATH`, default `octo.chains.toml` if present in the +//! working directory) defines the chain list — one `[[chains]]` entry per chain, with +//! `[chains.retry]` / `[chains.circuit]` sub-tables for resilience tuning. +//! 2. For each chain, the env var `OCTO_CHAIN__RPC_URL` (chain id upper-cased, +//! non-alphanumeric characters replaced with `_`) overrides that chain's `rpc_url` — this is +//! the field most likely to carry a secret (Alchemy/Infura API keys) and most likely to differ +//! per deploy environment, so it is the one override worth a documented env var rather than +//! forcing secrets into a config file. +//! 3. If no TOML file is present at all, a single implicit chain is built from the legacy flat +//! env vars (`NETWORK`, `HORIZON_URL`, `FRIENDBOT_URL`, `HORIZON_*`) so existing `.env`-only +//! single-chain deployments keep working unmodified. +//! +//! This crate does not implement CAIP-2 parsing/validation for `chain_id` — that belongs to the +//! chain-abstraction trait crate (tracked separately). Here a chain id is just a non-empty, +//! whitespace-free, unique string. +#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + +use octo_resilience::{CircuitBreaker, RetryPolicy}; +use serde::Deserialize; +use std::collections::HashSet; +use std::fmt; +use std::time::Duration; + +/// A URL that is never printed in full — `Debug` shows only `scheme://host/***`, stripping path, +/// query, and userinfo, which is where provider API keys (Alchemy, Infura, ...) live. Reaching the +/// real value requires the deliberate [`RedactedUrl::expose_secret`] call, so leaking it into a +/// log line or error message takes an explicit choice rather than an accidental `{:?}`/`{}`. +#[derive(Clone, PartialEq, Eq)] +pub struct RedactedUrl(String); + +impl RedactedUrl { + pub fn new(url: impl Into) -> Self { + Self(url.into()) + } + + /// The raw URL, secrets and all. Only call this where the value is actually needed to make a + /// network request (constructing an HTTP client) — never to log, display, or include it in an + /// error. + pub fn expose_secret(&self) -> &str { + &self.0 + } + + fn redacted(&self) -> String { + match self.0.find("://") { + Some(scheme_end) => { + let scheme = &self.0[..scheme_end]; + let rest = &self.0[scheme_end + 3..]; + let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let host_part = &rest[..host_end]; + // Strip userinfo (user:pass@host) if present — also potentially a secret. + let host_only = host_part.rsplit('@').next().unwrap_or(host_part); + format!("{scheme}://{host_only}/***") + } + None => "***".to_string(), + } + } +} + +impl fmt::Debug for RedactedUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "RedactedUrl({:?})", self.redacted()) + } +} + +impl fmt::Display for RedactedUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.redacted()) + } +} + +/// Which chain implementation a [`ChainConfig`] entry describes. Only `Stellar` is a real, working +/// adapter today — this codebase has no EVM RPC client yet. `#[non_exhaustive]` so adding `Evm` +/// later is not a breaking change for this crate's dependents. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum ChainKind { + Stellar, +} + +impl Default for ChainKind { + fn default() -> Self { + Self::Stellar + } +} + +/// Resolved, validated configuration for one chain. +#[derive(Debug, Clone)] +pub struct ChainConfig { + pub chain_id: String, + pub kind: ChainKind, + pub rpc_url: RedactedUrl, + pub enabled: bool, + pub confirmation_depth: u32, + pub poll_interval: Duration, + pub retry: RetryPolicy, + pub circuit: CircuitBreaker, + /// Stellar-only: testnet friendbot endpoint. `None` for mainnet chains and non-Stellar kinds. + pub faucet_url: Option, +} + +/// The full set of configured chains. +#[derive(Debug, Clone)] +pub struct AppConfig { + pub chains: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum ChainConfigError { + #[error("failed to parse chain config TOML: {0}")] + Toml(#[from] toml::de::Error), + #[error("chain id must not be empty or whitespace-only")] + EmptyChainId, + #[error("chain id {0:?} must not contain whitespace")] + WhitespaceInChainId(String), + #[error("duplicate chain id: {0:?}")] + DuplicateChainId(String), + #[error("no chains are enabled — at least one enabled chain is required")] + NoEnabledChains, + #[error("chain {chain_id:?}: {reason}")] + Invalid { chain_id: String, reason: String }, +} + +impl AppConfig { + /// Build and validate from an already-resolved chain list (used by both the TOML path and the + /// legacy single-chain env fallback). + pub fn new(chains: Vec) -> Result { + validate(&chains)?; + Ok(Self { chains }) + } + + /// Parse a TOML document into an `AppConfig`. Pure parsing + validation — no env vars, no I/O + /// — so it's easy to unit test. + pub fn from_toml_str(toml_str: &str) -> Result { + let doc: TomlDoc = toml::from_str(toml_str)?; + let chains = doc + .chains + .into_iter() + .map(ChainConfig::from) + .collect::>(); + Self::new(chains) + } + + /// Apply per-chain RPC URL overrides from environment variables named + /// `OCTO_CHAIN__RPC_URL` (chain id upper-cased, non-alphanumeric chars replaced with + /// `_`). `lookup` is injectable so tests don't have to mutate real process env vars. + pub fn apply_env_overrides(&mut self, lookup: impl Fn(&str) -> Option) { + for chain in &mut self.chains { + let var_name = format!("OCTO_CHAIN_{}_RPC_URL", env_key(&chain.chain_id)); + if let Some(url) = lookup(&var_name) { + chain.rpc_url = RedactedUrl::new(url); + } + } + } +} + +/// Upper-case a chain id and replace every non-alphanumeric byte with `_`, for building the env +/// var name that overrides that chain's RPC URL. +fn env_key(chain_id: &str) -> String { + chain_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_uppercase() + } else { + '_' + } + }) + .collect() +} + +fn validate(chains: &[ChainConfig]) -> Result<(), ChainConfigError> { + let mut seen = HashSet::new(); + for chain in chains { + let id = chain.chain_id.trim(); + if id.is_empty() { + return Err(ChainConfigError::EmptyChainId); + } + if chain.chain_id.chars().any(char::is_whitespace) { + return Err(ChainConfigError::WhitespaceInChainId( + chain.chain_id.clone(), + )); + } + if !seen.insert(chain.chain_id.clone()) { + return Err(ChainConfigError::DuplicateChainId(chain.chain_id.clone())); + } + } + if !chains.iter().any(|c| c.enabled) { + return Err(ChainConfigError::NoEnabledChains); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// TOML wire format — deliberately separate from the resolved `ChainConfig` so the file format can +// stay stable (defaults, optional sub-tables) independent of the runtime type's shape. +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct TomlDoc { + #[serde(default)] + chains: Vec, +} + +#[derive(Debug, Deserialize)] +struct ChainConfigToml { + chain_id: String, + #[serde(default)] + kind: ChainKind, + rpc_url: String, + #[serde(default = "default_true")] + enabled: bool, + #[serde(default = "default_confirmation_depth")] + confirmation_depth: u32, + #[serde(default = "default_poll_interval_secs")] + poll_interval_secs: u64, + #[serde(default)] + faucet_url: Option, + #[serde(default)] + retry: RetryConfigToml, + #[serde(default)] + circuit: CircuitConfigToml, +} + +fn default_true() -> bool { + true +} +fn default_confirmation_depth() -> u32 { + 1 +} +fn default_poll_interval_secs() -> u64 { + 5 +} + +impl From for ChainConfig { + fn from(t: ChainConfigToml) -> Self { + Self { + chain_id: t.chain_id, + kind: t.kind, + rpc_url: RedactedUrl::new(t.rpc_url), + enabled: t.enabled, + confirmation_depth: t.confirmation_depth, + poll_interval: Duration::from_secs(t.poll_interval_secs), + retry: t.retry.into(), + circuit: t.circuit.into(), + faucet_url: t.faucet_url, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +struct RetryConfigToml { + max_attempts: u32, + base_delay_ms: u64, + max_delay_ms: u64, + multiplier: f64, + jitter_factor: f64, +} + +impl Default for RetryConfigToml { + fn default() -> Self { + let d = RetryPolicy::default(); + Self { + max_attempts: d.max_attempts, + base_delay_ms: d.base_delay_ms, + max_delay_ms: d.max_delay_ms, + multiplier: d.multiplier, + jitter_factor: d.jitter_factor, + } + } +} + +impl From for RetryPolicy { + fn from(t: RetryConfigToml) -> Self { + RetryPolicy { + max_attempts: t.max_attempts, + base_delay_ms: t.base_delay_ms, + max_delay_ms: t.max_delay_ms, + multiplier: t.multiplier, + jitter_factor: t.jitter_factor, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +struct CircuitConfigToml { + failure_threshold: u32, + reset_timeout_secs: u64, +} + +impl Default for CircuitConfigToml { + fn default() -> Self { + Self { + failure_threshold: 5, + reset_timeout_secs: 30, + } + } +} + +impl From for CircuitBreaker { + fn from(t: CircuitConfigToml) -> Self { + CircuitBreaker::new( + t.failure_threshold, + Duration::from_secs(t.reset_timeout_secs), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_MULTI_CHAIN: &str = r#" + [[chains]] + chain_id = "stellar:testnet" + kind = "stellar" + rpc_url = "https://horizon-testnet.stellar.org" + enabled = true + faucet_url = "https://friendbot.stellar.org" + + [[chains]] + chain_id = "base:mainnet" + kind = "stellar" + rpc_url = "https://eth-mainnet.g.alchemy.com/v2/super-secret-key-123" + enabled = false + "#; + + #[test] + fn parses_valid_multi_chain_file() { + let cfg = AppConfig::from_toml_str(VALID_MULTI_CHAIN).expect("parses"); + assert_eq!(cfg.chains.len(), 2); + assert_eq!(cfg.chains[0].chain_id, "stellar:testnet"); + assert!(cfg.chains[0].enabled); + assert_eq!(cfg.chains[0].confirmation_depth, 1); // default + assert!(!cfg.chains[1].enabled); + } + + #[test] + fn rejects_duplicate_chain_id() { + let toml = r#" + [[chains]] + chain_id = "stellar:testnet" + rpc_url = "https://a.example" + + [[chains]] + chain_id = "stellar:testnet" + rpc_url = "https://b.example" + "#; + let err = AppConfig::from_toml_str(toml).unwrap_err(); + assert!(matches!(err, ChainConfigError::DuplicateChainId(_))); + } + + #[test] + fn rejects_empty_chain_id() { + let toml = r#" + [[chains]] + chain_id = " " + rpc_url = "https://a.example" + "#; + let err = AppConfig::from_toml_str(toml).unwrap_err(); + assert!(matches!(err, ChainConfigError::EmptyChainId)); + } + + #[test] + fn rejects_no_enabled_chains() { + let toml = r#" + [[chains]] + chain_id = "stellar:testnet" + rpc_url = "https://a.example" + enabled = false + "#; + let err = AppConfig::from_toml_str(toml).unwrap_err(); + assert!(matches!(err, ChainConfigError::NoEnabledChains)); + } + + #[test] + fn env_override_takes_precedence_over_toml() { + let mut cfg = AppConfig::from_toml_str(VALID_MULTI_CHAIN).expect("parses"); + cfg.apply_env_overrides(|key| { + if key == "OCTO_CHAIN_STELLAR_TESTNET_RPC_URL" { + Some("https://overridden.example".to_string()) + } else { + None + } + }); + assert_eq!( + cfg.chains[0].rpc_url.expose_secret(), + "https://overridden.example" + ); + // Unrelated chain is untouched. + assert!(cfg.chains[1] + .rpc_url + .expose_secret() + .contains("super-secret-key-123")); + } + + #[test] + fn redacted_url_never_prints_path_or_query() { + let url = RedactedUrl::new("https://eth-mainnet.g.alchemy.com/v2/super-secret-key-123"); + let debug = format!("{url:?}"); + let display = format!("{url}"); + assert!( + !debug.contains("super-secret-key-123"), + "debug leaked: {debug}" + ); + assert!( + !display.contains("super-secret-key-123"), + "display leaked: {display}" + ); + assert!(debug.contains("eth-mainnet.g.alchemy.com")); + } + + #[test] + fn env_key_replaces_non_alphanumeric() { + assert_eq!(env_key("stellar:testnet"), "STELLAR_TESTNET"); + assert_eq!(env_key("base-sepolia.dev"), "BASE_SEPOLIA_DEV"); + } +} diff --git a/crates/api/src/chain_registry.rs b/crates/api/src/chain_registry.rs new file mode 100644 index 0000000..6425e59 --- /dev/null +++ b/crates/api/src/chain_registry.rs @@ -0,0 +1,304 @@ +//! Runtime registry resolved from [`crate::chain_config::AppConfig`] at startup: one entry per +//! configured chain, each with its own resilience state (so a degraded chain can never open +//! another chain's circuit breaker) and, for Stellar chains, a live [`crate::horizon::Horizon`] +//! client. +//! +//! This is deliberately a **lightweight, config-and-resilience-only** registry — it does not +//! define a chain-adapter trait or hold `dyn` adapter objects. A future trait-based registry +//! (covering address validation, deposit derivation, EVM adapters, ...) can supersede or wrap +//! this one without disturbing the config/validation/isolation work done here. + +use crate::chain_config::{AppConfig, ChainConfig, ChainConfigError, ChainKind}; +use crate::horizon::Horizon; +use octo_wallet_core::StellarNetwork; +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +struct ChainEntry { + config: ChainConfig, + /// `Some` for Stellar-kind chains — the only kind with a real client today. + horizon: Option, + stellar_network: Option, + /// Mirrors `octo_ingest::LastPollTracker`'s shape (a `chain_id`-keyed timestamp map) rather + /// than depending on the `octo-ingest` crate directly — `octo-api` has no other reason to + /// depend on `octo-ingest` outside of tests, and pulling it in just for this one type would + /// be a heavier coupling than the one field it's used for. + last_poll_unix: Mutex>, +} + +/// A resolved set of chains, built from validated [`AppConfig`]. +pub struct ChainRegistry { + entries: HashMap, + /// The chain id `AppState`'s legacy single-network accessors (`network()`, `horizon()`, ...) + /// delegate to. The first enabled Stellar-kind chain in configuration order. + primary_stellar_chain_id: Option, +} + +impl ChainRegistry { + /// Build a registry from validated config. Does not touch the network — call + /// [`ChainRegistry::probe_liveness`] separately before serving traffic. + pub fn new(cfg: &AppConfig) -> Result { + let mut entries = HashMap::with_capacity(cfg.chains.len()); + let mut primary_stellar_chain_id = None; + + for chain in &cfg.chains { + let stellar_network = match chain.kind { + ChainKind::Stellar => { + let network = resolve_stellar_network(&chain.chain_id).ok_or_else(|| { + ChainConfigError::Invalid { + chain_id: chain.chain_id.clone(), + reason: "stellar chain_id must be (or contain, after ':') one of \ + mainnet/public, testnet/test, or standalone" + .to_string(), + } + })?; + if primary_stellar_chain_id.is_none() && chain.enabled { + primary_stellar_chain_id = Some(chain.chain_id.clone()); + } + Some(network) + } + }; + + let horizon = match chain.kind { + ChainKind::Stellar => Some(Horizon::with_resilience( + chain.rpc_url.expose_secret().to_string(), + chain.retry.clone(), + chain.circuit.clone(), + )), + }; + + entries.insert( + chain.chain_id.clone(), + ChainEntry { + config: chain.clone(), + horizon, + stellar_network, + last_poll_unix: Mutex::new(None), + }, + ); + } + + Ok(Self { + entries, + primary_stellar_chain_id, + }) + } + + /// Build a registry holding exactly one Stellar chain, bypassing the full multi-chain + /// construction path. Used by `AppState::new`/`new_with_resilience` so the many existing + /// single-chain tests and call sites keep working unchanged. + pub fn single_stellar( + chain_id: impl Into, + network: StellarNetwork, + horizon_url: String, + friendbot_url: Option, + retry: octo_resilience::RetryPolicy, + circuit: octo_resilience::CircuitBreaker, + ) -> Self { + let chain_id = chain_id.into(); + let config = ChainConfig { + chain_id: chain_id.clone(), + kind: ChainKind::Stellar, + rpc_url: crate::chain_config::RedactedUrl::new(horizon_url.clone()), + enabled: true, + confirmation_depth: 1, + poll_interval: Duration::from_secs(5), + retry: retry.clone(), + circuit: circuit.clone(), + faucet_url: friendbot_url, + }; + let horizon = Horizon::with_resilience(horizon_url, retry, circuit); + let mut entries = HashMap::with_capacity(1); + entries.insert( + chain_id.clone(), + ChainEntry { + config, + horizon: Some(horizon), + stellar_network: Some(network), + last_poll_unix: Mutex::new(None), + }, + ); + Self { + entries, + primary_stellar_chain_id: Some(chain_id), + } + } + + /// Probe every enabled chain's RPC with a short timeout. Returns the first failure — callers + /// should treat any error here as fatal at startup ("fail fast and loudly"). Never includes + /// the raw RPC URL or the underlying transport error's `Display` in the returned message — + /// `reqwest::Error` often embeds the request URL, which would leak the same secrets + /// `RedactedUrl` exists to hide. + pub async fn probe_liveness(&self, timeout: Duration) -> Result<(), ChainConfigError> { + for entry in self.entries.values() { + if !entry.config.enabled { + continue; + } + match entry.config.kind { + ChainKind::Stellar => { + let Some(horizon) = &entry.horizon else { + continue; + }; + if let Err(reason) = horizon.liveness_probe(timeout).await { + return Err(ChainConfigError::Invalid { + chain_id: entry.config.chain_id.clone(), + reason, + }); + } + } + } + } + Ok(()) + } + + pub fn chains(&self) -> impl Iterator { + self.entries.values().map(|e| &e.config) + } + + pub fn get(&self, chain_id: &str) -> Option<&ChainConfig> { + self.entries.get(chain_id).map(|e| &e.config) + } + + /// The resolved `StellarNetwork` for a Stellar-kind chain entry, if `chain_id` names one. + /// Used by `bin/server` to spawn one ingest `Supervisor` per enabled Stellar chain — the + /// supervisor's network-filter argument needs the canonical `StellarNetwork`, not the + /// possibly-arbitrary configured chain id string. + pub fn chain_stellar_network(&self, chain_id: &str) -> Option { + self.entries.get(chain_id)?.stellar_network + } + + /// Record a successful poll for `chain_id` (a no-op if the id is unknown). + pub fn record_chain_poll_success(&self, chain_id: &str, timestamp_unix: i64) { + if let Some(entry) = self.entries.get(chain_id) { + if let Ok(mut guard) = entry.last_poll_unix.lock() { + *guard = Some(timestamp_unix); + } + } + } + + /// `(last_poll_unix, seconds_since)` for `chain_id`, if it's known and has polled at least + /// once. + pub fn chain_poll_status(&self, chain_id: &str) -> Option<(i64, i64)> { + let entry = self.entries.get(chain_id)?; + let last = (*entry.last_poll_unix.lock().ok()?)?; + let now = now_unix(); + Some((last, (now - last).max(0))) + } + + // -- legacy single-Stellar-chain accessors, used by `AppState` so route handlers written + // -- against "the" Stellar network keep compiling unchanged. -- + + fn primary_entry(&self) -> &ChainEntry { + let id = self + .primary_stellar_chain_id + .as_deref() + .expect("ChainRegistry always has at least one enabled Stellar chain (enforced by AppConfig::new's validation and single_stellar's construction)"); + self.entries + .get(id) + .expect("primary_stellar_chain_id always names an entry inserted into `entries`") + } + + pub fn stellar_network(&self) -> StellarNetwork { + self.primary_entry() + .stellar_network + .expect("primary chain is always Stellar-kind") + } + + pub fn stellar_horizon(&self) -> &Horizon { + self.primary_entry() + .horizon + .as_ref() + .expect("primary chain is always Stellar-kind") + } + + pub fn stellar_horizon_url(&self) -> &str { + self.primary_entry().config.rpc_url.expose_secret() + } + + pub fn stellar_friendbot_url(&self) -> Option<&str> { + self.primary_entry().config.faucet_url.as_deref() + } +} + +fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or(0) +} + +/// Resolve a `StellarNetwork` from a chain id, accepting either a bare legacy id ("testnet") or a +/// forward-compatible slug ("stellar:testnet") — tries the whole string first, then the part +/// after the last `:`. +fn resolve_stellar_network(chain_id: &str) -> Option { + StellarNetwork::parse(chain_id) + .or_else(|| chain_id.rsplit(':').next().and_then(StellarNetwork::parse)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chain_config::{ChainConfig, RedactedUrl}; + use octo_resilience::{CircuitBreaker, RetryPolicy}; + + fn test_chain(chain_id: &str) -> ChainConfig { + ChainConfig { + chain_id: chain_id.to_string(), + kind: ChainKind::Stellar, + rpc_url: RedactedUrl::new("https://horizon-testnet.stellar.org"), + enabled: true, + confirmation_depth: 1, + poll_interval: Duration::from_secs(5), + retry: RetryPolicy::default(), + // Low threshold so the isolation test can open the circuit in a couple of calls. + circuit: CircuitBreaker::new(2, Duration::from_secs(30)), + faucet_url: None, + } + } + + #[test] + fn one_chains_circuit_breaker_opening_does_not_affect_another() { + let cfg = AppConfig::new(vec![test_chain("testnet"), test_chain("standalone")]) + .expect("valid config"); + let registry = ChainRegistry::new(&cfg).expect("registry builds"); + + let a = registry.get("testnet").unwrap(); + let b = registry.get("standalone").unwrap(); + + a.circuit.on_failure(); + a.circuit.on_failure(); + assert!( + a.circuit.check().is_err(), + "chain a's circuit should be open" + ); + assert!( + b.circuit.check().is_ok(), + "chain b's circuit must be unaffected by chain a's failures" + ); + } + + #[test] + fn resolves_stellar_network_from_legacy_and_slug_ids() { + assert_eq!( + resolve_stellar_network("testnet"), + Some(StellarNetwork::Testnet) + ); + assert_eq!( + resolve_stellar_network("stellar:testnet"), + Some(StellarNetwork::Testnet) + ); + assert_eq!(resolve_stellar_network("not-a-network"), None); + } + + #[test] + fn poll_status_tracks_per_chain_independently() { + let cfg = AppConfig::new(vec![test_chain("testnet"), test_chain("standalone")]) + .expect("valid config"); + let registry = ChainRegistry::new(&cfg).expect("registry builds"); + + registry.record_chain_poll_success("testnet", 1_000); + assert!(registry.chain_poll_status("testnet").is_some()); + assert!(registry.chain_poll_status("standalone").is_none()); + } +} diff --git a/crates/api/src/horizon.rs b/crates/api/src/horizon.rs index 0e43073..8dc5c9c 100644 --- a/crates/api/src/horizon.rs +++ b/crates/api/src/horizon.rs @@ -215,6 +215,28 @@ impl Horizon { } } + /// One-shot startup liveness check: does this base URL answer at all within `timeout`? Used + /// to fail server boot fast and loudly on a bad chain RPC endpoint, rather than discovering it + /// lazily on the first customer deposit. + /// + /// Deliberately bypasses the circuit breaker (a single boot-time probe, not ongoing traffic) + /// and never surfaces the raw `reqwest::Error` or the request URL in its error string — + /// `reqwest::Error`'s `Display` frequently embeds the request URL, which would defeat the + /// point of a redacted RPC URL the moment boot fails. + pub async fn liveness_probe(&self, timeout: Duration) -> Result<(), String> { + let client = match reqwest::Client::builder().timeout(timeout).build() { + Ok(c) => c, + Err(_) => return Err("failed to build HTTP client".to_string()), + }; + match client.get(&self.base_url).send().await { + Ok(resp) if resp.status().is_success() => Ok(()), + Ok(resp) => Err(format!("liveness probe returned HTTP {}", resp.status())), + Err(e) if e.is_timeout() => Err(format!("liveness probe timed out after {timeout:?}")), + Err(e) if e.is_connect() => Err("liveness probe: connection failed".to_string()), + Err(_) => Err("liveness probe: request failed".to_string()), + } + } + /// Fetch an account's balances. Retried on transient failures (transport errors, 5xx). /// Returns `NotFound` if the account does not exist on-chain yet. pub async fn balances(&self, account_g: &str) -> Result, ApiError> { diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index a41dbf8..fba7c66 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -6,6 +6,8 @@ pub mod audit; pub mod auth; +pub mod chain_config; +pub mod chain_registry; mod error; pub mod horizon; mod json; @@ -15,12 +17,16 @@ pub mod sponsor_validation; mod state; pub mod submit_validation; +pub use chain_config::{AppConfig, ChainConfig, ChainConfigError, ChainKind, RedactedUrl}; +pub use chain_registry::ChainRegistry; pub use error::{ApiError, ApiResult, Envelope}; pub use state::AppState; use axum::extract::DefaultBodyLimit; use axum::routing::{delete, get, post}; +use axum::Json; use axum::Router; +use serde::Serialize; use tower_http::cors::{Any, CorsLayer}; /// Keep API request payloads bounded to a deliberate, documented ceiling. @@ -42,6 +48,7 @@ pub fn build_router(state: AppState) -> Router { // together with the error handler that turns an oversized body into a 413 envelope. Router::new() .route("/health", get(health)) + .route("/health/chains", get(health_chains)) .route("/v1/auth/signup", post(auth::signup)) .route("/v1/auth/verify-email", post(auth::verify_email)) .route("/v1/auth/resend-otp", post(auth::resend_otp)) @@ -197,6 +204,48 @@ async fn health() -> &'static str { "ok" } +/// Per-chain detail: reachability and last successful ingest poll, one entry per configured +/// chain. Never includes the raw RPC URL — only `chain_id`, `kind`, `enabled`, and poll timing. +#[derive(Serialize)] +struct ChainHealth { + chain_id: String, + kind: &'static str, + enabled: bool, + last_poll_unix: Option, + seconds_since_last_poll: Option, +} + +#[derive(Serialize)] +struct ChainsHealthResponse { + chains: Vec, +} + +async fn health_chains( + axum::extract::State(state): axum::extract::State, +) -> Json { + let registry = state.chains(); + let chains = registry + .chains() + .map(|c| { + let (last_poll_unix, seconds_since_last_poll) = + match registry.chain_poll_status(&c.chain_id) { + Some((last, since)) => (Some(last), Some(since)), + None => (None, None), + }; + ChainHealth { + chain_id: c.chain_id.clone(), + kind: match c.kind { + chain_config::ChainKind::Stellar => "stellar", + }, + enabled: c.enabled, + last_poll_unix, + seconds_since_last_poll, + } + }) + .collect(); + Json(ChainsHealthResponse { chains }) +} + // NOTE: a `handle_errors` HandleErrorLayer helper lived here to convert oversized-body errors // into a 413 envelope. It is unnecessary with `DefaultBodyLimit` (axum renders that rejection as // 413 itself) and did not satisfy `Router::layer`'s Service bounds, so it was removed. diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index 05c9f67..f15f636 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -1,5 +1,6 @@ -//! Shared application state: DB handle, master key, network, and Horizon config. +//! Shared application state: DB handle, master key, and the per-chain registry. +use crate::chain_registry::ChainRegistry; use crate::error::ApiError; use crate::horizon::Horizon; use base64::Engine; @@ -27,10 +28,8 @@ struct Inner { /// When set, the server tries this key first (for already-migrated rows) and falls back to /// `master_key` for rows not yet re-sealed by `octo-migrate-keys`. master_key_next: Option>, - network: StellarNetwork, - horizon: Horizon, - horizon_url: String, - friendbot_url: Option, + /// Per-chain config + resilience state. Holds "the" Stellar chain today; ready to hold more. + chains: Arc, /// Base URL of the hosted checkout frontend, used to build the `url` field on payment-link /// responses (e.g. `https://app.octo.dev/pay/`). No trailing slash. public_app_url: String, @@ -162,7 +161,59 @@ impl AppState { retry: RetryPolicy, circuit: CircuitBreaker, ) -> Self { - let horizon = Horizon::with_resilience(horizon_url.clone(), retry, circuit); + let chains = Arc::new(crate::chain_registry::ChainRegistry::single_stellar( + network.as_str(), + network, + horizon_url, + friendbot_url, + retry, + circuit, + )); + Self::from_parts( + store, + master_key, + master_key_next, + chains, + public_app_url, + email, + jwt_secret, + ) + } + + /// Build state from an already-resolved, potentially multi-chain [`ChainRegistry`]. This is + /// the real production construction path (`bin/server`); `new`/`new_with_resilience` above + /// wrap it with a single implicit Stellar chain so existing single-chain call sites and tests + /// don't need to change. + pub fn from_chain_registry( + store: Store, + master_key: [u8; MASTER_KEY_LEN], + chains: Arc, + public_app_url: String, + email: EmailSender, + ) -> Self { + let mut secret = vec![0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut secret); + Self::from_parts( + store, + master_key, + None, + chains, + public_app_url, + email, + secret, + ) + } + + #[allow(clippy::too_many_arguments)] + fn from_parts( + store: Store, + master_key: [u8; MASTER_KEY_LEN], + master_key_next: Option<[u8; MASTER_KEY_LEN]>, + chains: Arc, + public_app_url: String, + email: EmailSender, + jwt_secret: Vec, + ) -> Self { let webhooks = WebhookSender::new(store.clone()); Self { inner: Arc::new(Inner { @@ -171,10 +222,7 @@ impl AppState { store, master_key: Zeroizing::new(master_key), master_key_next: master_key_next.map(Zeroizing::new), - network, - horizon, - horizon_url, - friendbot_url, + chains, public_app_url, jwt_secret, webhooks, @@ -233,12 +281,14 @@ impl AppState { &self.inner.jwt_secret } + /// The Stellar network of the primary configured Stellar chain. Route handlers written + /// against "the" Stellar network (pre-multi-chain) use this unchanged. pub fn network(&self) -> StellarNetwork { - self.inner.network + self.inner.chains.stellar_network() } pub fn horizon(&self) -> &Horizon { - &self.inner.horizon + self.inner.chains.stellar_horizon() } pub fn webhooks(&self) -> &WebhookSender { @@ -250,11 +300,17 @@ impl AppState { } pub fn horizon_url(&self) -> &str { - &self.inner.horizon_url + self.inner.chains.stellar_horizon_url() } pub fn friendbot_url(&self) -> Option<&str> { - self.inner.friendbot_url.as_deref() + self.inner.chains.stellar_friendbot_url() + } + + /// The full per-chain registry — config, resilience state, and poll-health for every + /// configured chain (used by the `/health/chains` route and, later, multi-chain routes). + pub fn chains(&self) -> &Arc { + &self.inner.chains } /// Base URL of the hosted checkout frontend (no trailing slash). diff --git a/docs/architecture.md b/docs/architecture.md index 281c910..dab32cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,3 +77,45 @@ 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. + +## Deployment: per-chain configuration + +`bin/server` resolves its chain set into a `ChainRegistry` (`octo-api::chain_registry`) at +startup — one entry per configured chain, each with its own RPC endpoint, confirmation depth, +poll interval, and resilience state (`RetryPolicy` + `CircuitBreaker`). This is what makes a +degraded RPC on one chain unable to open another chain's circuit breaker: each chain's breaker is +a distinct instance, never a shared process-global one. + +Configuration precedence, resolved by `bin/server`'s `load_chain_config`: + +1. **`CHAIN_CONFIG_PATH`**, if set, must name an existing TOML file — see + [`octo.chains.example.toml`](../octo.chains.example.toml) for a worked example with two + `[[chains]]` entries (one enabled Stellar testnet chain, one disabled placeholder). A path + that doesn't exist aborts boot rather than silently falling back. +2. Else, `./octo.chains.toml` in the working directory, if present. +3. Else, the legacy flat env vars (`NETWORK`, `HORIZON_URL`, `FRIENDBOT_URL`, `HORIZON_*`) build + a single implicit Stellar chain — today's single-chain deployments keep working unmodified. + +Whichever source wins, each configured chain's `rpc_url` can additionally be overridden by +`OCTO_CHAIN__RPC_URL` (chain id upper-cased, non-alphanumeric characters replaced with +`_`) — the one field most likely to carry a secret (Alchemy/Infura-style API keys) and most +likely to differ per deploy environment. + +**Fail fast at startup.** After the registry is built, every *enabled* chain's RPC is probed with +a short-timeout liveness check (`ChainRegistry::probe_liveness`); a bad endpoint aborts boot with +a clear message instead of surfacing lazily on the first customer deposit. The probe's error path +deliberately never includes the raw RPC URL or the underlying transport error's `Display` — both +can embed the request URL, defeating the redaction below. + +**RPC URLs are redacted everywhere they might be logged.** `ChainConfig::rpc_url` is a +`RedactedUrl`: its `Debug`/`Display` show only `scheme://host/***`, stripping path, query, and +userinfo (where provider API keys live). Reaching the real value requires the deliberate +`.expose_secret()` call used only where a request is actually made. `GET /health/chains` reports +per-chain reachability (last successful ingest poll, derived from each chain's own poll loop) and +never includes `rpc_url` at all. + +This registry is deliberately lightweight — config, a Horizon client for Stellar-kind chains, and +resilience/poll-health state, not a trait-based chain-adapter abstraction. A more general +trait-based registry (chain-agnostic address validation, deposit derivation, EVM adapters, ...) +is expected to supersede or wrap it later without disturbing the config/validation/isolation work +described here. diff --git a/octo.chains.example.toml b/octo.chains.example.toml new file mode 100644 index 0000000..3658ea3 --- /dev/null +++ b/octo.chains.example.toml @@ -0,0 +1,39 @@ +# Worked multi-chain configuration example. Copy to `octo.chains.toml` (or point +# CHAIN_CONFIG_PATH at a copy of this file) to use it instead of the legacy flat +# NETWORK/HORIZON_URL/FRIENDBOT_URL env vars. See .env.example and +# docs/architecture.md's "Per-chain configuration" section for the full precedence rules. +# +# `rpc_url` below is safe to commit as a placeholder — the real value for a chain that carries +# secrets should come from the OCTO_CHAIN__RPC_URL env var override at deploy time, not +# be checked into this file. + +[[chains]] +chain_id = "stellar:testnet" +kind = "stellar" +rpc_url = "https://horizon-testnet.stellar.org" +enabled = true +confirmation_depth = 1 +poll_interval_secs = 5 +faucet_url = "https://friendbot.stellar.org" + +[chains.retry] +max_attempts = 3 +base_delay_ms = 200 +max_delay_ms = 5000 + +[chains.circuit] +failure_threshold = 5 +reset_timeout_secs = 30 + +# A second chain, disabled — illustrates the shape for a chain not yet live (e.g. a local +# quickstart node used in CI, switched on only when needed). This issue does not add EVM support +# (no adapter exists yet for `kind = "stellar"`'s siblings), so this entry stays a Stellar-kind +# placeholder rather than a fabricated working EVM chain. Once an EVM ChainKind and adapter exist, +# a real Base/Arbitrum entry looks like this but with kind = "evm". +[[chains]] +chain_id = "stellar:standalone" +kind = "stellar" +rpc_url = "http://localhost:8000" +enabled = false +confirmation_depth = 1 +poll_interval_secs = 5