From 6f4b1514294c7d0f5168dea7d41f5f0f14d58a94 Mon Sep 17 00:00:00 2001 From: rouzwelt Date: Fri, 14 Aug 2026 00:13:09 +0000 Subject: [PATCH] feat: orchestrator multichain support - RAI-1749 --- SPEC.md | 59 +- config.example.toml | 21 +- config.prod.toml | 2 +- config.staging.toml | 2 +- docs/runbooks/orchestrator-onboarding.md | 47 +- src/admin.rs | 92 +++- src/config.rs | 653 ++++++++++++++++++----- src/lib.rs | 3 +- src/mint/api/initiate.rs | 111 +++- src/mint/api/mod.rs | 15 + src/redemption/transfer.rs | 31 +- src/tokenized_asset/api.rs | 28 +- src/tokenized_asset/cli.rs | 148 +++-- tests/harness/mod.rs | 13 +- tests/multichain_orchestrator.rs | 401 ++++++++++++++ 15 files changed, 1330 insertions(+), 296 deletions(-) create mode 100644 tests/multichain_orchestrator.rs diff --git a/SPEC.md b/SPEC.md index d560ad43..0b5fb689 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1201,23 +1201,23 @@ against the local SQLite store, and is where future issuer actions (e.g. `mint`, receipts to a corroborated destination through the Turnkey signer, driving the receipt-moving engine (see "Receipt custody"). The destination is stated by exactly one of two mutually exclusive flags: `--to-configured-orchestrator` - reads `[orchestrator].address` from `--config` (the cutover path — the - orchestrator address is never typed), while `--to
` states an - explicit destination (the wallet-rotation path). `--to` naming the configured - orchestrator address is refused: the cutover path must state it through the - config flag, so a hand-typed orchestrator address never enters the flow. - Either way the stated address only becomes reachable as a transfer destination - through the corroboration witness described under "Receipt custody". Before - anything is signed the command verifies the deployment hold is armed (the hold - file present and the readiness marker absent — the engine's projection - rebuilds must not race a running service), verifies the bot wallet's native - balance covers a fixed transfer-gas ceiling at the current gas price (no - per-transaction estimate — estimating a transfer of receipts the destination - does not hold yet would revert), corroborates the destination, and then - prompts with the asset, vault, holder, destination and its corroborated kind, - and the tracked receipt count — the operator confirms what was proven, not - what was typed. A re-run after a completed move reports the already-migrated - observation distinctly and submits nothing. + reads the `--network`'s `[orchestrator.addresses]` entry from `--config` (the + cutover path — the orchestrator address is never typed), while + `--to
` states an explicit destination (the wallet-rotation path). + `--to` naming the configured orchestrator address is refused: the cutover path + must state it through the config flag, so a hand-typed orchestrator address + never enters the flow. Either way the stated address only becomes reachable as + a transfer destination through the corroboration witness described under + "Receipt custody". Before anything is signed the command verifies the + deployment hold is armed (the hold file present and the readiness marker + absent — the engine's projection rebuilds must not race a running service), + verifies the bot wallet's native balance covers a fixed transfer-gas ceiling + at the current gas price (no per-transaction estimate — estimating a transfer + of receipts the destination does not hold yet would revert), corroborates the + destination, and then prompts with the asset, vault, holder, destination and + its corroborated kind, and the tracked receipt count — the operator confirms + what was proven, not what was typed. A re-run after a completed move reports + the already-migrated observation distinctly and submits nothing. - `issuer confirm-custody ` — verifies on-chain that the Turnkey bot wallet holds exactly every tracked receipt balance for the asset's vault, then records it as the inventory's custody holder. The rollback counterpart of @@ -1404,10 +1404,11 @@ The orchestrator onboarding and custody subcommands (`orchestrator-preflight`, `confirm-custody`) take the same network flags and require the `TURNKEY_*` group: the facts they verify or establish are keyed to the Turnkey bot wallet, so a local-key signer is refused. All but `confirm-custody` also take `--config` -(the TOML configuration file; its `[orchestrator].address` is the only source of -the orchestrator address — never typed); `confirm-custody` involves no -orchestrator address at all. See `docs/runbooks/orchestrator-onboarding.md` for -the ordered onboarding and per-asset cutover procedures. +(the TOML configuration file; its per-network `[orchestrator.addresses]` map is +the only source of the orchestrator address — never typed); `confirm-custody` +involves no orchestrator address at all. See +`docs/runbooks/orchestrator-onboarding.md` for the ordered onboarding and +per-asset cutover procedures. ### Receipt custody @@ -2037,6 +2038,10 @@ File"): an asset whose `[assets.]` table sets `vault_mode = "orchestrator"` routes its mints and burns through the orchestrator; an asset without an override takes `[orchestrator].default_vault_mode`, which itself defaults to `"vault_direct"`. +The mode is keyed by symbol alone (it applies on every network the asset is +listed on); the orchestrator **address** is keyed by network via +`[orchestrator.addresses]` — each chain carries its own deployment — and is +resolved at the operation's anchoring point from the operation's own network. The mapping is loaded once at startup (changing it is a config change + restart, like any other deploy-time setting) and threaded to the two call sites that resolve it — `MintServices` and `BurnManager`, each of which resolves a given @@ -4454,16 +4459,20 @@ default. ```toml [orchestrator] -# ST0xOrchestrator contract address. Required when any asset resolves to -# orchestrator mode; rejected as a startup error if that is the case and it -# is missing or malformed. -address = "0x..." # Mode for assets without a per-asset override below: # "vault_direct" (the default when omitted) | "orchestrator". # The full-rollout end state sets this to "orchestrator" and drops the # per-asset overrides, so newly onboarded assets default to the orchestrator. default_vault_mode = "vault_direct" +# ST0xOrchestrator contract addresses, one per network — each chain carries +# its own deployment. Keys are network wire names (base | ethereum | +# hyperevm). Required when any asset resolves to orchestrator mode: startup +# then demands an entry for EVERY configured chain, and rejects unknown +# network keys and missing, malformed, or zero addresses. +[orchestrator.addresses] +base = "0x..." + # Per-asset override, keyed by underlying symbol. During the pilot exactly one # asset carries this; every other asset stays on the default. [assets.RKLB] diff --git a/config.example.toml b/config.example.toml index 4d9a3456..90294ae3 100644 --- a/config.example.toml +++ b/config.example.toml @@ -6,21 +6,26 @@ # # Parsing is strict — unknown keys and invalid vault_mode strings are startup # errors, and any asset resolving to orchestrator mode while -# [orchestrator].address is missing or malformed is a startup error. +# [orchestrator.addresses] is missing or malformed is a startup error. [orchestrator] -# ST0xOrchestrator contract address. Required when any asset resolves to -# orchestrator mode; rejected as a startup error if that is the case and it -# is missing, malformed, or the zero address. -address = "0x1234567890abcdef1234567890abcdef12345678" - # Mode for assets without a per-asset override below: # "vault_direct" (the default when omitted) | "orchestrator". # The full-rollout end state sets this to "orchestrator" and drops the # per-asset overrides, so newly onboarded assets default to the orchestrator. default_vault_mode = "vault_direct" -# Per-asset override, keyed by underlying symbol. During the pilot exactly one -# asset carries this; every other asset stays on the default. +# ST0xOrchestrator contract addresses, one per network — each chain carries +# its own deployment. Keys are network wire names: base | ethereum | hyperevm. +# Required when any asset resolves to orchestrator mode: at startup every +# configured chain must have an entry (a missing, malformed, zero-address, or +# unknown-network entry is a startup error). +[orchestrator.addresses] +base = "0x1234567890abcdef1234567890abcdef12345678" + +# Per-asset override, keyed by underlying symbol. The mode is keyed by symbol +# alone — it applies on every network the asset is listed on; the address is +# what varies per network. During the pilot exactly one asset carries this; +# every other asset stays on the default. [assets.RKLB] vault_mode = "orchestrator" diff --git a/config.prod.toml b/config.prod.toml index 26792bc0..5cef46d4 100644 --- a/config.prod.toml +++ b/config.prod.toml @@ -7,4 +7,4 @@ # # Parsing is strict — unknown keys and invalid vault_mode strings are startup # errors, and any asset resolving to orchestrator mode while -# [orchestrator].address is missing or malformed is a startup error. +# [orchestrator.addresses] is missing or malformed is a startup error. diff --git a/config.staging.toml b/config.staging.toml index a9485726..16178825 100644 --- a/config.staging.toml +++ b/config.staging.toml @@ -7,4 +7,4 @@ # # Parsing is strict — unknown keys and invalid vault_mode strings are startup # errors, and any asset resolving to orchestrator mode while -# [orchestrator].address is missing or malformed is a startup error. +# [orchestrator.addresses] is missing or malformed is a startup error. diff --git a/docs/runbooks/orchestrator-onboarding.md b/docs/runbooks/orchestrator-onboarding.md index 8418c802..3423159a 100644 --- a/docs/runbooks/orchestrator-onboarding.md +++ b/docs/runbooks/orchestrator-onboarding.md @@ -43,20 +43,25 @@ on-chain resolution. The one address argument in this document, configured orchestrator address, and is guarded by the kind-aware corroboration witness (see SPEC "Receipt custody"). -## 1. Ship the orchestrator address in the config (stays dark) +## 1. Ship the orchestrator addresses in the config (stays dark) Add to `config.prod.toml`: ```toml -[orchestrator] -address = "0x…" # from st0x.deploy +[orchestrator.addresses] +base = "0x…" # from st0x.deploy; one entry per network — each chain has + # its own orchestrator deployment ``` Do **not** add any `[assets.]` section — with no `vault_mode` overrides every asset stays vault-direct, so this deploys dark. Parsing is strict (unknown -keys and a malformed or zero address are startup errors, even while dark). -Verify locally with `cargo run --bin validate-config`, then deploy. The config -file is baked into the systemd unit (`CONFIG=`, see +keys, unknown network names, and malformed or zero addresses are startup errors, +even while dark), and once any asset resolves to orchestrator mode, startup +requires an entry for **every** configured chain. Every `issuer` verification +below runs per `--network` against that network's entry, and an asset's cutover +(steps 7–14) runs per chain it is listed on. Verify locally with +`cargo run --bin validate-config`, then deploy. The config file is baked into +the systemd unit (`CONFIG=`, see `nix/upgradeable-services.nix`). Every command below passes `--config "$CONFIG"` — the unit's own value — so the CLI provably validates and approves against the exact file the running service resolves, never a stray local copy. @@ -122,13 +127,13 @@ One-time unlimited ERC-20 approval, bot wallet → orchestrator, on the asset's vault share token, signed by Turnkey after an explicit confirmation. Before sending, the command verifies the configured address answers as an orchestrator (interface reads plus a healthy `vaultLogicIsExpected()`), so a typo'd or stale -`[orchestrator].address` is refused rather than granted an unlimited allowance. -Idempotent: a re-run reports "already unlimited" and sends nothing, so batching -every asset's approval early is safe — approvals are inert until the asset's -`vault_mode` flips. Success is re-verified by an on-chain allowance read. When -this step actually submits, the transaction is also live proof that the policy's -`approve` allowance works; the idempotent no-op path proves nothing new — step -4's signing proof covers `approve` in that case. +`[orchestrator.addresses]` entry is refused rather than granted an unlimited +allowance. Idempotent: a re-run reports "already unlimited" and sends nothing, +so batching every asset's approval early is safe — approvals are inert until the +asset's `vault_mode` flips. Success is re-verified by an on-chain allowance +read. When this step actually submits, the transaction is also live proof that +the policy's `approve` allowance works; the idempotent no-op path proves nothing +new — step 4's signing proof covers `approve` in that case. Record each executed approval in the table below. @@ -239,14 +244,14 @@ issuer move-receipts \ --network base --chain-id 8453 --rpc-url "$RPC_URL" ``` -The destination is read from `[orchestrator].address` — never typed — and -corroborated as an ERC-1155-receiving contract before anything is signed. The -command prompts with the asset, vault, holder, destination and its corroborated -kind, and the tracked receipt count. A vault tracking more than 14 receipts -moves in multiple bounded transactions, each verified before the next. A re-run -after any interruption is safe: an interrupted move resumes with only the -remaining receipts, and a completed move reports "already migrated" and submits -nothing. +The destination is read from the `--network`'s `[orchestrator.addresses]` entry +— never typed — and corroborated as an ERC-1155-receiving contract before +anything is signed. The command prompts with the asset, vault, holder, +destination and its corroborated kind, and the tracked receipt count. A vault +tracking more than 14 receipts moves in multiple bounded transactions, each +verified before the next. A re-run after any interruption is safe: an +interrupted move resumes with only the remaining receipts, and a completed move +reports "already migrated" and submits nothing. ## 11. Verify the move diff --git a/src/admin.rs b/src/admin.rs index a2b4715f..753c6f82 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -2231,7 +2231,17 @@ pub(crate) async fn orchestrator_health( let mut orchestrators_seen: Vec<(Network, Address)> = Vec::new(); for asset in &enabled_assets { - match config.vault_mode_for(&asset.underlying) { + let mode = config + .vault_mode_for(&asset.underlying, asset.network) + .map_err(|err| { + error!(target: "admin", network = %asset.network, + underlying = %asset.underlying, + error = %err, + "Orchestrator address missing for asset's network" + ); + Status::InternalServerError + })?; + match mode { VaultMode::VaultDirect => { assets.push(AssetVaultModeStatus { underlying: asset.underlying.clone(), @@ -3020,7 +3030,7 @@ mod tests { AlpacaError, AlpacaService, MintCallbackRequest, RedeemRequest, RedeemRequestStatus, RedeemResponse, TokenizationRequest, }; - use crate::config::{VaultMode, VaultModeConfig}; + use crate::config::{VaultMode, VaultModeConfig, VaultModeKind}; use crate::mint::test_utils::{ TestHarness, network_vault_services, test_config, }; @@ -6865,11 +6875,9 @@ mod tests { seed_enabled_asset(&pool, "TSLA", tsla_vault).await; let vault_mode_config = VaultModeConfig::new( - HashMap::from([( - "AAPL".to_string(), - VaultMode::Orchestrator { address: orchestrator }, - )]), - VaultMode::VaultDirect, + HashMap::from([("AAPL".to_string(), VaultModeKind::Orchestrator)]), + VaultModeKind::VaultDirect, + HashMap::from([(Network::Base, orchestrator)]), ); let vault_service: Arc = Arc::new( MockVaultService::new_success() @@ -6928,11 +6936,9 @@ mod tests { .await; let vault_mode_config = VaultModeConfig::new( - HashMap::from([( - "AAPL".to_string(), - VaultMode::Orchestrator { address: orchestrator }, - )]), - VaultMode::VaultDirect, + HashMap::from([("AAPL".to_string(), VaultModeKind::Orchestrator)]), + VaultModeKind::VaultDirect, + HashMap::from([(Network::Base, orchestrator)]), ); let vault_service: Arc = Arc::new( MockVaultService::new_success().with_vault_logic_expected(false), @@ -6953,6 +6959,49 @@ mod tests { ); } + /// An orchestrator-kind asset whose network has no + /// `[orchestrator.addresses]` entry must surface as a 500 with an ERROR + /// log — never silently report as vault-direct. Unreachable in a + /// validated deploy (the startup cross-check requires an entry per + /// configured chain), so this pins the fail-loud behavior of the gap. + #[traced_test] + #[tokio::test] + async fn orchestrator_health_missing_address_is_an_internal_error() { + let pool = setup_pool().await; + seed_enabled_asset( + &pool, + "AAPL", + address!("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ) + .await; + + let vault_mode_config = VaultModeConfig::new( + HashMap::from([("AAPL".to_string(), VaultModeKind::Orchestrator)]), + VaultModeKind::VaultDirect, + HashMap::new(), + ); + let vault_service: Arc = + Arc::new(MockVaultService::new_success()); + let rocket = orchestrator_health_rocket( + pool, + health_config(vault_mode_config), + vault_service, + ); + + let (status, _body) = dispatch_orchestrator_health(rocket, true).await; + + assert_eq!( + status, + Status::InternalServerError, + "a missing per-network address must fail loudly, not report \ + vault-direct" + ); + assert!(logs_contain_at!( + Level::ERROR, + &["Orchestrator address missing for asset's network"] + )); + } + #[traced_test] #[tokio::test] async fn orchestrator_health_dedupes_shared_orchestrator() { @@ -6974,7 +7023,8 @@ mod tests { let vault_mode_config = VaultModeConfig::new( HashMap::new(), - VaultMode::Orchestrator { address: orchestrator }, + VaultModeKind::Orchestrator, + HashMap::from([(Network::Base, orchestrator)]), ); let mock = Arc::new(MockVaultService::new_success()); let vault_service: Arc = mock.clone(); @@ -7015,11 +7065,9 @@ mod tests { .await; let vault_mode_config = VaultModeConfig::new( - HashMap::from([( - "AAPL".to_string(), - VaultMode::Orchestrator { address: orchestrator }, - )]), - VaultMode::VaultDirect, + HashMap::from([("AAPL".to_string(), VaultModeKind::Orchestrator)]), + VaultModeKind::VaultDirect, + HashMap::from([(Network::Base, orchestrator)]), ); let vault_service: Arc = Arc::new(MockVaultService::new_success().with_vault_logic_error()); @@ -7066,11 +7114,9 @@ mod tests { .await; let vault_mode_config = VaultModeConfig::new( - HashMap::from([( - "AAPL".to_string(), - VaultMode::Orchestrator { address: orchestrator }, - )]), - VaultMode::VaultDirect, + HashMap::from([("AAPL".to_string(), VaultModeKind::Orchestrator)]), + VaultModeKind::VaultDirect, + HashMap::from([(Network::Base, orchestrator)]), ); let vault_service: Arc = Arc::new( MockVaultService::new_success().with_next_burn_receipt_id_error(), diff --git a/src/config.rs b/src/config.rs index 29a8d012..2521148d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -55,14 +55,23 @@ impl VaultMode { } } -/// The backend kind of a [`VaultMode`], without the orchestrator's address -/// payload — for mode-mismatch errors and logs where only the kind matters -/// and a free-form string would let call sites invent labels the compiler -/// cannot check. +/// The backend kind of a [`VaultMode`] +/// +/// Without the orchestrator's address payload — for mode-mismatch errors +/// and logs where only the kind matters and a free-form string would let +/// call sites invent labels the compiler cannot check. #[derive( - Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, + Debug, + Clone, + Copy, + PartialEq, + Eq, + Default, + serde::Serialize, + serde::Deserialize, )] pub enum VaultModeKind { + #[default] VaultDirect, Orchestrator, } @@ -78,49 +87,108 @@ impl std::fmt::Display for VaultModeKind { /// Resolved per-asset vault-mode configuration loaded from the optional TOML /// config file. Defaults to all-`VaultDirect` when no file is provided. +/// +/// Modes are keyed by underlying symbol; orchestrator **addresses** are keyed +/// by network, because each chain carries its own orchestrator deployment. +/// The two are joined at query time by [`Self::mode_for`], so a symbol's mode +/// flip applies on every network the asset is listed on while each network's +/// operations target that network's contract. #[derive(Debug, Clone, Default)] pub struct VaultModeConfig { - /// Per-asset overrides keyed by the underlying symbol string (e.g. "AAPL"). - per_asset: HashMap, + /// Per-asset mode overrides keyed by the underlying symbol string + /// (e.g. "AAPL"). + per_asset: HashMap, /// Fallback used for any asset not listed in `per_asset`. - default: VaultMode, - /// `[orchestrator].address` as parsed from the TOML file, retained even + default: VaultModeKind, + /// `[orchestrator.addresses]` as parsed from the TOML file, retained even /// while every asset still resolves to vault-direct: the onboarding ops /// tooling (role/allowance preflight, approval execution) needs the - /// address before the first asset's cutover, and the config file is its - /// single source of truth. - orchestrator_address: Option
, + /// addresses before the first asset's cutover, and the config file is + /// their single source of truth. + orchestrator_addresses: HashMap, +} + +/// An asset resolved to orchestrator mode on a network that has no +/// `[orchestrator.addresses]` entry. Never silently falls back to +/// vault-direct — the startup cross-check in `Env::into_config` makes this +/// unreachable in a validated deploy, and unvalidated paths must fail loudly. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +#[error( + "no [orchestrator.addresses] entry for network '{network}'; add it to \ + the TOML config (every configured chain needs one while any asset \ + resolves to orchestrator mode)" +)] +pub struct MissingOrchestratorAddress { + pub network: Network, } impl VaultModeConfig { - /// Programmatic constructor for tests and harnesses. Configs built this - /// way carry no `[orchestrator].address` (`orchestrator_address()` is - /// `None`); orchestrator addresses live inside the `VaultMode` variants. + /// Programmatic constructor for tests and harnesses. #[must_use] pub const fn new( - per_asset: HashMap, - default: VaultMode, + per_asset: HashMap, + default: VaultModeKind, + orchestrator_addresses: HashMap, ) -> Self { - Self { per_asset, default, orchestrator_address: None } + Self { per_asset, default, orchestrator_addresses } } - /// The `[orchestrator].address` from the TOML file, if one was configured. - /// Present as soon as the section carries an address — deliberately not - /// gated on any asset resolving to orchestrator mode. + /// The `[orchestrator.addresses]` entry for `network`, if one was + /// configured. Present as soon as the map carries the network — + /// deliberately not gated on any asset resolving to orchestrator mode. #[must_use] - pub const fn orchestrator_address(&self) -> Option
{ - self.orchestrator_address + pub fn orchestrator_address_for( + &self, + network: Network, + ) -> Option
{ + self.orchestrator_addresses.get(&network).copied() } - /// Returns the `VaultMode` for the given underlying asset symbol. - /// - /// Uses the per-asset override from the TOML config if present, otherwise - /// falls back to the configured default (which itself defaults to - /// `VaultDirect` when no TOML file is provided). + /// Whether the default or any per-asset override resolves to + /// orchestrator kind — the trigger for the startup requirement that + /// every configured chain has an `[orchestrator.addresses]` entry. + #[must_use] + pub fn has_orchestrator_kind(&self) -> bool { + self.default == VaultModeKind::Orchestrator + || self + .per_asset + .values() + .any(|kind| *kind == VaultModeKind::Orchestrator) + } + + /// Returns the mode **kind** for the given underlying asset symbol — + /// per-asset override first, then the configured default. Infallible: + /// use where only the kind matters (status surfaces); address-carrying + /// resolution is [`Self::mode_for`]. #[must_use] - pub fn mode_for(&self, underlying: &UnderlyingSymbol) -> VaultMode { + pub fn kind_for(&self, underlying: &UnderlyingSymbol) -> VaultModeKind { self.per_asset.get(underlying.as_str()).copied().unwrap_or(self.default) } + + /// Returns the `VaultMode` for the given underlying asset symbol on the + /// given network. + /// + /// The mode kind comes from the per-asset override (falling back to the + /// configured default); an orchestrator kind then resolves the network's + /// address from `[orchestrator.addresses]`. A missing entry is a typed + /// error, never a silent vault-direct fallback. + /// + /// # Errors + /// + /// If the orchestrator address is missing + pub fn mode_for( + &self, + underlying: &UnderlyingSymbol, + network: Network, + ) -> Result { + match self.kind_for(underlying) { + VaultModeKind::VaultDirect => Ok(VaultMode::VaultDirect), + VaultModeKind::Orchestrator => self + .orchestrator_address_for(network) + .map(|address| VaultMode::Orchestrator { address }) + .ok_or(MissingOrchestratorAddress { network }), + } + } } /// Default chain ID (Base mainnet) @@ -183,10 +251,30 @@ impl Config { .map_err(|error| ConfigError::ChainRegistry(Box::new(error))) } - /// Returns the `VaultMode` for the given underlying asset symbol. + /// Returns the `VaultMode` for the given underlying asset symbol on the + /// given network. See [`VaultModeConfig::mode_for`]. + /// + /// # Errors + /// + /// Returns [`MissingOrchestratorAddress`] when the asset resolves to + /// orchestrator kind and `network` has no `[orchestrator.addresses]` + /// entry. + pub fn vault_mode_for( + &self, + underlying: &UnderlyingSymbol, + network: Network, + ) -> Result { + self.vault_mode_config.mode_for(underlying, network) + } + + /// Returns the mode **kind** for the given underlying asset symbol. + /// See [`VaultModeConfig::kind_for`]. #[must_use] - pub fn vault_mode_for(&self, underlying: &UnderlyingSymbol) -> VaultMode { - self.vault_mode_config.mode_for(underlying) + pub fn vault_mode_kind_for( + &self, + underlying: &UnderlyingSymbol, + ) -> VaultModeKind { + self.vault_mode_config.kind_for(underlying) } } @@ -404,6 +492,27 @@ impl Env { VaultModeConfig::default() }; + // While anything resolves to orchestrator kind, every configured + // chain must carry an `[orchestrator.addresses]` entry — a missing + // address is a deploy error here, not a runtime surprise at + // initiation/detection time. Deliberately over-strict (an + // orchestrator asset listed only on Base still demands an entry for + // every other configured chain): assets live in the database, so + // parse time cannot narrow the requirement per asset. + if vault_mode_config.has_orchestrator_kind() { + for chain in &chains { + if vault_mode_config + .orchestrator_address_for(chain.network) + .is_none() + { + return Err(MissingOrchestratorAddress { + network: chain.network, + } + .into()); + } + } + } + Ok(Config { database_url: self.database_url, database_max_connections: self.database_max_connections, @@ -717,11 +826,21 @@ pub enum ConfigError { #[error("Failed to parse toml config file: {0}")] Toml(#[from] toml::de::Error), #[error( - "[orchestrator].address is required when any asset resolves to \ - orchestrator mode" + "[orchestrator.addresses] must have at least one entry when any \ + asset resolves to orchestrator mode" + )] + MissingOrchestratorAddresses, + #[error(transparent)] + MissingOrchestratorAddressForNetwork(#[from] MissingOrchestratorAddress), + #[error( + "Invalid [orchestrator.addresses] key '{key}': not a known network \ + (expected one of: base, ethereum, hyperevm)" + )] + UnknownOrchestratorNetwork { key: String }, + #[error( + "Invalid [orchestrator.addresses] entry '{0}': not a valid EVM \ + address" )] - MissingOrchestratorAddress, - #[error("Invalid [orchestrator].address '{0}': not a valid EVM address")] InvalidOrchestratorAddress(String), #[error("Invalid [assets] key '{symbol}': {error}")] InvalidAssetSymbol { @@ -751,7 +870,12 @@ struct TomlFile { #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct OrchestratorSection { - address: Option, + /// Per-network orchestrator contract addresses, keyed by the network's + /// wire name (`base`, `ethereum`, `hyperevm`). Each chain carries its + /// own deployment; keys and addresses are validated in + /// `resolve_vault_modes` (unknown networks and zero/malformed addresses + /// are startup errors). + addresses: Option>, default_vault_mode: Option, } @@ -791,9 +915,13 @@ pub(crate) fn load_vault_mode_config( /// Converts the raw TOML file into a validated `VaultModeConfig`. /// /// Validation rules: -/// - `default_vault_mode = "orchestrator"` requires `[orchestrator].address`. -/// - Any `[assets.].vault_mode = "orchestrator"` requires -/// `[orchestrator].address`. +/// - `default_vault_mode = "orchestrator"` requires a non-empty +/// `[orchestrator.addresses]` map. +/// - Any `[assets.].vault_mode = "orchestrator"` requires a non-empty +/// `[orchestrator.addresses]` map (the per-configured-chain requirement is +/// enforced at startup, where the chain list is known). +/// - `[orchestrator.addresses]` keys must be known network wire names and +/// values must be non-zero EVM addresses. /// - An unknown `vault_mode` string fails via serde (see `VaultModeStr`). /// - `[assets.]` keys are validated as underlying symbols and normalized /// to upper case (matching how assets are keyed everywhere else), so @@ -805,30 +933,37 @@ pub(crate) fn load_vault_mode_config( fn resolve_vault_modes( toml: &TomlFile, ) -> Result { - let orchestrator_address = - match toml.orchestrator.as_ref().and_then(|o| o.address.as_ref()) { - Some(addr_str) => { - let address = addr_str.parse::
().map_err(|_| { - ConfigError::InvalidOrchestratorAddress(addr_str.clone()) - })?; - if address.is_zero() { - return Err(ConfigError::InvalidOrchestratorAddress( - addr_str.clone(), - )); + let mut orchestrator_addresses = HashMap::new(); + if let Some(entries) = + toml.orchestrator.as_ref().and_then(|o| o.addresses.as_ref()) + { + for (network_key, addr_str) in entries { + let network = network_key.parse::().map_err(|_| { + ConfigError::UnknownOrchestratorNetwork { + key: network_key.clone(), } - Some(address) + })?; + let address = addr_str.parse::
().map_err(|_| { + ConfigError::InvalidOrchestratorAddress(addr_str.clone()) + })?; + if address.is_zero() { + return Err(ConfigError::InvalidOrchestratorAddress( + addr_str.clone(), + )); } - None => None, - }; + orchestrator_addresses.insert(network, address); + } + } - let resolve_mode = - |mode_str: &VaultModeStr| -> Result { + let resolve_kind = + |mode_str: &VaultModeStr| -> Result { match mode_str { - VaultModeStr::VaultDirect => Ok(VaultMode::VaultDirect), + VaultModeStr::VaultDirect => Ok(VaultModeKind::VaultDirect), VaultModeStr::Orchestrator => { - let address = orchestrator_address - .ok_or(ConfigError::MissingOrchestratorAddress)?; - Ok(VaultMode::Orchestrator { address }) + if orchestrator_addresses.is_empty() { + return Err(ConfigError::MissingOrchestratorAddresses); + } + Ok(VaultModeKind::Orchestrator) } } }; @@ -838,8 +973,8 @@ fn resolve_vault_modes( .as_ref() .and_then(|o| o.default_vault_mode.as_ref()) { - None => VaultMode::VaultDirect, - Some(mode_str) => resolve_mode(mode_str)?, + None => VaultModeKind::VaultDirect, + Some(mode_str) => resolve_kind(mode_str)?, }; let mut per_asset = HashMap::new(); @@ -852,15 +987,15 @@ fn resolve_vault_modes( .as_str() .to_string(); - let mode = resolve_mode(&asset_section.vault_mode)?; - if per_asset.insert(normalized.clone(), mode).is_some() { + let kind = resolve_kind(&asset_section.vault_mode)?; + if per_asset.insert(normalized.clone(), kind).is_some() { return Err(ConfigError::DuplicateAssetSymbol { symbol: normalized, }); } } - Ok(VaultModeConfig { per_asset, default, orchestrator_address }) + Ok(VaultModeConfig { per_asset, default, orchestrator_addresses }) } /// RPC URL uses a scheme that cannot be mapped to HTTP. @@ -1460,17 +1595,26 @@ mod tests { } const ORCH_ADDR: &str = "0x1234567890abcdef1234567890abcdef12345678"; + const ETH_ORCH_ADDR: &str = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"; fn orch_address() -> Address { ORCH_ADDR.parse().unwrap() } + fn eth_orch_address() -> Address { + ETH_ORCH_ADDR.parse().unwrap() + } + + fn base_addresses() -> HashMap { + HashMap::from([("base".to_string(), ORCH_ADDR.to_string())]) + } + #[test] fn no_config_file_every_asset_vault_direct() { let cfg = VaultModeConfig::default(); - assert_eq!(cfg.default, VaultMode::VaultDirect); + assert_eq!(cfg.default, VaultModeKind::VaultDirect); assert!(cfg.per_asset.is_empty()); - assert_eq!(cfg.orchestrator_address(), None); + assert_eq!(cfg.orchestrator_address_for(Network::Base), None); } // Pins the committed per-environment deploy configs (baked into the @@ -1489,7 +1633,11 @@ mod tests { let cfg = resolve_vault_modes(&toml_file) .unwrap_or_else(|error| panic!("{name} must resolve: {error}")); - assert_eq!(cfg.default, VaultMode::VaultDirect, "{name} not dark"); + assert_eq!( + cfg.default, + VaultModeKind::VaultDirect, + "{name} not dark" + ); assert!(cfg.per_asset.is_empty(), "{name} has asset overrides"); } } @@ -1505,17 +1653,20 @@ mod tests { assert_eq!( cfg.per_asset.get("RKLB").copied(), - Some(VaultMode::Orchestrator { address: orch_address() }) + Some(VaultModeKind::Orchestrator) + ); + assert_eq!(cfg.default, VaultModeKind::VaultDirect); + assert_eq!( + cfg.orchestrator_address_for(Network::Base), + Some(orch_address()) ); - assert_eq!(cfg.default, VaultMode::VaultDirect); - assert_eq!(cfg.orchestrator_address(), Some(orch_address())); } #[test] fn per_asset_override_to_orchestrator() { let toml = TomlFile { orchestrator: Some(OrchestratorSection { - address: Some(ORCH_ADDR.to_string()), + addresses: Some(base_addresses()), default_vault_mode: None, }), assets: HashMap::from([( @@ -1528,16 +1679,16 @@ mod tests { assert_eq!( cfg.per_asset.get("AAPL").copied(), - Some(VaultMode::Orchestrator { address: orch_address() }) + Some(VaultModeKind::Orchestrator) ); - assert_eq!(cfg.default, VaultMode::VaultDirect); + assert_eq!(cfg.default, VaultModeKind::VaultDirect); } #[test] fn per_asset_override_to_vault_direct_ignores_default() { let toml = TomlFile { orchestrator: Some(OrchestratorSection { - address: Some(ORCH_ADDR.to_string()), + addresses: Some(base_addresses()), default_vault_mode: Some(VaultModeStr::Orchestrator), }), assets: HashMap::from([( @@ -1550,19 +1701,16 @@ mod tests { assert_eq!( cfg.per_asset.get("TSLA").copied(), - Some(VaultMode::VaultDirect) - ); - assert_eq!( - cfg.default, - VaultMode::Orchestrator { address: orch_address() } + Some(VaultModeKind::VaultDirect) ); + assert_eq!(cfg.default, VaultModeKind::Orchestrator); } #[test] fn no_per_asset_override_uses_default_vault_mode() { let toml = TomlFile { orchestrator: Some(OrchestratorSection { - address: Some(ORCH_ADDR.to_string()), + addresses: Some(base_addresses()), default_vault_mode: Some(VaultModeStr::Orchestrator), }), assets: HashMap::new(), @@ -1570,10 +1718,7 @@ mod tests { let cfg = resolve_vault_modes(&toml).unwrap(); - assert_eq!( - cfg.default, - VaultMode::Orchestrator { address: orch_address() } - ); + assert_eq!(cfg.default, VaultModeKind::Orchestrator); } #[test] @@ -1582,16 +1727,16 @@ mod tests { let cfg = resolve_vault_modes(&toml).unwrap(); - assert_eq!(cfg.default, VaultMode::VaultDirect); + assert_eq!(cfg.default, VaultModeKind::VaultDirect); assert!(cfg.per_asset.is_empty()); - assert_eq!(cfg.orchestrator_address(), None); + assert_eq!(cfg.orchestrator_address_for(Network::Base), None); } #[test] - fn orchestrator_asset_without_address_is_startup_error() { + fn orchestrator_asset_without_addresses_is_startup_error() { let toml = TomlFile { orchestrator: Some(OrchestratorSection { - address: None, + addresses: None, default_vault_mode: None, }), assets: HashMap::from([( @@ -1602,15 +1747,15 @@ mod tests { assert!(matches!( resolve_vault_modes(&toml), - Err(ConfigError::MissingOrchestratorAddress) + Err(ConfigError::MissingOrchestratorAddresses) )); } #[test] - fn default_orchestrator_without_address_is_startup_error() { + fn default_orchestrator_without_addresses_is_startup_error() { let toml = TomlFile { orchestrator: Some(OrchestratorSection { - address: None, + addresses: Some(HashMap::new()), default_vault_mode: Some(VaultModeStr::Orchestrator), }), assets: HashMap::new(), @@ -1618,7 +1763,7 @@ mod tests { assert!(matches!( resolve_vault_modes(&toml), - Err(ConfigError::MissingOrchestratorAddress) + Err(ConfigError::MissingOrchestratorAddresses) )); } @@ -1626,7 +1771,10 @@ mod tests { fn invalid_orchestrator_address_is_startup_error() { let toml = TomlFile { orchestrator: Some(OrchestratorSection { - address: Some("not-an-address".to_string()), + addresses: Some(HashMap::from([( + "base".to_string(), + "not-an-address".to_string(), + )])), default_vault_mode: None, }), assets: HashMap::new(), @@ -1638,6 +1786,41 @@ mod tests { )); } + #[test] + fn unknown_orchestrator_network_key_is_startup_error() { + let toml = TomlFile { + orchestrator: Some(OrchestratorSection { + addresses: Some(HashMap::from([( + "solana".to_string(), + ORCH_ADDR.to_string(), + )])), + default_vault_mode: None, + }), + assets: HashMap::new(), + }; + + assert!(matches!( + resolve_vault_modes(&toml), + Err(ConfigError::UnknownOrchestratorNetwork { key }) if key == "solana" + )); + } + + // The pre-multichain `[orchestrator].address` form must fail loudly at + // startup (deny_unknown_fields), never parse as a dark config that + // silently dropped the address. + #[test] + fn legacy_single_address_key_is_parse_error() { + let legacy = r#" + [orchestrator] + address = "0x1234567890abcdef1234567890abcdef12345678" + "#; + + assert!( + toml::from_str::(legacy).is_err(), + "the retired [orchestrator].address key must be rejected" + ); + } + #[test] fn vault_mode_serde_wire_format_is_stable() { assert_eq!( @@ -1670,31 +1853,81 @@ mod tests { #[test] fn mode_for_prefers_per_asset_override_and_falls_back_to_default() { let cfg = VaultModeConfig::new( - HashMap::from([( - "AAPL".to_string(), - VaultMode::Orchestrator { address: orch_address() }, - )]), - VaultMode::VaultDirect, + HashMap::from([("AAPL".to_string(), VaultModeKind::Orchestrator)]), + VaultModeKind::VaultDirect, + HashMap::from([(Network::Base, orch_address())]), ); assert_eq!( - cfg.mode_for(&UnderlyingSymbol::new("AAPL").unwrap()), + cfg.mode_for( + &UnderlyingSymbol::new("AAPL").unwrap(), + Network::Base + ) + .unwrap(), VaultMode::Orchestrator { address: orch_address() } ); assert_eq!( - cfg.mode_for(&UnderlyingSymbol::new("TSLA").unwrap()), + cfg.mode_for( + &UnderlyingSymbol::new("TSLA").unwrap(), + Network::Base + ) + .unwrap(), VaultMode::VaultDirect ); } - // The address must survive resolution even while no asset resolves to + // The whole point of the per-network map: the same symbol's orchestrator + // mode resolves each network's own contract address. + #[test] + fn mode_for_resolves_a_different_address_per_network() { + let cfg = VaultModeConfig::new( + HashMap::from([("AAPL".to_string(), VaultModeKind::Orchestrator)]), + VaultModeKind::VaultDirect, + HashMap::from([ + (Network::Base, orch_address()), + (Network::Ethereum, eth_orch_address()), + ]), + ); + let aapl = UnderlyingSymbol::new("AAPL").unwrap(); + + assert_eq!( + cfg.mode_for(&aapl, Network::Base).unwrap(), + VaultMode::Orchestrator { address: orch_address() } + ); + assert_eq!( + cfg.mode_for(&aapl, Network::Ethereum).unwrap(), + VaultMode::Orchestrator { address: eth_orch_address() } + ); + } + + // Never a silent vault-direct fallback: an orchestrator-kind asset on a + // network with no address entry is a typed error. + #[test] + fn mode_for_missing_network_address_is_an_error() { + let cfg = VaultModeConfig::new( + HashMap::from([("AAPL".to_string(), VaultModeKind::Orchestrator)]), + VaultModeKind::VaultDirect, + HashMap::from([(Network::Base, orch_address())]), + ); + + assert_eq!( + cfg.mode_for( + &UnderlyingSymbol::new("AAPL").unwrap(), + Network::Ethereum + ) + .unwrap_err(), + MissingOrchestratorAddress { network: Network::Ethereum } + ); + } + + // The addresses must survive resolution even while no asset resolves to // orchestrator mode: the onboarding ops tooling (preflight, approvals) // runs against exactly this dark configuration, before the first cutover. #[test] - fn orchestrator_address_exposed_while_all_assets_vault_direct() { + fn orchestrator_addresses_exposed_while_all_assets_vault_direct() { let toml = TomlFile { orchestrator: Some(OrchestratorSection { - address: Some(ORCH_ADDR.to_string()), + addresses: Some(base_addresses()), default_vault_mode: None, }), assets: HashMap::new(), @@ -1702,22 +1935,13 @@ mod tests { let cfg = resolve_vault_modes(&toml).unwrap(); - assert_eq!(cfg.default, VaultMode::VaultDirect); + assert_eq!(cfg.default, VaultModeKind::VaultDirect); assert!(cfg.per_asset.is_empty()); - assert_eq!(cfg.orchestrator_address(), Some(orch_address())); - } - - #[test] - fn programmatic_config_carries_no_orchestrator_address() { - let cfg = VaultModeConfig::new( - HashMap::from([( - "AAPL".to_string(), - VaultMode::Orchestrator { address: orch_address() }, - )]), - VaultMode::VaultDirect, + assert_eq!( + cfg.orchestrator_address_for(Network::Base), + Some(orch_address()) ); - - assert_eq!(cfg.orchestrator_address(), None); + assert_eq!(cfg.orchestrator_address_for(Network::Ethereum), None); } #[test] @@ -1726,8 +1950,9 @@ mod tests { std::fs::write( file.path(), r#" - [orchestrator] - address = "0x1234567890abcdef1234567890abcdef12345678" + [orchestrator.addresses] + base = "0x1234567890abcdef1234567890abcdef12345678" + ethereum = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" [assets.RKLB] vault_mode = "orchestrator" @@ -1739,10 +1964,17 @@ mod tests { assert_eq!( cfg.per_asset.get("RKLB").copied(), - Some(VaultMode::Orchestrator { address: orch_address() }) + Some(VaultModeKind::Orchestrator) + ); + assert_eq!(cfg.default, VaultModeKind::VaultDirect); + assert_eq!( + cfg.orchestrator_address_for(Network::Base), + Some(orch_address()) + ); + assert_eq!( + cfg.orchestrator_address_for(Network::Ethereum), + Some(eth_orch_address()) ); - assert_eq!(cfg.default, VaultMode::VaultDirect); - assert_eq!(cfg.orchestrator_address(), Some(orch_address())); } #[test] @@ -1759,7 +1991,10 @@ mod tests { fn zero_orchestrator_address_is_startup_error() { let toml = TomlFile { orchestrator: Some(OrchestratorSection { - address: Some(Address::ZERO.to_string()), + addresses: Some(HashMap::from([( + "base".to_string(), + Address::ZERO.to_string(), + )])), default_vault_mode: None, }), assets: HashMap::from([( @@ -1783,7 +2018,7 @@ mod tests { fn lowercase_asset_key_normalizes_to_the_stored_symbol() { let toml = TomlFile { orchestrator: Some(OrchestratorSection { - address: Some(ORCH_ADDR.to_string()), + addresses: Some(base_addresses()), default_vault_mode: None, }), assets: HashMap::from([( @@ -1796,7 +2031,7 @@ mod tests { assert_eq!( cfg.per_asset.get("RKLB").copied(), - Some(VaultMode::Orchestrator { address: orch_address() }) + Some(VaultModeKind::Orchestrator) ); assert!(!cfg.per_asset.contains_key("rklb")); } @@ -1844,8 +2079,8 @@ mod tests { #[test] fn unknown_vault_mode_string_in_toml_is_parse_error() { let bad_toml = r#" - [orchestrator] - address = "0x1234567890abcdef1234567890abcdef12345678" + [orchestrator.addresses] + base = "0x1234567890abcdef1234567890abcdef12345678" [assets.AAPL] vault_mode = "not_a_valid_mode" @@ -1859,8 +2094,10 @@ mod tests { fn unknown_toml_key_is_parse_error() { let bad_toml = r#" [orchestrator] - address = "0x1234567890abcdef1234567890abcdef12345678" unexpected_key = "oops" + + [orchestrator.addresses] + base = "0x1234567890abcdef1234567890abcdef12345678" "#; let result = toml::from_str::(bad_toml); @@ -1871,7 +2108,7 @@ mod tests { fn vault_mode_for_uses_per_asset_override_then_default() { let toml = TomlFile { orchestrator: Some(OrchestratorSection { - address: Some(ORCH_ADDR.to_string()), + addresses: Some(base_addresses()), default_vault_mode: Some(VaultModeStr::Orchestrator), }), assets: HashMap::from([( @@ -1886,14 +2123,168 @@ mod tests { // Explicit VaultDirect override wins over the orchestrator default assert_eq!( - config.vault_mode_for(&UnderlyingSymbol::new("TSLA").unwrap()), + config + .vault_mode_for( + &UnderlyingSymbol::new("TSLA").unwrap(), + Network::Base + ) + .unwrap(), VaultMode::VaultDirect ); // Asset not in per_asset falls back to default (orchestrator) assert_eq!( - config.vault_mode_for(&UnderlyingSymbol::new("AAPL").unwrap()), + config + .vault_mode_for( + &UnderlyingSymbol::new("AAPL").unwrap(), + Network::Base + ) + .unwrap(), VaultMode::Orchestrator { address: orch_address() } ); } + + // The startup cross-check: while anything resolves to orchestrator kind, + // every configured chain needs an `[orchestrator.addresses]` entry — a + // missing one is a deploy error, not a runtime surprise. + #[tokio::test] + async fn startup_requires_an_address_for_every_configured_chain() { + let file = NamedTempFile::new().unwrap(); + std::fs::write( + file.path(), + r#" + [orchestrator.addresses] + ethereum = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" + + [assets.RKLB] + vault_mode = "orchestrator" + "#, + ) + .unwrap(); + + let config_path = file.path().display().to_string(); + let mut args = minimal_args(); + args.push("--config"); + args.push(&config_path); + let env = Env::try_parse_from(args).unwrap(); + + // minimal_args configures the Base chain, which has no entry above. + let error = env.into_config().map(|_| ()).unwrap_err(); + assert!(matches!( + error, + ConfigError::MissingOrchestratorAddressForNetwork( + MissingOrchestratorAddress { network: Network::Base } + ) + )); + } + + // The dark config (nothing orchestrator-kind) must NOT demand addresses: + // that is the standing prod deployment until the first cutover. + #[tokio::test] + async fn startup_dark_config_needs_no_addresses() { + let file = NamedTempFile::new().unwrap(); + std::fs::write( + file.path(), + r#" + [assets.RKLB] + vault_mode = "vault_direct" + "#, + ) + .unwrap(); + + let config_path = file.path().display().to_string(); + let mut args = minimal_args(); + args.push("--config"); + args.push(&config_path); + let env = Env::try_parse_from(args).unwrap(); + + env.into_config().expect("a dark config must not demand addresses"); + } + + /// Enables the Ethereum chain group on top of `minimal_args`, so the + /// startup cross-check iterates two configured chains. + fn two_chain_args() -> Vec<&'static str> { + let mut args = minimal_args(); + args.extend([ + "--chain-ethereum-rpc-url", + "wss://localhost:8546", + "--chain-ethereum-chain-id", + "1", + "--chain-ethereum-subgraph-url", + "http://localhost:0/eth-subgraph", + "--chain-ethereum-backfill-start-block", + "1", + ]); + args + } + + /// The cross-check must cover EVERY configured chain, not just Base: with + /// Base and Ethereum both configured, a map carrying only the Base entry + /// must name Ethereum as the missing network. + #[tokio::test] + async fn startup_two_chains_reject_a_single_address_entry() { + let file = NamedTempFile::new().unwrap(); + std::fs::write( + file.path(), + r#" + [orchestrator.addresses] + base = "0x1234567890abcdef1234567890abcdef12345678" + + [assets.RKLB] + vault_mode = "orchestrator" + "#, + ) + .unwrap(); + + let config_path = file.path().display().to_string(); + let mut args = two_chain_args(); + args.push("--config"); + args.push(&config_path); + let env = Env::try_parse_from(args).unwrap(); + + let error = env.into_config().map(|_| ()).unwrap_err(); + assert!(matches!( + error, + ConfigError::MissingOrchestratorAddressForNetwork( + MissingOrchestratorAddress { network: Network::Ethereum } + ) + )); + } + + /// The valid multichain shape: an entry per configured chain passes the + /// startup cross-check and each network resolves its own address. + #[tokio::test] + async fn startup_two_chains_accept_an_entry_per_chain() { + let file = NamedTempFile::new().unwrap(); + std::fs::write( + file.path(), + r#" + [orchestrator.addresses] + base = "0x1234567890abcdef1234567890abcdef12345678" + ethereum = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" + + [assets.RKLB] + vault_mode = "orchestrator" + "#, + ) + .unwrap(); + + let config_path = file.path().display().to_string(); + let mut args = two_chain_args(); + args.push("--config"); + args.push(&config_path); + let env = Env::try_parse_from(args).unwrap(); + + let config = + env.into_config().expect("an entry per chain must pass startup"); + let rklb = UnderlyingSymbol::new("RKLB").unwrap(); + assert_eq!( + config.vault_mode_for(&rklb, Network::Base).unwrap(), + VaultMode::Orchestrator { address: orch_address() } + ); + assert_eq!( + config.vault_mode_for(&rklb, Network::Ethereum).unwrap(), + VaultMode::Orchestrator { address: eth_orch_address() } + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 4fa31413..400e627c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -97,7 +97,8 @@ pub use alpaca::AlpacaConfig; pub use auth::{AuthConfig, InternalIpWhitelist, IpWhitelist, IssuerApiKey}; pub use chain::ChainConfig; pub use config::{ - Config, Environment, LogLevel, VaultMode, VaultModeConfig, setup_tracing, + Config, Environment, LogLevel, VaultMode, VaultModeConfig, VaultModeKind, + setup_tracing, }; pub use st0x_issuance_dto::Network; pub use telemetry::TelemetryGuard; diff --git a/src/mint/api/initiate.rs b/src/mint/api/initiate.rs index a72b6554..5844772d 100644 --- a/src/mint/api/initiate.rs +++ b/src/mint/api/initiate.rs @@ -69,8 +69,10 @@ pub(crate) async fn initiate_mint( // Resolve the asset's mode from config exactly once, here at initiate // time: the persisted anchor on `Initiated` is what every later // mode-dependent step derives from, even if the asset's configured - // vault_mode flips mid-flight. - let mint_mode = config.vault_mode_for(&request.underlying); + // vault_mode flips mid-flight. The address resolves per the request's + // network — each chain carries its own orchestrator deployment. + let mint_mode = + config.vault_mode_for(&request.underlying, request.network)?; let command = MintCommand::Initiate { issuer_request_id: issuer_request_id.clone(), @@ -119,7 +121,7 @@ mod tests { use super::initiate_mint; use crate::account::{AccountCommand, AlpacaAccountNumber, Email}; use crate::auth::FailedAuthRateLimiter; - use crate::config::{Config, VaultMode, VaultModeConfig}; + use crate::config::{Config, VaultMode, VaultModeConfig, VaultModeKind}; use crate::mint::api::test_utils::{ TestAccountAndAsset, TestHarness, test_config, }; @@ -280,9 +282,10 @@ mod tests { vault_mode_config: VaultModeConfig::new( HashMap::from([( underlying.as_str().to_string(), - VaultMode::Orchestrator { address: orchestrator_address }, + VaultModeKind::Orchestrator, )]), - VaultMode::VaultDirect, + VaultModeKind::VaultDirect, + HashMap::from([(Network::Base, orchestrator_address)]), ), ..test_config() }; @@ -365,6 +368,104 @@ mod tests { ); } + /// An orchestrator-kind asset whose network has no + /// `[orchestrator.addresses]` entry must refuse the mint with a 500 — + /// no `Initiated` event may anchor a mode without its address, and the + /// response body must not leak the network detail (it stays in the + /// server log). Unreachable in a validated deploy (the startup + /// cross-check requires an entry per configured chain); this pins the + /// fail-loud behavior of the gap. + #[traced_test] + #[tokio::test] + async fn initiate_missing_orchestrator_address_refuses_the_mint() { + let harness = TestHarness::new().await; + let TestAccountAndAsset { client_id, underlying, .. } = + harness.setup_account_and_asset().await; + + let TestHarness { + pool, + account_store, + asset_store: tokenized_asset_store, + mint_store, + .. + } = harness; + + let config = Config { + vault_mode_config: VaultModeConfig::new( + HashMap::from([( + underlying.as_str().to_string(), + VaultModeKind::Orchestrator, + )]), + VaultModeKind::VaultDirect, + HashMap::new(), + ), + ..test_config() + }; + + let rocket = rocket::build() + .manage(config) + .manage(FailedAuthRateLimiter::new().unwrap()) + .manage(mint_store) + .manage(account_store) + .manage(tokenized_asset_store) + .manage(pool.clone()) + .mount("/", routes![initiate_mint]); + let client = rocket::local::asynchronous::Client::tracked(rocket) + .await + .expect("valid rocket instance"); + + let request_body = serde_json::json!({ + "tokenization_request_id": "alp-no-address-1", + "qty": "100.5", + "underlying_symbol": underlying.as_str(), + "token_symbol": "tAAPL", + "network": "base", + "client_id": client_id, + "wallet_address": "0x1234567890abcdef1234567890abcdef12345678" + }); + let response = client + .post("/inkind/issuance") + .header(ContentType::JSON) + .header(Header::new( + "X-API-KEY", + "test-key-12345678901234567890123456", + )) + .remote("127.0.0.1:8000".parse().unwrap()) + .body(request_body.to_string()) + .dispatch() + .await; + + assert_eq!( + response.status(), + Status::InternalServerError, + "a missing per-network address must refuse the mint" + ); + let body = response.into_string().await.expect("valid response body"); + assert!( + !body.to_ascii_lowercase().contains("base"), + "the refusal body must not leak the network detail, got: {body}" + ); + + let mint_events: i64 = sqlx::query_scalar( + " + SELECT COUNT(*) + FROM events + WHERE aggregate_type = 'Mint' + ", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + mint_events, 0, + "no Initiated event may anchor a mode without its address" + ); + assert!(logs_contain_at!( + tracing::Level::ERROR, + &["Orchestrator address missing for mint network"] + )); + } + #[traced_test] #[tokio::test] async fn test_initiate_mint_rejects_frozen_asset() { diff --git a/src/mint/api/mod.rs b/src/mint/api/mod.rs index 5b5beee0..ea129ca0 100644 --- a/src/mint/api/mod.rs +++ b/src/mint/api/mod.rs @@ -12,6 +12,7 @@ use super::{ UnderlyingSymbol, }; use crate::account::{AccountView, view::find_by_client_id}; +use crate::config::MissingOrchestratorAddress; use crate::tokenized_asset::view::load_asset_by_network; use crate::underlying::load_freeze_status; @@ -68,6 +69,9 @@ pub(crate) enum MintApiError { #[error("Failed to execute mint command")] CommandExecutionFailed(#[source] AggregateError>), + #[error("Orchestrator address not configured for the asset's network")] + OrchestratorAddressMissing(#[from] MissingOrchestratorAddress), + #[error("Failed to query mint view")] MintViewQueryFailed(#[source] super::view::MintViewError), @@ -127,6 +131,17 @@ impl<'r> Responder<'r, 'static> for MintApiError { "Internal server error", ) } + // Deploy-validation gap (the startup cross-check requires an + // address per configured chain): refuse the mint loudly rather + // than anchor a mode without its address. + Self::OrchestratorAddressMissing(e) => { + error!(target: "mint", error = %e, + "Orchestrator address missing for mint network" + ); + MintErrorResponse::internal_server_error( + "Internal server error", + ) + } }; response.respond_to(req) diff --git a/src/redemption/transfer.rs b/src/redemption/transfer.rs index 576da824..d70c02fb 100644 --- a/src/redemption/transfer.rs +++ b/src/redemption/transfer.rs @@ -15,7 +15,7 @@ use crate::account::view::{AccountViewError, find_by_wallet}; use crate::account::{AccountView, AlpacaAccountNumber, ClientId}; use crate::bindings; use crate::burn_excess::exclusion::is_excluded_funding_log; -use crate::config::VaultModeConfig; +use crate::config::{MissingOrchestratorAddress, VaultModeConfig}; use crate::tokenized_asset::{ Network, TokenSymbol, TokenizedAssetView, UnderlyingSymbol, }; @@ -55,6 +55,13 @@ pub(crate) enum TransferProcessingError { AccountView(#[from] AccountViewError), #[error("No asset found for vault {vault}")] NoMatchingAsset { vault: Address }, + /// Deliberately absent from `is_non_transient`: a missing address is a + /// deploy-config error while the transfer is a real burn, so detection + /// retries and the vault's checkpoint holds until the config is fixed — + /// never skipping the redemption. The startup cross-check in + /// `Env::into_config` makes this unreachable in a validated deploy. + #[error(transparent)] + MissingOrchestratorAddress(#[from] MissingOrchestratorAddress), #[error( "Multiple enabled assets are bound to vault {vault}; refusing to \ attribute its redemptions to an arbitrary underlying" @@ -160,7 +167,10 @@ pub(crate) async fn detect_transfer( // Anchor the asset's currently-configured mode on the Detected event: // every later burn step derives from this persisted value, so an asset // cutover mid-redemption never switches an in-flight redemption's path. - let burn_mode = vault_modes.mode_for(&underlying); + // The address resolves per the asset's network; a missing entry errors + // loudly here (the startup cross-check makes it unreachable in a + // validated deploy) rather than anchoring a wrong mode. + let burn_mode = vault_modes.mode_for(&underlying, network)?; let command = RedemptionCommand::Detect { issuer_request_id: issuer_request_id.clone(), @@ -379,7 +389,7 @@ mod tests { use tracing_test::traced_test; use super::{TransferOutcome, TransferProcessingError, detect_transfer}; - use crate::config::{VaultMode, VaultModeConfig}; + use crate::config::{VaultMode, VaultModeConfig, VaultModeKind}; use crate::redemption::IssuerRedemptionRequestId; use crate::redemption::Redemption; use crate::redemption::RedemptionServices; @@ -467,9 +477,10 @@ mod tests { let vault = address!("0x1234567890abcdef1234567890abcdef12345678"); let bot_wallet = address!("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"); let ap_wallet = address!("0x9999999999999999999999999999999999999999"); - let orchestrator_mode = VaultMode::Orchestrator { - address: address!("0x00000000000000000000000000000000000000aa"), - }; + let orchestrator_address = + address!("0x00000000000000000000000000000000000000aa"); + let orchestrator_mode = + VaultMode::Orchestrator { address: orchestrator_address }; let pool = setup_test_db_with_asset(vault, Some(ap_wallet)).await; let store = setup_test_store(&pool); @@ -478,9 +489,13 @@ mod tests { let vault_modes = VaultModeConfig::new( std::collections::HashMap::from([( "AAPL".to_string(), - orchestrator_mode, + VaultModeKind::Orchestrator, + )]), + VaultModeKind::VaultDirect, + std::collections::HashMap::from([( + Network::Base, + orchestrator_address, )]), - VaultMode::VaultDirect, ); let value = U256::from_str_radix("100000000000000000000", 10).unwrap(); diff --git a/src/tokenized_asset/api.rs b/src/tokenized_asset/api.rs index ae54f4ab..b3410a35 100644 --- a/src/tokenized_asset/api.rs +++ b/src/tokenized_asset/api.rs @@ -18,16 +18,16 @@ use super::{ }; use crate::auth::{InternalAuth, IssuerAuth}; use crate::chain::ConfiguredNetworks; -use crate::config::{Config, VaultMode}; +use crate::config::{Config, VaultModeKind}; use crate::underlying::load_freeze_status; -impl From for VaultModeTag { - fn from(mode: VaultMode) -> Self { - match mode { - VaultMode::VaultDirect => Self::VaultDirect, +impl From for VaultModeTag { + fn from(kind: VaultModeKind) -> Self { + match kind { + VaultModeKind::VaultDirect => Self::VaultDirect, // The tag deliberately drops the orchestrator address — the // liquidity bot only needs to know an authorization is required. - VaultMode::Orchestrator { .. } => Self::Orchestrator, + VaultModeKind::Orchestrator => Self::Orchestrator, } } } @@ -190,7 +190,9 @@ pub(crate) async fn get_tokenized_asset_status( Status::InternalServerError })?; - let vault_mode = config.vault_mode_for(&underlying).into(); + // Only the kind crosses the wire (the tag drops the address), so this + // stays network-free: the mode is keyed by symbol alone. + let vault_mode = config.vault_mode_kind_for(&underlying).into(); Ok(Json(TokenizedAssetStatusResponse { underlying, @@ -1266,13 +1268,13 @@ mod tests { vault_mode_config: VaultModeConfig::new( HashMap::from([( "AAPL".to_string(), - VaultMode::Orchestrator { - address: address!( - "0xdddddddddddddddddddddddddddddddddddddddd" - ), - }, + VaultModeKind::Orchestrator, + )]), + VaultModeKind::VaultDirect, + HashMap::from([( + Network::Base, + address!("0xdddddddddddddddddddddddddddddddddddddddd"), )]), - VaultMode::VaultDirect, ), ..test_config() }; diff --git a/src/tokenized_asset/cli.rs b/src/tokenized_asset/cli.rs index d7d2ea94..1f8b572d 100644 --- a/src/tokenized_asset/cli.rs +++ b/src/tokenized_asset/cli.rs @@ -24,7 +24,7 @@ use crate::burn_excess::cli::{ }; use crate::config::{ DEFAULT_DATABASE_MAX_CONNECTIONS, DEFAULT_DATABASE_URL, LogLevel, - VaultMode, VaultModeConfig, load_vault_mode_config, setup_tracing, + VaultModeConfig, VaultModeKind, load_vault_mode_config, setup_tracing, }; use crate::prepare_event_sourced_startup; use crate::receipt_inventory::migration::{ @@ -213,9 +213,10 @@ struct ForceCompleteRedemptionArgs { #[derive(Args)] struct OrchestratorPreflightArgs { - /// TOML config file whose `[orchestrator].address` names the orchestrator - /// to check — the config file is the single source of truth for the - /// address, matching what the deployed service resolves. + /// TOML config file whose `[orchestrator.addresses]` entry for + /// `--network` names the orchestrator to check — the config file is the + /// single source of truth for the address, matching what the deployed + /// service resolves. #[arg(long, env = "CONFIG")] config: PathBuf, @@ -265,9 +266,10 @@ struct ApproveOrchestratorArgs { #[arg(value_parser = |value: &str| UnderlyingSymbol::new(value.to_ascii_uppercase()))] underlying: UnderlyingSymbol, - /// TOML config file whose `[orchestrator].address` names the approval's - /// spender — the config file is the single source of truth for the - /// address, matching what the deployed service resolves. + /// TOML config file whose `[orchestrator.addresses]` entry for + /// `--network` names the approval's spender — the config file is the + /// single source of truth for the address, matching what the deployed + /// service resolves. #[arg(long, env = "CONFIG")] config: PathBuf, @@ -311,9 +313,10 @@ struct VerifyOrchestratorSigningArgs { #[arg(value_parser = |value: &str| UnderlyingSymbol::new(value.to_ascii_uppercase()))] underlying: UnderlyingSymbol, - /// TOML config file whose `[orchestrator].address` names the orchestrator - /// the shapes target — the config file is the single source of truth for - /// the address, matching what the deployed service resolves. + /// TOML config file whose `[orchestrator.addresses]` entry for + /// `--network` names the orchestrator the shapes target — the config + /// file is the single source of truth for the address, matching what + /// the deployed service resolves. #[arg(long, env = "CONFIG")] config: PathBuf, @@ -355,13 +358,14 @@ struct VerifyOrchestratorSigningArgs { #[group(required = true, multiple = false)] struct MoveDestination { /// Explicit destination address — the wallet-rotation path. Refused if - /// it names the configured [orchestrator].address: the cutover path + /// it names the configured orchestrator address: the cutover path /// must read the orchestrator from the config, never a typed address. #[arg(long)] to: Option
, - /// Read the destination from [orchestrator].address in --config — the - /// cutover path, keeping the orchestrator address never-typed. + /// Read the destination from the network's [orchestrator.addresses] + /// entry in --config — the cutover path, keeping the orchestrator + /// address never-typed. #[arg(long)] to_configured_orchestrator: bool, } @@ -376,9 +380,9 @@ struct MoveReceiptsArgs { #[clap(flatten)] destination: MoveDestination, - /// TOML config file; its `[orchestrator].address` is the only source of - /// the orchestrator address, matching what the deployed service - /// resolves. + /// TOML config file; its `[orchestrator.addresses]` entry for + /// `--network` is the only source of the orchestrator address, matching + /// what the deployed service resolves. #[arg(long, env = "CONFIG")] config: PathBuf, @@ -650,7 +654,8 @@ async fn run_orchestrator_preflight( } let vault_modes = load_vault_mode_config(&args.config)?; - let orchestrator = orchestrator_address_from(&vault_modes, &args.config)?; + let orchestrator = + orchestrator_address_from(&vault_modes, &args.config, args.network)?; // Only the wallet address is used — the readiness facts (roles, // approvals) are keyed to the Turnkey bot wallet, and requiring the full @@ -706,7 +711,8 @@ async fn run_approve_orchestrator( ); } - let orchestrator = required_orchestrator_address(&args.config)?; + let orchestrator = + required_orchestrator_address(&args.config, args.network)?; let SignerConfig::Turnkey(turnkey_config) = args.signer.into_config()? else { @@ -800,7 +806,8 @@ async fn run_verify_orchestrator_signing( ); } - let orchestrator = required_orchestrator_address(&args.config)?; + let orchestrator = + required_orchestrator_address(&args.config, args.network)?; let SignerConfig::Turnkey(turnkey_config) = args.signer.into_config()? else { @@ -886,25 +893,28 @@ async fn run_move_receipts( ); } - let configured_orchestrator = - load_vault_mode_config(&args.config)?.orchestrator_address(); + let configured_orchestrator = load_vault_mode_config(&args.config)? + .orchestrator_address_for(args.network); let destination = match ( args.destination.to, args.destination.to_configured_orchestrator, ) { (None, true) => configured_orchestrator.ok_or_else(|| { anyhow::anyhow!( - "{} has no [orchestrator].address to move receipts to; add \ - the section, or state a wallet destination with --to", - args.config.display() + "{} has no [orchestrator.addresses] entry for '{}' to move \ + receipts to; add it, or state a wallet destination with \ + --to", + args.config.display(), + args.network ) })?, (Some(stated), false) => { if configured_orchestrator == Some(stated) { anyhow::bail!( - "--to {stated} is the configured [orchestrator].address; \ - use --to-configured-orchestrator so the cutover \ - destination is read from {}, never typed", + "--to {stated} is the configured orchestrator address \ + for '{}'; use --to-configured-orchestrator so the \ + cutover destination is read from {}, never typed", + args.network, args.config.display() ); } @@ -1127,26 +1137,32 @@ async fn verify_gas_readiness( /// address is refused: the config may stay dark (no asset needs /// `vault_mode = "orchestrator"`) and still carry the address these commands /// work against. -fn required_orchestrator_address(config: &Path) -> anyhow::Result
{ +fn required_orchestrator_address( + config: &Path, + network: Network, +) -> anyhow::Result
{ let vault_modes = load_vault_mode_config(config)?; - orchestrator_address_from(&vault_modes, config) + orchestrator_address_from(&vault_modes, config, network) } fn orchestrator_address_from( vault_modes: &VaultModeConfig, config: &Path, + network: Network, ) -> anyhow::Result
{ - let Some(orchestrator) = vault_modes.orchestrator_address() else { + let Some(orchestrator) = vault_modes.orchestrator_address_for(network) + else { anyhow::bail!( - "{} has no [orchestrator].address; add the section — the config \ - may stay dark (no asset needs vault_mode = \"orchestrator\")", + "{} has no [orchestrator.addresses] entry for '{network}'; add \ + it — the config may stay dark (no asset needs \ + vault_mode = \"orchestrator\")", config.display() ); }; // No zero-address guard here: `load_vault_mode_config` already refuses a - // zero `[orchestrator].address` at parse time, for the service and these - // commands alike. + // zero `[orchestrator.addresses]` entry at parse time, for the service + // and these commands alike. Ok(orchestrator) } @@ -1178,10 +1194,7 @@ async fn preflight_assets( let orchestrator_scoped: Vec<(UnderlyingSymbol, Address)> = listed .into_iter() .filter(|(underlying, _)| { - matches!( - vault_modes.mode_for(underlying), - VaultMode::Orchestrator { .. } - ) + vault_modes.kind_for(underlying) == VaultModeKind::Orchestrator }) .collect(); if orchestrator_scoped.is_empty() { @@ -2245,7 +2258,11 @@ mod tests { fn all_orchestrator_modes() -> VaultModeConfig { VaultModeConfig::new( std::collections::HashMap::new(), - VaultMode::Orchestrator { address: Address::repeat_byte(0xdd) }, + VaultModeKind::Orchestrator, + std::collections::HashMap::from([( + Network::Base, + Address::repeat_byte(0xdd), + )]), ) } @@ -2281,9 +2298,13 @@ mod tests { let sgov_only = VaultModeConfig::new( std::collections::HashMap::from([( "SGOV".to_string(), - VaultMode::Orchestrator { address: Address::repeat_byte(0xdd) }, + VaultModeKind::Orchestrator, + )]), + VaultModeKind::VaultDirect, + std::collections::HashMap::from([( + Network::Base, + Address::repeat_byte(0xdd), )]), - VaultMode::VaultDirect, ); let assets = preflight_assets(&admin.pool, Network::Base, &[], &sgov_only) @@ -2296,7 +2317,8 @@ mod tests { let all_vault_direct = VaultModeConfig::new( std::collections::HashMap::new(), - VaultMode::VaultDirect, + VaultModeKind::VaultDirect, + std::collections::HashMap::new(), ); let error = preflight_assets( &admin.pool, @@ -2438,7 +2460,7 @@ mod tests { std::fs::write( &config_path, format!( - "[orchestrator]\naddress = \"{orchestrator}\"\n\n\ + "[orchestrator.addresses]\nbase = \"{orchestrator}\"\n\n\ [assets.RKLB]\nvault_mode = \"orchestrator\"\n" ), ) @@ -2624,7 +2646,7 @@ mod tests { .unwrap_err(); assert!( - error.to_string().contains("[orchestrator].address"), + error.to_string().contains("[orchestrator.addresses]"), "the refusal must name the missing section, got {error}" ); } @@ -2887,8 +2909,8 @@ mod tests { let config_path = directory.join("issuance-config.toml"); std::fs::write( &config_path, - "[orchestrator]\n\ - address = \"0x1234567890abcdef1234567890abcdef12345678\"\n", + "[orchestrator.addresses]\n\ + base = \"0x1234567890abcdef1234567890abcdef12345678\"\n", ) .unwrap(); config_path @@ -3028,10 +3050,31 @@ mod tests { let dark = directory.path().join("dark.toml"); std::fs::write(&dark, "# dark: no [orchestrator] section\n").unwrap(); - let error = required_orchestrator_address(&dark).unwrap_err(); + let error = + required_orchestrator_address(&dark, Network::Base).unwrap_err(); assert!( - error.to_string().contains("[orchestrator].address"), - "the refusal must name the missing key, got {error}" + error.to_string().contains("[orchestrator.addresses]") + && error.to_string().contains("base"), + "the refusal must name the missing entry and network, got {error}" + ); + + // An entry for a DIFFERENT network must not satisfy the requested + // one — each chain carries its own orchestrator deployment. + let wrong_network = directory.path().join("wrong-network.toml"); + std::fs::write( + &wrong_network, + format!( + "[orchestrator.addresses]\nethereum = \"{}\"\n", + Address::repeat_byte(0xdd) + ), + ) + .unwrap(); + let error = + required_orchestrator_address(&wrong_network, Network::Base) + .unwrap_err(); + assert!( + error.to_string().contains("base"), + "the refusal must name the requested network, got {error}" ); // The zero address is refused by the config loader itself (before @@ -3040,10 +3083,11 @@ mod tests { let zeroed = directory.path().join("zero.toml"); std::fs::write( &zeroed, - format!("[orchestrator]\naddress = \"{}\"\n", Address::ZERO), + format!("[orchestrator.addresses]\nbase = \"{}\"\n", Address::ZERO), ) .unwrap(); - let error = required_orchestrator_address(&zeroed).unwrap_err(); + let error = + required_orchestrator_address(&zeroed, Network::Base).unwrap_err(); assert!( error.to_string().contains("not a valid EVM address"), "a zero address must be refused by the config loader, got {error}" @@ -3063,7 +3107,7 @@ mod tests { let error = run_verify_orchestrator_signing(args).await.unwrap_err(); assert!( - error.to_string().contains("[orchestrator].address"), + error.to_string().contains("[orchestrator.addresses]"), "the refusal must name the missing address, got {error}" ); } diff --git a/tests/harness/mod.rs b/tests/harness/mod.rs index 1447cc93..a811e65e 100644 --- a/tests/harness/mod.rs +++ b/tests/harness/mod.rs @@ -37,7 +37,7 @@ use st0x_issuance::test_utils::{ }; use st0x_issuance::{ AlpacaConfig, AuthConfig, ChainConfig, Config, Environment, IpWhitelist, - LogLevel, Network, SignerConfig, VaultMode, VaultModeConfig, + LogLevel, Network, SignerConfig, VaultModeConfig, VaultModeKind, }; /// The internal API key every harness-built config and request header share: @@ -1053,17 +1053,16 @@ pub fn tokens(amount: u64) -> U256 { } /// A `VaultModeConfig` putting one underlying in orchestrator mode over a -/// vault-direct default — the single-asset-pilot shape. +/// vault-direct default — the single-asset-pilot shape, with the +/// orchestrator address registered for the harness's Base network. pub fn orchestrator_vault_modes( underlying: &str, orchestrator_address: Address, ) -> VaultModeConfig { VaultModeConfig::new( - HashMap::from([( - underlying.to_string(), - VaultMode::Orchestrator { address: orchestrator_address }, - )]), - VaultMode::VaultDirect, + HashMap::from([(underlying.to_string(), VaultModeKind::Orchestrator)]), + VaultModeKind::VaultDirect, + HashMap::from([(Network::Base, orchestrator_address)]), ) } diff --git a/tests/multichain_orchestrator.rs b/tests/multichain_orchestrator.rs new file mode 100644 index 00000000..5f753257 --- /dev/null +++ b/tests/multichain_orchestrator.rs @@ -0,0 +1,401 @@ +//! Multichain orchestrator routing on two Anvil chains. +//! +//! Each network carries its own orchestrator deployment at its own address +//! (`[orchestrator.addresses]` in the TOML config). This suite proves the +//! per-network keying end to end: two chains, two orchestrators at +//! deliberately different addresses, one orchestrator-mode mint AND one +//! redemption per chain — every operation must land on its own network's +//! orchestrator and leave the other chain untouched. + +mod harness; + +use alloy::network::EthereumWallet; +use alloy::primitives::{Address, B256, Bytes, U256, b256}; +use alloy::providers::ProviderBuilder; +use alloy::signers::SignerSync; +use alloy::signers::local::PrivateKeySigner; +use httpmock::prelude::*; +use rocket::local::asynchronous::Client; +use serde_json::json; +use sqlx::sqlite::SqlitePoolOptions; +use st0x_issuance::bindings::IST0xOrchestratorV1::IST0xOrchestratorV1Instance; +use st0x_issuance::bindings::OffchainAssetReceiptVault::OffchainAssetReceiptVaultInstance; +use st0x_issuance::test_utils::{LocalEvm, ROLE_DEPOSIT, ROLE_WITHDRAW}; +use st0x_issuance::{ + ETHEREUM_TEST_CHAIN_ID, Network, VaultModeConfig, VaultModeKind, + initialize_rocket, +}; +use std::collections::HashMap; + +use crate::harness::{ + MintFlowRequest, TEST_API_KEY, confirm_mint_journal, initiate_mint_request, + tokens, +}; + +const USER_PRIVATE_KEY: B256 = + b256!("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); + +/// Signs the orchestrator's own `mintAuthDigest(token, to, amount, nonce)` +/// with the recipient's key, read from the given chain — the digest carries +/// that chain's EIP-712 domain (chainId + verifyingContract), so an +/// authorization is bound to one network's orchestrator. +async fn signed_mint_authorization( + evm: &LocalEvm, + orchestrator_address: Address, + token: Address, + recipient_signer: &PrivateKeySigner, + amount: U256, + nonce: B256, +) -> Result> { + let reader = harness::bot_provider(evm).await?; + let orchestrator = + IST0xOrchestratorV1Instance::new(orchestrator_address, &reader); + let digest = orchestrator + .mintAuthDigest(token, recipient_signer.address(), amount, nonce) + .call() + .await?; + let signature = recipient_signer.sign_hash_sync(&digest)?; + Ok(Bytes::from(signature.as_bytes().to_vec())) +} + +/// Delivers the liquidity bot's authorization via +/// `POST /internal/mints//authorization`. +async fn deliver_mint_authorization( + client: &Client, + tokenization_request_id: &str, + nonce: B256, + signature: &Bytes, +) { + let status = client + .post(format!( + "/internal/mints/{tokenization_request_id}/authorization" + )) + .header(rocket::http::ContentType::JSON) + .header(rocket::http::Header::new("X-API-KEY", TEST_API_KEY)) + .remote( + "127.0.0.1:8000".parse().expect("test client address must parse"), + ) + .body(json!({ "nonce": nonce, "signature": signature }).to_string()) + .dispatch() + .await + .status(); + + assert_eq!( + status, + rocket::http::Status::Ok, + "the mint authorization delivery must be accepted" + ); +} + +/// Fetches the decoded `Minted` logs at `orchestrator_address` on the given +/// chain. +async fn minted_logs( + evm: &LocalEvm, + orchestrator_address: Address, +) -> Result< + Vec, + Box, +> { + let reader = harness::bot_provider(evm).await?; + let orchestrator = + IST0xOrchestratorV1Instance::new(orchestrator_address, &reader); + Ok(orchestrator + .Minted_filter() + .from_block(0) + .query() + .await? + .into_iter() + .map(|(minted, _log)| minted) + .collect()) +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn orchestrator_operations_route_to_each_networks_own_orchestrator() +-> Result<(), Box> { + let base_evm = LocalEvm::new().await?; + let eth_evm = LocalEvm::with_chain_id(ETHEREUM_TEST_CHAIN_ID).await?; + + let user_signer = PrivateKeySigner::from_bytes(&USER_PRIVATE_KEY)?; + let user_wallet = user_signer.address(); + + base_evm.grant_certify_role(base_evm.wallet_address).await?; + base_evm.certify_vault(U256::MAX).await?; + let base_orchestrator = base_evm.deploy_orchestrator().await?; + + // Both Anvils deploy from the same key, so the default vault addresses + // collide across chains — and the service refuses one vault address on + // two networks. The Ethereum asset therefore lives on a freshly deployed + // vault; its extra deployments also shift the deployer nonce, so the + // Ethereum orchestrator lands at a genuinely different address and the + // per-network keying under test cannot hold vacuously. + let (eth_vault_address, eth_authorizer_address) = + eth_evm.deploy_additional_vault().await?; + assert_ne!(eth_vault_address, base_evm.vault_address); + harness::setup_roles_on_vault( + ð_evm, + eth_authorizer_address, + eth_vault_address, + user_wallet, + eth_evm.wallet_address, + ) + .await?; + + let eth_orchestrator = eth_evm.deploy_orchestrator().await?; + assert_ne!( + base_orchestrator, eth_orchestrator, + "the two chains must carry orchestrators at different addresses" + ); + // `deploy_orchestrator` wires DEPOSIT/WITHDRAW on the DEFAULT vault's + // authorizer only; the Ethereum asset's vault needs the same grants. + eth_evm + .grant_role_on_authorizer( + eth_authorizer_address, + ROLE_DEPOSIT, + eth_orchestrator, + ) + .await?; + eth_evm + .grant_role_on_authorizer( + eth_authorizer_address, + ROLE_WITHDRAW, + eth_orchestrator, + ) + .await?; + + let mock_alpaca = MockServer::start(); + let mint_callback_mock = + harness::alpaca_mocks::setup_mint_mocks(&mock_alpaca); + let (_redeem_mock, _poll_mock) = + harness::alpaca_mocks::setup_redemption_mocks(&mock_alpaca); + + let temp_dir = tempfile::tempdir()?; + let db_path = temp_dir.path().join("multichain_orchestrator.db"); + let db_url = format!("sqlite:{}?mode=rwc", db_path.display()); + + let pool = + SqlitePoolOptions::new().max_connections(1).connect(&db_url).await?; + sqlx::migrate!("./migrations").run(&pool).await?; + harness::preseed_tokenized_asset_into_pool( + &pool, + base_evm.vault_address, + "AAPL", + "tAAPL", + ) + .await?; + harness::preseed_tokenized_asset_into_pool_with_network( + &pool, + eth_vault_address, + "TSLA", + "tTSLA", + Network::Ethereum, + ) + .await?; + pool.close().await; + + let (mut config, _mock_subgraph) = + harness::create_multichain_config_with_db( + &db_url, + &mock_alpaca, + &base_evm, + ð_evm, + eth_vault_address, + )?; + config.vault_mode_config = VaultModeConfig::new( + HashMap::from([ + ("AAPL".to_string(), VaultModeKind::Orchestrator), + ("TSLA".to_string(), VaultModeKind::Orchestrator), + ]), + VaultModeKind::VaultDirect, + HashMap::from([ + (Network::Base, base_orchestrator), + (Network::Ethereum, eth_orchestrator), + ]), + ); + + let rocket = initialize_rocket(config).await?; + let client = rocket::local::asynchronous::Client::tracked(rocket).await?; + + let link_body = harness::setup_account(&client, user_wallet).await; + let client_id = link_body.client_id.to_string(); + + for (evm, orchestrator, vault_address, request, amount, nonce) in [ + ( + &base_evm, + base_orchestrator, + base_evm.vault_address, + MintFlowRequest { + client_id: &client_id, + tokenization_request_id: "alp-orch-base-aapl", + quantity: "50.0", + underlying: "AAPL", + token: "tAAPL", + network: Network::Base, + }, + tokens(50), + B256::with_last_byte(1), + ), + ( + ð_evm, + eth_orchestrator, + eth_vault_address, + MintFlowRequest { + client_id: &client_id, + tokenization_request_id: "alp-orch-eth-tsla", + quantity: "10.0", + underlying: "TSLA", + token: "tTSLA", + network: Network::Ethereum, + }, + tokens(10), + B256::with_last_byte(2), + ), + ] { + let tokenization_request_id = request.tokenization_request_id; + let issuer_request_id = + initiate_mint_request(&client, user_wallet, &request).await?; + + let signature = signed_mint_authorization( + evm, + orchestrator, + vault_address, + &user_signer, + amount, + nonce, + ) + .await?; + deliver_mint_authorization( + &client, + tokenization_request_id, + nonce, + &signature, + ) + .await; + + confirm_mint_journal( + &client, + tokenization_request_id, + &issuer_request_id, + ) + .await?; + } + + harness::wait_for_mock_hits(&mint_callback_mock, 2).await?; + + for (evm, vault_address, expected_amount) in [ + (&base_evm, base_evm.vault_address, tokens(50)), + (ð_evm, eth_vault_address, tokens(10)), + ] { + let user_provider = ProviderBuilder::new() + .wallet(EthereumWallet::from(user_signer.clone())) + .connect(&evm.endpoint) + .await?; + let vault = OffchainAssetReceiptVaultInstance::new( + vault_address, + &user_provider, + ); + let shares = harness::wait_for_shares(&vault, user_wallet).await?; + assert_eq!( + shares, expected_amount, + "each chain's mint must credit exactly its own quantity" + ); + } + + // The keying proof: each chain's own orchestrator carries exactly one + // Minted log, and that log's token/recipient/amount are the ones THIS + // chain's mint requested — a swapped or shared address would change an + // observed value here (the wrong chain's token or amount, or a second + // log on one orchestrator). + for (evm, orchestrator, expected_token, expected_amount) in [ + (&base_evm, base_orchestrator, base_evm.vault_address, tokens(50)), + (ð_evm, eth_orchestrator, eth_vault_address, tokens(10)), + ] { + let logs = minted_logs(evm, orchestrator).await?; + assert_eq!( + logs.len(), + 1, + "each chain's orchestrator must carry exactly its own mint" + ); + assert_eq!( + logs[0].token, expected_token, + "the Minted log must name this chain's vault token" + ); + assert_eq!( + logs[0].to, user_wallet, + "the Minted log must name the requested recipient" + ); + assert_eq!( + logs[0].amount, expected_amount, + "the Minted log must carry this chain's requested amount" + ); + } + + // Redemption legs: the burn path resolves its mode (and orchestrator + // address) from the DETECTED asset's network, so each chain's redemption + // must burn through that chain's own orchestrator. The mints above left + // the receipts in each orchestrator's custody, so the burn walk has + // inventory on both chains. + for (evm, orchestrator, vault_address, amount) in [ + (&base_evm, base_orchestrator, base_evm.vault_address, tokens(50)), + (ð_evm, eth_orchestrator, eth_vault_address, tokens(10)), + ] { + // The bot wallet approves THIS chain's orchestrator to pull the + // shares it is about to receive. + let bot = harness::bot_provider(evm).await?; + OffchainAssetReceiptVaultInstance::new(vault_address, &bot) + .approve(orchestrator, U256::MAX) + .send() + .await? + .get_receipt() + .await?; + + // The user sends the minted shares to the redemption (bot) wallet; + // the running service detects, calls Alpaca, and burns. + let user_provider = ProviderBuilder::new() + .wallet(EthereumWallet::from(user_signer.clone())) + .connect(&evm.endpoint) + .await?; + let vault = OffchainAssetReceiptVaultInstance::new( + vault_address, + &user_provider, + ); + vault + .transfer(evm.wallet_address, amount) + .send() + .await? + .get_receipt() + .await?; + + harness::wait_for_burn(&vault, evm.wallet_address).await?; + } + + // Same non-vacuous shape as the mint proof: each chain's orchestrator + // carries exactly one Burned log naming that chain's own token and + // amount — a swapped or shared address would double a log or show the + // wrong chain's facts. + for (evm, orchestrator, expected_token, expected_amount) in [ + (&base_evm, base_orchestrator, base_evm.vault_address, tokens(50)), + (ð_evm, eth_orchestrator, eth_vault_address, tokens(10)), + ] { + let reader = harness::bot_provider(evm).await?; + let contract = IST0xOrchestratorV1Instance::new(orchestrator, &reader); + let burned_logs = + contract.Burned_filter().from_block(0).query().await?; + assert_eq!( + burned_logs.len(), + 1, + "each chain's orchestrator must carry exactly its own burn" + ); + let (burned, _log) = &burned_logs[0]; + assert_eq!( + burned.token, expected_token, + "the Burned log must name this chain's vault token" + ); + assert_eq!( + burned.amount, expected_amount, + "the Burned log must carry this chain's redeemed amount" + ); + } + + Ok(()) +}