From 3cb00ac97f6d36bbdf0f3a9225c75a830f796625 Mon Sep 17 00:00:00 2001 From: jonas089 Date: Mon, 4 May 2026 16:26:49 +0200 Subject: [PATCH 01/10] feat(chain): allow configuring Hyperlane domain ID independently of chain ID Some chains (e.g. Eden testnet) use a Hyperlane domain ID that differs from their EVM chain ID. The previous behaviour relied on a single hardcoded match arm (31337 -> 131337), which does not extend to such chains. Add two ways to set a per-chain override that is stored on HyperlaneAddresses.domain_id: - solver-cli chain add --domain-id N - {NAME}_DOMAIN_ID env var, picked up by solver-cli deploy The Hyperlane relay-config generator now consults state domain_id first, matching the behaviour the rebalancer already had, with the hardcoded fallback only used when no override is set. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 11 +++++++++ solver-cli/src/commands/chain.rs | 34 ++++++++++++++++++++------- solver-cli/src/deployment/deployer.rs | 26 ++++++++++++++++++++ solver-cli/src/solver/config_gen.rs | 14 +++++++---- 4 files changed, 72 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index a2b2be12..ff3f525e 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,17 @@ EDEN_CHAIN_ID=3735928814 ANVIL1_CHAIN_ID=31337 ANVIL2_CHAIN_ID=31338 +# ============================================================================= +# HYPERLANE DOMAIN IDs (optional, only set when domain ≠ chain ID) +# ============================================================================= +# A chain's Hyperlane domain ID defaults to its EVM chain ID. Override here +# when the chain uses a distinct domain ID (e.g. Eden, or any chain whose ID +# collides with Hyperlane's hardcoded KnownHyperlaneDomain enum). +# Pattern: {NAME}_DOMAIN_ID — picked up by `solver-cli deploy`. +# Equivalent CLI flag for `chain add`: --domain-id . +# +#EDEN_DOMAIN_ID= + # ============================================================================= # PRIVATE KEYS (without 0x prefix) # ============================================================================= diff --git a/solver-cli/src/commands/chain.rs b/solver-cli/src/commands/chain.rs index d2137553..67e15cbc 100644 --- a/solver-cli/src/commands/chain.rs +++ b/solver-cli/src/commands/chain.rs @@ -51,6 +51,12 @@ pub enum ChainCommand { #[arg(long)] warp_token: Option, + /// Hyperlane domain ID. Defaults to the EVM chain ID; set this when the + /// chain's domain ID differs (e.g. Eden testnet, or chains that collide + /// with Hyperlane's hardcoded KnownHyperlaneDomain enum). + #[arg(long)] + domain_id: Option, + /// Project directory #[arg(long)] dir: Option, @@ -125,6 +131,7 @@ struct ChainAddParams { tokens: Vec, default_decimals: u8, warp_token: Option, + domain_id: Option, dir: Option, } @@ -141,6 +148,7 @@ impl ChainCommand { token, decimals, warp_token, + domain_id, dir, } => { Self::add( @@ -154,6 +162,7 @@ impl ChainCommand { tokens: token, default_decimals: decimals, warp_token, + domain_id, dir, }, output, @@ -176,6 +185,7 @@ impl ChainCommand { tokens, default_decimals, warp_token, + domain_id, dir, } = params; let out = OutputFormatter::new(output); @@ -209,18 +219,23 @@ impl ChainCommand { )); } - // Build contracts struct - let hyperlane = warp_token - .as_ref() - .map(|addr| crate::state::HyperlaneAddresses { - domain_id: None, + // Build contracts struct. + // Construct HyperlaneAddresses if either a warp_token or domain_id was supplied. + let hyperlane = if warp_token.is_some() || domain_id.is_some() { + Some(crate::state::HyperlaneAddresses { + domain_id, mailbox: None, merkle_tree_hook: None, validator_announce: None, igp: None, - warp_token: Some(addr.clone()), - warp_token_type: Some("collateral".to_string()), - }); + warp_token: warp_token.clone(), + warp_token_type: warp_token + .as_ref() + .map(|_| "collateral".to_string()), + }) + } else { + None + }; let contracts = ContractAddresses { input_settler_escrow: Some(input_settler.clone()), @@ -235,6 +250,9 @@ impl ChainCommand { if let Some(ref addr) = warp_token { print_address("Warp token router", addr); } + if let Some(id) = domain_id { + print_kv("Hyperlane domain ID", id); + } // Build tokens map let mut token_map: HashMap = HashMap::new(); diff --git a/solver-cli/src/deployment/deployer.rs b/solver-cli/src/deployment/deployer.rs index de46dac9..c9462d78 100644 --- a/solver-cli/src/deployment/deployer.rs +++ b/solver-cli/src/deployment/deployer.rs @@ -151,6 +151,10 @@ impl Deployer { ); } + // Apply {NAME}_DOMAIN_ID env-var override (takes precedence over the + // value pulled from hyperlane-addresses.json). + Self::apply_domain_id_override(&mut chain_config); + // Insert into state by chain_id state.chains.insert(chain_config.chain_id, chain_config); } @@ -174,6 +178,28 @@ impl Deployer { Ok(value) } + /// Apply a `{NAME}_DOMAIN_ID` env-var override onto chain_config, if present. + /// Creates a HyperlaneAddresses entry if one doesn't yet exist on the chain. + fn apply_domain_id_override(chain_config: &mut ChainConfig) { + let var = format!("{}_DOMAIN_ID", chain_config.name.to_uppercase()); + let Ok(raw) = std::env::var(&var) else { + return; + }; + let Ok(domain_id) = raw.parse::() else { + info!("Ignoring {} (not a valid u64): {}", var, raw); + return; + }; + let hyperlane = chain_config + .contracts + .hyperlane + .get_or_insert_with(HyperlaneAddresses::default); + hyperlane.domain_id = Some(domain_id); + info!( + " Hyperlane domain ID for {} (from {}): {}", + chain_config.name, var, domain_id + ); + } + /// Populate token and Hyperlane addresses from the deployment artifacts fn populate_tokens_from_hyperlane( chain_config: &mut ChainConfig, diff --git a/solver-cli/src/solver/config_gen.rs b/solver-cli/src/solver/config_gen.rs index 0fac8804..82202b88 100644 --- a/solver-cli/src/solver/config_gen.rs +++ b/solver-cli/src/solver/config_gen.rs @@ -548,7 +548,9 @@ poll_interval_seconds = 3 }, "chainId": chain.chain_id, "displayName": display_name, - "domainId": Self::hyperlane_domain_id(chain.chain_id), + "domainId": hyp + .and_then(|h| h.domain_id) + .unwrap_or_else(|| Self::hyperlane_domain_id(chain.chain_id)), "isTestnet": true, "name": chain.name, "nativeToken": { @@ -647,10 +649,12 @@ poll_interval_seconds = 3 .context("Failed to serialize Hyperlane relayer config") } - /// Map EVM chain ID to Hyperlane domain ID. - /// Domain IDs can differ from chain IDs to avoid conflicts with the Hyperlane agent's - /// hardcoded KnownHyperlaneDomain enum (e.g. 31337 is hardcoded as "test4"). - /// Using domain 131337 for chain 31337 lets us keep the "anvil1" name. + /// Fallback EVM chain ID → Hyperlane domain ID mapping. + /// Used only when the chain has no explicit `domain_id` in state. To override + /// per chain, pass `--domain-id` to `solver-cli chain add`, or set + /// `{NAME}_DOMAIN_ID` in `.env` before `solver-cli deploy`. + /// The 31337 → 131337 entry exists because the Hyperlane agent has 31337 + /// hardcoded in its KnownHyperlaneDomain enum as "test4". fn hyperlane_domain_id(chain_id: u64) -> u64 { match chain_id { 31337 => 131337, From fde70cd94aad9ce9836aaedab23cfc0795c17efb Mon Sep 17 00:00:00 2001 From: jonas089 Date: Mon, 4 May 2026 16:36:58 +0200 Subject: [PATCH 02/10] fix(rebalancer): refuse to silently use ERC20 as Hyperlane warp router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `collect_assets` previously fell back to the underlying ERC20 address as `collateral_token` when `chain.contracts.hyperlane.warp_token` was not set. The rebalancer then called `transferRemote` against the vanilla ERC20, which either reverted with a missing-selector error or — for HypNative warp routes — with `Native: amount exceeds msg.value`. The misconfig manifested at submit time, far away from the actual setup mistake. Replace the fallback with an explicit error that names the chain and asset and points at the field to set. Catch the same condition at `solver-cli configure` time so the error surfaces during setup rather than at rebalancer startup. Co-Authored-By: Claude Opus 4.7 (1M context) --- rebalancer/src/config.rs | 65 ++++++++++++++++++++++++- solver-cli/src/rebalancer/config_gen.rs | 59 ++++++++++++++++------ 2 files changed, 107 insertions(+), 17 deletions(-) diff --git a/rebalancer/src/config.rs b/rebalancer/src/config.rs index 9ec15b91..488694a6 100644 --- a/rebalancer/src/config.rs +++ b/rebalancer/src/config.rs @@ -421,11 +421,23 @@ fn collect_assets( .address .parse() .with_context(|| format!("Invalid address for {} on chain {}", symbol, chain_id))?; - let collateral_token = match warp_token { + // The rebalancer bridges via Hyperlane's `transferRemote`, which only + // exists on the warp router (HypERC20Collateral / HypSynthetic / HypNative). + // Refuse to fall back to the underlying ERC20 silently — that combination + // produces "Native: amount exceeds msg.value" / missing-selector reverts + // at submit time, far away from the misconfiguration. + let collateral_token: Address = match warp_token { Some(wt) => wt .parse() .with_context(|| format!("Invalid warp_token for chain {}", chain_id))?, - None => address, + None => bail!( + "Asset {} on chain {} has no Hyperlane warp router configured. \ + Set `chains.{}.contracts.hyperlane.warp_token` in state.json \ + (or pass `--warp-token` to `solver-cli chain add`).", + symbol, + chain_id, + chain_id + ), }; let asset_type = match token.token_type.as_deref() { Some(t) if t.eq_ignore_ascii_case("native") => AssetType::Native, @@ -770,6 +782,55 @@ service_url = "http://127.0.0.1:8080" assert!(err.to_string().contains("Missing [signer]")); } + #[test] + fn rejects_missing_warp_token() { + // State with USDC on both chains but no warp_token on chain 31338 — + // historically this silently fell back to using the ERC20 itself as + // the warp router, which then reverted at `transferRemote` time. + let state = serde_json::json!({ + "env": "local", + "chains": { + "31337": { + "name": "anvil1", "chain_id": 31337, + "rpc": "http://127.0.0.1:8545", + "contracts": { + "hyperlane": { "warp_token": "0x0000000000000000000000000000000000000A01" } + }, + "tokens": { + "USDC": { "address": "0x0000000000000000000000000000000000001111", "symbol": "USDC", "decimals": 6 } + }, + "deployer": null + }, + "31338": { + "name": "anvil2", "chain_id": 31338, + "rpc": "http://127.0.0.1:8546", + "contracts": {}, + "tokens": { + "USDC": { "address": "0x0000000000000000000000000000000000002222", "symbol": "USDC", "decimals": 6 } + }, + "deployer": null + } + }, + "solver": { + "address": "0x000000000000000000000000000000000000dEaD", + "operator_address": null, + "private_key_ref": "env", + "configured": true + }, + "users": {}, + "last_updated": "2025-01-01T00:00:00Z" + }) + .to_string(); + let (_dir, path) = setup_config(&state, minimal_toml()); + let err = RebalancerConfig::load(&path).expect_err("should fail"); + let msg = err.to_string(); + assert!( + msg.contains("no Hyperlane warp router configured"), + "unexpected error: {}", + msg + ); + } + #[test] fn rejects_invalid_transfer_bps_bounds() { let toml = r#" diff --git a/solver-cli/src/rebalancer/config_gen.rs b/solver-cli/src/rebalancer/config_gen.rs index e02a6ed5..0688eace 100644 --- a/solver-cli/src/rebalancer/config_gen.rs +++ b/solver-cli/src/rebalancer/config_gen.rs @@ -54,24 +54,53 @@ impl RebalancerConfigGenerator { anyhow::bail!("No chains configured"); } - // Verify at least one token exists on 2+ chains (so rebalancer has something to do) - let has_multi_chain_token = { - let mut by_symbol: BTreeMap = BTreeMap::new(); - for chain in state.chains.values() { - for token in chain.tokens.values() { - *by_symbol - .entry(token.symbol.to_ascii_uppercase()) - .or_default() += 1; - } + // Group tokens by symbol → list of (chain_id, chain_name) so we can detect + // both "no asset on 2+ chains" and "asset present without a warp router". + let mut by_symbol: BTreeMap> = BTreeMap::new(); + for chain in state.chains.values() { + for token in chain.tokens.values() { + by_symbol + .entry(token.symbol.to_ascii_uppercase()) + .or_default() + .push((chain.chain_id, chain.name.as_str())); } - by_symbol.values().any(|&count| count >= 2) - }; + } + let has_multi_chain_token = by_symbol.values().any(|chains| chains.len() >= 2); if !has_multi_chain_token { anyhow::bail!( "No asset found on at least two chains; cannot generate rebalancer config" ); } + // Every chain that participates in a multi-chain asset must have a Hyperlane + // warp router configured. Catching this here means a misconfig surfaces at + // `solver-cli configure` instead of at rebalancer startup, where the same + // check runs again as a safety net. + for (symbol, chains) in &by_symbol { + if chains.len() < 2 { + continue; + } + for (chain_id, chain_name) in chains { + let chain = &state.chains[chain_id]; + let warp_token = chain + .contracts + .hyperlane + .as_ref() + .and_then(|h| h.warp_token.as_deref()); + if warp_token.is_none() { + anyhow::bail!( + "Asset {} on chain {} ({}) has no Hyperlane warp router configured. \ + Set `chains.{}.contracts.hyperlane.warp_token` in state.json \ + (or pass `--warp-token` to `solver-cli chain add`).", + symbol, + chain_name, + chain_id, + chain_id, + ); + } + } + } + let forwarding_service_url = std::env::var("FORWARDING_BACKEND") .unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); let _: reqwest::Url = forwarding_service_url @@ -197,8 +226,8 @@ mod tests { merkle_tree_hook: None, validator_announce: None, igp: None, - warp_token: None, - warp_token_type: None, + warp_token: Some("0x0000000000000000000000000000000000009999".to_string()), + warp_token_type: Some("synthetic".to_string()), }), }, tokens: HashMap::from([( @@ -231,8 +260,8 @@ mod tests { merkle_tree_hook: None, validator_announce: None, igp: None, - warp_token: None, - warp_token_type: None, + warp_token: Some("0x0000000000000000000000000000000000009999".to_string()), + warp_token_type: Some("synthetic".to_string()), }), }, tokens: HashMap::from([( From e917a678d7cc4066653ad90b4c895d38ee4ad79a Mon Sep 17 00:00:00 2001 From: jonas089 Date: Mon, 4 May 2026 17:34:11 +0200 Subject: [PATCH 03/10] feat(cli): full Hyperlane warp config via CLI for any token on any EVM chain Adds the missing CLI surface so any Hyperlane warp token (collateral, synthetic, or native) on any EVM chain can be configured without manual state.json edits. State schema (back-compat): - TokenInfo gains optional warp_token and warp_token_type so a chain can host multiple tokens with distinct warp routers. Per-token values take precedence over the chain-level fallback in contracts.hyperlane.warp_token. CLI: - solver-cli token add gains --token-type (erc20|native), --warp-token , --warp-token-type . - solver-cli chain add gains --warp-token-type, --mailbox, --igp. - solver-cli chain add --warp-token no longer hardcodes warp_token_type = "collateral"; uses --warp-token-type if supplied, otherwise falls back to "collateral" only when warp_token alone is given. Consumers: - rebalancer collect_assets prefers token.warp_token over the chain-level fallback so multi-token chains work. - solver-cli configure validation enforces the same precedence and surfaces missing warp routers at setup time. - deployer populate_tokens_from_hyperlane writes per-token warp_token and warp_token_type from the Hyperlane artifact and now also reads igp. Tests: - Added per_token_warp_token_overrides_chain_level covering the override path with no chain-level warp_token set. Co-Authored-By: Claude Opus 4.7 (1M context) --- rebalancer/src/config.rs | 66 ++++++++++++++- solver-cli/src/commands/chain.rs | 81 +++++++++++++++--- solver-cli/src/commands/token.rs | 107 +++++++++++++++++++++--- solver-cli/src/deployment/deployer.rs | 33 ++++++-- solver-cli/src/rebalancer/config_gen.rs | 27 ++++-- solver-cli/src/state/types.rs | 14 +++- 6 files changed, 283 insertions(+), 45 deletions(-) diff --git a/rebalancer/src/config.rs b/rebalancer/src/config.rs index 488694a6..1e3743c8 100644 --- a/rebalancer/src/config.rs +++ b/rebalancer/src/config.rs @@ -194,6 +194,8 @@ struct StateToken { decimals: u8, #[serde(default)] token_type: Option, + #[serde(default)] + warp_token: Option, } // ── Config loading ───────────────────────────────────────────────────────── @@ -376,20 +378,27 @@ fn collect_assets( ) -> Result> { let chain_id_set: HashSet = chains.iter().map(|c| c.chain_id).collect(); - // Group tokens by symbol across chains + // Group tokens by symbol across chains. Per-token warp_token (set via + // `solver-cli token add --warp-token`) takes precedence over the chain-level + // `contracts.hyperlane.warp_token` fallback. This lets one chain host + // multiple tokens with distinct warp routers. let mut by_symbol: TokensBySymbol<'_> = BTreeMap::new(); for (chain_id, chain) in &state.chains { - let warp_token = chain + let chain_warp_token = chain .contracts .hyperlane .as_ref() .and_then(|h| h.warp_token.clone()); for token in chain.tokens.values() { + let warp_token = token + .warp_token + .clone() + .or_else(|| chain_warp_token.clone()); let normalized = token.symbol.to_ascii_uppercase(); by_symbol .entry(normalized) .or_default() - .push((*chain_id, token, warp_token.clone())); + .push((*chain_id, token, warp_token)); } } @@ -782,6 +791,57 @@ service_url = "http://127.0.0.1:8080" assert!(err.to_string().contains("Missing [signer]")); } + #[test] + fn per_token_warp_token_overrides_chain_level() { + // Chain has no chain-level warp_token; the per-token warp_token must + // be honored so the rebalancer can find the warp router. + let state = serde_json::json!({ + "env": "local", + "chains": { + "31337": { + "name": "anvil1", "chain_id": 31337, + "rpc": "http://127.0.0.1:8545", + "contracts": {}, + "tokens": { + "USDC": { + "address": "0x0000000000000000000000000000000000001111", + "symbol": "USDC", "decimals": 6, + "warp_token": "0x0000000000000000000000000000000000000A01" + } + }, + "deployer": null + }, + "31338": { + "name": "anvil2", "chain_id": 31338, + "rpc": "http://127.0.0.1:8546", + "contracts": {}, + "tokens": { + "USDC": { + "address": "0x0000000000000000000000000000000000002222", + "symbol": "USDC", "decimals": 6, + "warp_token": "0x0000000000000000000000000000000000000B01" + } + }, + "deployer": null + } + }, + "solver": { + "address": "0x000000000000000000000000000000000000dEaD", + "operator_address": null, "private_key_ref": "env", "configured": true + }, + "users": {}, "last_updated": "2025-01-01T00:00:00Z" + }) + .to_string(); + let (_dir, path) = setup_config(&state, minimal_toml()); + let config = RebalancerConfig::load(&path).expect("valid config"); + let usdc = &config.assets[0]; + let collateral_31337 = usdc.tokens[&31337].collateral_token; + assert_eq!( + format!("{:?}", collateral_31337).to_lowercase(), + "0x0000000000000000000000000000000000000a01" + ); + } + #[test] fn rejects_missing_warp_token() { // State with USDC on both chains but no warp_token on chain 31338 — diff --git a/solver-cli/src/commands/chain.rs b/solver-cli/src/commands/chain.rs index 67e15cbc..c90ac174 100644 --- a/solver-cli/src/commands/chain.rs +++ b/solver-cli/src/commands/chain.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{bail, Result}; use clap::Subcommand; use std::collections::HashMap; use std::env; @@ -9,6 +9,19 @@ use crate::state::{ChainConfig, ContractAddresses, StateManager, TokenInfo}; use crate::utils::*; use crate::OutputFormat; +fn validate_warp_token_type(t: &str) -> Result<()> { + match t.to_ascii_lowercase().as_str() { + "collateral" | "synthetic" | "native" => Ok(()), + other => bail!( + "Invalid --warp-token-type {:?}; expected \"collateral\", \"synthetic\", or \"native\"", + other + ), + } +} + +// `Add` carries a lot of optional Hyperlane fields by design (see `solver-cli +// chain add --help`); boxing each one would just hurt ergonomics. +#[allow(clippy::large_enum_variant)] #[derive(Subcommand)] pub enum ChainCommand { /// Add a chain with existing contract deployments @@ -45,12 +58,25 @@ pub enum ChainCommand { #[arg(long, default_value = "6")] decimals: u8, - /// Hyperlane warp token router address (HypERC20Collateral or HypERC20Synthetic). - /// Required for HypCollateral chains where the warp router differs from the ERC20. - /// Optional for HypSynthetic chains (the token address is used as fallback). + /// Default Hyperlane warp router address for tokens on this chain. + /// Tokens with their own `warp_token` (set via `solver-cli token add`) + /// override this value. #[arg(long)] warp_token: Option, + /// Default Hyperlane warp router type for this chain: + /// "collateral" | "synthetic" | "native". Per-token override via token add. + #[arg(long)] + warp_token_type: Option, + + /// Hyperlane mailbox address on this chain. + #[arg(long)] + mailbox: Option, + + /// Hyperlane interchain gas paymaster address on this chain. + #[arg(long)] + igp: Option, + /// Hyperlane domain ID. Defaults to the EVM chain ID; set this when the /// chain's domain ID differs (e.g. Eden testnet, or chains that collide /// with Hyperlane's hardcoded KnownHyperlaneDomain enum). @@ -131,6 +157,9 @@ struct ChainAddParams { tokens: Vec, default_decimals: u8, warp_token: Option, + warp_token_type: Option, + mailbox: Option, + igp: Option, domain_id: Option, dir: Option, } @@ -148,6 +177,9 @@ impl ChainCommand { token, decimals, warp_token, + warp_token_type, + mailbox, + igp, domain_id, dir, } => { @@ -162,6 +194,9 @@ impl ChainCommand { tokens: token, default_decimals: decimals, warp_token, + warp_token_type, + mailbox, + igp, domain_id, dir, }, @@ -185,9 +220,15 @@ impl ChainCommand { tokens, default_decimals, warp_token, + warp_token_type, + mailbox, + igp, domain_id, dir, } = params; + if let Some(ref t) = warp_token_type { + validate_warp_token_type(t)?; + } let out = OutputFormatter::new(output); let project_dir = dir.unwrap_or_else(|| env::current_dir().unwrap()); let state_mgr = StateManager::new(&project_dir); @@ -220,18 +261,25 @@ impl ChainCommand { } // Build contracts struct. - // Construct HyperlaneAddresses if either a warp_token or domain_id was supplied. - let hyperlane = if warp_token.is_some() || domain_id.is_some() { + // Construct HyperlaneAddresses if any Hyperlane field was supplied. + let any_hyperlane = warp_token.is_some() + || warp_token_type.is_some() + || domain_id.is_some() + || mailbox.is_some() + || igp.is_some(); + let hyperlane = if any_hyperlane { Some(crate::state::HyperlaneAddresses { domain_id, - mailbox: None, + mailbox: mailbox.clone(), merkle_tree_hook: None, validator_announce: None, - igp: None, + igp: igp.clone(), warp_token: warp_token.clone(), - warp_token_type: warp_token - .as_ref() - .map(|_| "collateral".to_string()), + // Default chain-level type to "collateral" only when a warp_token + // is supplied without an explicit type — preserves prior behaviour. + warp_token_type: warp_token_type + .clone() + .or_else(|| warp_token.as_ref().map(|_| "collateral".to_string())), }) } else { None @@ -250,6 +298,15 @@ impl ChainCommand { if let Some(ref addr) = warp_token { print_address("Warp token router", addr); } + if let Some(ref t) = warp_token_type { + print_kv("Warp router type", t); + } + if let Some(ref addr) = mailbox { + print_address("Mailbox", addr); + } + if let Some(ref addr) = igp { + print_address("IGP", addr); + } if let Some(id) = domain_id { print_kv("Hyperlane domain ID", id); } @@ -269,6 +326,8 @@ impl ChainCommand { symbol: parsed.symbol, decimals, token_type: "erc20".to_string(), + warp_token: None, + warp_token_type: None, }, ); } diff --git a/solver-cli/src/commands/token.rs b/solver-cli/src/commands/token.rs index 686a98a6..aea91514 100644 --- a/solver-cli/src/commands/token.rs +++ b/solver-cli/src/commands/token.rs @@ -1,5 +1,5 @@ use alloy::primitives::{Address, U256}; -use anyhow::Result; +use anyhow::{bail, Result}; use clap::Subcommand; use std::env; use std::path::PathBuf; @@ -10,6 +10,37 @@ use crate::state::{StateManager, TokenInfo}; use crate::utils::*; use crate::OutputFormat; +struct AddTokenParams { + chain_ref: String, + symbol: String, + address: String, + decimals: u8, + token_type: String, + warp_token: Option, + warp_token_type: Option, + dir: Option, +} + +fn validate_token_type(t: &str) -> Result<()> { + match t.to_ascii_lowercase().as_str() { + "erc20" | "native" => Ok(()), + other => bail!( + "Invalid --token-type {:?}; expected \"erc20\" or \"native\"", + other + ), + } +} + +fn validate_warp_token_type(t: &str) -> Result<()> { + match t.to_ascii_lowercase().as_str() { + "collateral" | "synthetic" | "native" => Ok(()), + other => bail!( + "Invalid --warp-token-type {:?}; expected \"collateral\", \"synthetic\", or \"native\"", + other + ), + } +} + #[derive(Subcommand)] pub enum TokenCommand { /// Add a token to a chain @@ -22,7 +53,7 @@ pub enum TokenCommand { #[arg(long)] symbol: String, - /// Token contract address + /// Token contract address (use any placeholder for native tokens — value is unused) #[arg(long)] address: String, @@ -30,6 +61,20 @@ pub enum TokenCommand { #[arg(long, default_value = "18")] decimals: u8, + /// Token type: "erc20" (default) or "native" + #[arg(long, default_value = "erc20")] + token_type: String, + + /// Hyperlane warp router address for this token (takes precedence over the + /// chain-level warp_token). Required when a chain has multiple tokens with + /// distinct routers. + #[arg(long)] + warp_token: Option, + + /// Hyperlane warp router type: "collateral", "synthetic", or "native". + #[arg(long)] + warp_token_type: Option, + /// Project directory #[arg(long)] dir: Option, @@ -94,8 +139,26 @@ impl TokenCommand { symbol, address, decimals, + token_type, + warp_token, + warp_token_type, dir, - } => Self::add(chain, symbol, address, decimals, dir, output).await, + } => { + Self::add( + AddTokenParams { + chain_ref: chain, + symbol, + address, + decimals, + token_type, + warp_token, + warp_token_type, + dir, + }, + output, + ) + .await + } TokenCommand::Remove { chain, symbol, dir } => { Self::remove(chain, symbol, dir, output).await } @@ -110,20 +173,28 @@ impl TokenCommand { } } - async fn add( - chain_ref: String, - symbol: String, - address: String, - decimals: u8, - dir: Option, - output: OutputFormat, - ) -> Result<()> { + async fn add(params: AddTokenParams, output: OutputFormat) -> Result<()> { + let AddTokenParams { + chain_ref, + symbol, + address, + decimals, + token_type, + warp_token, + warp_token_type, + dir, + } = params; let out = OutputFormatter::new(output); let project_dir = dir.unwrap_or_else(|| env::current_dir().unwrap()); let state_mgr = StateManager::new(&project_dir); out.header("Adding Token"); + validate_token_type(&token_type)?; + if let Some(ref t) = warp_token_type { + validate_warp_token_type(t)?; + } + // Load state let mut state = state_mgr.load_or_error().await?; @@ -156,6 +227,13 @@ impl TokenCommand { print_kv("Symbol", &symbol_upper); print_address("Address", &address); print_kv("Decimals", decimals); + print_kv("Token type", &token_type); + if let Some(ref wt) = warp_token { + print_address("Warp router", wt); + } + if let Some(ref wtt) = warp_token_type { + print_kv("Warp router type", wtt); + } // Add token chain.tokens.insert( @@ -164,7 +242,9 @@ impl TokenCommand { address: address.clone(), symbol: symbol_upper.clone(), decimals, - token_type: "erc20".to_string(), + token_type: token_type.clone(), + warp_token: warp_token.clone(), + warp_token_type: warp_token_type.clone(), }, ); } @@ -182,6 +262,9 @@ impl TokenCommand { "symbol": symbol_upper, "address": address, "decimals": decimals, + "token_type": token_type, + "warp_token": warp_token, + "warp_token_type": warp_token_type, }))?; } diff --git a/solver-cli/src/deployment/deployer.rs b/solver-cli/src/deployment/deployer.rs index c9462d78..f8b12b01 100644 --- a/solver-cli/src/deployment/deployer.rs +++ b/solver-cli/src/deployment/deployer.rs @@ -225,7 +225,25 @@ impl Deployer { .map(|s| s.to_string()) }; + // Per-token warp router address from artifact (defaults to chain-level + // warp_token; on a collateral chain the underlying ERC20 differs from + // the warp router, so we need both). + let chain_warp_token = chain_data + .get("warp_token") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let chain_warp_token_type = chain_data + .get("warp_token_type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + if let Some(addr) = token_address { + // On a collateral chain (`mock_usdc` exists), `addr` is the + // underlying ERC20 and the warp router is a separate contract. + // On synthetic/native chains, `addr` is the warp router itself + // and equals chain_warp_token. + let token_warp = chain_warp_token.clone(); + let token_warp_type = chain_warp_token_type.clone(); chain_config.tokens.insert( token_symbol.to_string(), TokenInfo { @@ -233,6 +251,8 @@ impl Deployer { symbol: token_symbol.to_string(), decimals: token_decimals, token_type: "erc20".to_string(), + warp_token: token_warp, + warp_token_type: token_warp_type, }, ); info!( @@ -243,7 +263,7 @@ impl Deployer { ); } - // Store Hyperlane contract addresses + // Store Hyperlane contract addresses (chain-level fallback) let hyperlane = HyperlaneAddresses { domain_id: chain_data.get("domain_id").and_then(|v| v.as_u64()), mailbox: chain_data @@ -258,15 +278,12 @@ impl Deployer { .get("validator_announce") .and_then(|v| v.as_str()) .map(|s| s.to_string()), - igp: None, - warp_token: chain_data - .get("warp_token") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - warp_token_type: chain_data - .get("warp_token_type") + igp: chain_data + .get("igp") .and_then(|v| v.as_str()) .map(|s| s.to_string()), + warp_token: chain_warp_token, + warp_token_type: chain_warp_token_type, }; chain_config.contracts.hyperlane = Some(hyperlane); } diff --git a/solver-cli/src/rebalancer/config_gen.rs b/solver-cli/src/rebalancer/config_gen.rs index 0688eace..97d9d839 100644 --- a/solver-cli/src/rebalancer/config_gen.rs +++ b/solver-cli/src/rebalancer/config_gen.rs @@ -72,30 +72,35 @@ impl RebalancerConfigGenerator { ); } - // Every chain that participates in a multi-chain asset must have a Hyperlane - // warp router configured. Catching this here means a misconfig surfaces at - // `solver-cli configure` instead of at rebalancer startup, where the same - // check runs again as a safety net. + // Every token participating in a multi-chain asset must have a Hyperlane + // warp router. Per-token warp_token (set via `solver-cli token add + // --warp-token`) takes precedence over the chain-level fallback. + // Catching this here means a misconfig surfaces at `solver-cli configure` + // instead of at rebalancer startup (where the same check is enforced). for (symbol, chains) in &by_symbol { if chains.len() < 2 { continue; } for (chain_id, chain_name) in chains { let chain = &state.chains[chain_id]; - let warp_token = chain + let token_warp = chain + .tokens + .values() + .find(|t| t.symbol.eq_ignore_ascii_case(symbol)) + .and_then(|t| t.warp_token.as_deref()); + let chain_warp = chain .contracts .hyperlane .as_ref() .and_then(|h| h.warp_token.as_deref()); - if warp_token.is_none() { + if token_warp.is_none() && chain_warp.is_none() { anyhow::bail!( "Asset {} on chain {} ({}) has no Hyperlane warp router configured. \ - Set `chains.{}.contracts.hyperlane.warp_token` in state.json \ - (or pass `--warp-token` to `solver-cli chain add`).", + Set it via `solver-cli token add --warp-token ` (per-token) \ + or `solver-cli chain add --warp-token ` (chain default).", symbol, chain_name, chain_id, - chain_id, ); } } @@ -237,6 +242,8 @@ mod tests { symbol: "USDC".to_string(), decimals: 6, token_type: "erc20".to_string(), + warp_token: None, + warp_token_type: None, }, )]), deployer: None, @@ -271,6 +278,8 @@ mod tests { symbol: "USDC".to_string(), decimals: 6, token_type: "erc20".to_string(), + warp_token: None, + warp_token_type: None, }, )]), deployer: None, diff --git a/solver-cli/src/state/types.rs b/solver-cli/src/state/types.rs index b7584561..8a522a45 100644 --- a/solver-cli/src/state/types.rs +++ b/solver-cli/src/state/types.rs @@ -136,7 +136,7 @@ impl ContractAddresses { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokenInfo { - /// Token contract address + /// Token contract address (placeholder allowed for native tokens) pub address: String, /// Token symbol @@ -145,9 +145,19 @@ pub struct TokenInfo { /// Token decimals pub decimals: u8, - /// Token type (erc20, native) + /// Token type ("erc20" or "native") #[serde(default = "default_token_type")] pub token_type: String, + + /// Hyperlane warp router address for this token, if any. + /// Takes precedence over `chain.contracts.hyperlane.warp_token`. + /// Set per-token when a chain hosts multiple assets with distinct routers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warp_token: Option, + + /// Hyperlane warp router type: "collateral" | "synthetic" | "native". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warp_token_type: Option, } fn default_token_type() -> String { From 04052b1fb1ab8f2eeccec9a4bd5c54156740f2f5 Mon Sep 17 00:00:00 2001 From: jonas089 Date: Mon, 4 May 2026 17:37:11 +0200 Subject: [PATCH 04/10] docs: split setup so users wire configs via solver-cli explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make setup` previously chained init + deploy + configure + fund + fund-operator + fund-user, which hid the wiring behind one button. Anyone trying to add a real chain (Eden, Arbitrum) had no idea which step did what or which CLI commands would have set up the same state. Changes: - `make setup` now stops after `init + deploy-permit2 + deploy`. Prints the follow-up commands the user should run themselves: `solver-cli chain list`, `solver-cli token list`, `solver-cli configure`, `make fund`, etc. - `make setup-demo` is the old behaviour for the one-button local demo, explicitly wired as `setup + configure + fund + fund-operator + fund-user`. - `scripts/setup.sh` (used by `make mvp`) now calls `setup-demo` so the auto-demo path keeps working. README rewrite: - "Path A — CLI walkthrough" walks through the manual sequence with comments on what each step writes/reads. - "Path B — one-button demo" documents `make mvp` / `make setup-demo`. - New "Adding external chains" section showing `chain add` and `token add` with --warp-token / --warp-token-type / --mailbox / --igp / --domain-id / --token-type, including the collateral / synthetic / native cases. - "Finding the addresses you need" table — Hyperlane registry, `cast call wrappedToken()`, etc. - Per-token vs chain-level warp router precedence documented. - Make + CLI command tables updated to match the new flag surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 33 ++++--- README.md | 222 +++++++++++++++++++++++++++++++++-------------- scripts/setup.sh | 4 +- 3 files changed, 182 insertions(+), 77 deletions(-) diff --git a/Makefile b/Makefile index 4e1950d4..1006a9fc 100644 --- a/Makefile +++ b/Makefile @@ -327,21 +327,32 @@ balances: build # Full Setup & Lifecycle # ============================================================================ -## setup: Full setup (init + deploy + configure + fund) -setup: init deploy-permit2 deploy configure fund fund-operator fund-user +## setup: Bring up local chains + deploy contracts ONLY. Does not configure or fund. +## Configuration and funding are explicit follow-up steps so you can see the wiring. +setup: init deploy-permit2 deploy @echo "" - @echo "Setup complete! Next steps:" - @echo " 1. make aggregator - Start OIF aggregator (in separate terminal)" - @echo " 2. make solver - Start solver service (in another terminal)" - @echo " 3. make operator - Start oracle operator service (in another terminal)" - @echo " 4. make rebalancer - Start rebalancer service (in another terminal)" - @echo " 5. make intent - Submit a test intent" - @echo " 6. make balances - Check balances" + @echo "Contracts deployed. State written to .config/state.json." + @echo "" + @echo "Next steps (run each one yourself — see README for what they do):" + @echo " 1. solver-cli chain list # inspect deployed chains" + @echo " 2. solver-cli token list # inspect tokens populated from Hyperlane artifacts" + @echo " 3. solver-cli configure # generate .config/{solver,oracle,rebalancer,aggregator}.* files" + @echo " 4. make fund fund-operator fund-user" + @echo " 5. make aggregator | make solver | make operator | make rebalancer (in separate terminals)" + @echo "" + @echo "Adding external chains/tokens? See README -> 'Adding external chains' for the CLI walkthrough." .PHONY: setup -## reset: Clean and reinitialize everything +## setup-demo: Convenience wrapper that runs setup + configure + fund. Use this for the +## one-button local demo flow; for production wiring, run the individual steps. +setup-demo: setup configure fund fund-operator fund-user + @echo "" + @echo "Demo setup complete! Start services with: make aggregator solver operator" +.PHONY: setup-demo + +## reset: Clean and reinitialize everything (demo path) reset: clean - @$(MAKE) setup FORCE=1 + @$(MAKE) setup-demo FORCE=1 .PHONY: reset ## frontend: Start the frontend (backend API + Next.js dev server) diff --git a/README.md b/README.md index 6537cff5..ea82ab25 100644 --- a/README.md +++ b/README.md @@ -20,54 +20,136 @@ This CLI deploys OIF contracts, runs a solver, and executes cross-chain token tr - [Rust](https://rustup.rs/) - Build the CLI - **Testnet ETH** - Get testnet ETH from a [faucet](https://sepoliafaucet.com) -## Quick Start: E2E Test +## Quick Start -### Option 1: Direct to Chain (Simpler) +There are two paths: the **CLI walkthrough** (recommended — teaches you the wiring) and the **one-button demo** (for someone who just wants to see it run end-to-end). -```bash -# 1. Start local EVM chain -make start - -# 2. Configure environment -cp .env.example .env -# Edit .env with your SEPOLIA_PK (must have Sepolia ETH for gas!) +### Path A — CLI walkthrough (recommended) -# 3. Full setup (build, deploy, configure, fund) -make clean && make setup +`make setup` deliberately stops after deploying contracts; you generate configs and fund accounts yourself with `solver-cli` so you understand each step. This is also the only way to wire **external chains** (Eden, Arbitrum, etc.) and **non-default tokens**. -# 4. Start solver (in separate terminal) -make solver +```bash +# 1. Configure environment +cp .env.example .env +# Edit .env: per-chain {NAME}_RPC + {NAME}_PK, plus SOLVER_PRIVATE_KEY, +# REBALANCER_PRIVATE_KEY (== SOLVER_PRIVATE_KEY), ORACLE_OPERATOR_PK (≠ solver). -# 5. Start oracle operator (in another separate terminal) -make operator +# 2. Start local Anvil chains + Hyperlane (Docker) +make start -# 6. Submit intent and check balances (in original terminal) -make balances -make mint +# 3. Deploy OIF contracts to every chain in your .env +make setup +# ↳ runs: init + deploy-permit2 + solver-cli deploy +# ↳ writes .config/state.json with chain IDs, contract addresses, +# and (for local stacks) Hyperlane warp router + token addresses +# pulled from .config/hyperlane-addresses.json + +# 4. Inspect what landed in state.json +solver-cli chain list +solver-cli token list + +# 5. Generate the four service configs from state.json +solver-cli configure +# ↳ writes: .config/solver.toml, .config/oracle.toml, +# .config/rebalancer.toml, .config/aggregator.json +# ↳ enforces every multi-chain asset has a Hyperlane warp router + +# 6. Fund accounts (each one is explicit so you can see what's happening) +make fund # mint USDC to the solver on the collateral chain +make fund-operator # send ETH to the oracle operator on every chain +make fund-user # send ETH to the test user on every chain + +# 7. Start services (separate terminals) +make aggregator # Terminal 1 +make solver # Terminal 2 +make operator # Terminal 3 +make rebalancer # Terminal 4 (optional) + +# 8. Submit a test intent and watch balances make balances make intent make balances ``` -### Option 2: With Aggregator (Recommended for Multi-Solver) +### Path B — one-button demo ```bash -# 1-3. Same as above (start chain, configure, setup) +make mvp # spawns chains, deploys, configures, funds, starts every service +# ... or, without the frontend / services: +make setup-demo # = setup + configure + fund + fund-operator + fund-user +``` -# 4. Start aggregator (Terminal 1) -make aggregator +`setup-demo` is the old all-in-one `setup` target; the new `setup` stops after deploy. -# 5. Start solver (Terminal 2) -make solver +## Adding external chains -# 6. Start oracle operator (Terminal 3) -make operator +To wire a real testnet/mainnet (e.g. Eden, Arbitrum) you'll use `solver-cli chain add` and `solver-cli token add`. There is no Docker step — the chain already exists. -# 7. Use aggregator API or CLI -curl http://localhost:4000/api/v1/solvers -make intent +```bash +# 1. Add the chain to .env (CLI auto-detects from {NAME}_RPC + {NAME}_PK): +echo 'EDEN_RPC=https://eden-rpc.example.com' >> .env +echo 'EDEN_PK=0x...your-deployer-key...' >> .env + +# Optional: if Eden's Hyperlane domain ID differs from its EVM chain ID, also: +echo 'EDEN_DOMAIN_ID=12345' >> .env + +# 2. (Option A) Deploy OIF contracts on Eden: +solver-cli deploy --chains eden + +# 2. (Option B) If contracts are already deployed, register them: +solver-cli chain add \ + --name eden \ + --rpc https://eden-rpc.example.com \ + --input-settler 0x... \ + --output-settler 0x... \ + --oracle 0x... \ + --domain-id 12345 \ + --mailbox 0x... \ + --igp 0x... \ + --warp-token 0x...HypERC20Collateral... \ + --warp-token-type collateral + +# 3. Add a token. For an ERC20 with its own Hyperlane warp route: +solver-cli token add \ + --chain eden \ + --symbol USDC \ + --address 0x...UnderlyingERC20... \ + --decimals 6 \ + --token-type erc20 \ + --warp-token 0x...HypERC20Collateral... \ + --warp-token-type collateral + +# For a synthetic chain (router IS the ERC20), pass the same address as both: +solver-cli token add --chain eden --symbol USDC \ + --address 0x...HypSynthetic... --decimals 6 \ + --warp-token 0x...HypSynthetic... --warp-token-type synthetic + +# For a native warp route (HypNative wraps ETH): +solver-cli token add --chain eden --symbol ETH \ + --address 0x0000000000000000000000000000000000000000 \ + --decimals 18 --token-type native \ + --warp-token 0x...HypNative... --warp-token-type native + +# 4. Regenerate configs +solver-cli configure ``` +### Finding the addresses you need + +| Address | Where to get it | +| --- | --- | +| Hyperlane mailbox / IGP / warp routers on real chains | Hyperlane registry: | +| Hyperlane domain ID | Same registry — `metadata.yaml` has `domainId`. Defaults to chain ID if you don't override. | +| Deployed OIF contracts on local stack | `solver-cli chain list` (reads `.config/state.json` written by `make setup`) | +| Token contract addresses | Token issuer docs, block explorer, or — for newly deployed warp routes — `cast call "wrappedToken()(address)"` | +| Underlying ERC20 vs warp router | On a **collateral** chain they differ; `cast call "wrappedToken()(address)"` returns the underlying ERC20. On a **synthetic** chain they are the same contract. | + +### Per-token vs chain-level warp router + +- The chain-level `--warp-token` on `chain add` is a **default** for every token on that chain. +- The per-token `--warp-token` on `token add` is **per-asset** and overrides the chain-level value. +- You need the per-token form when one chain has multiple tokens with different warp routers (e.g. USDC and USDT each on their own `HypERC20Collateral`). + ## Environment Setup Chains are configured with the pattern `{CHAIN}_RPC` and `{CHAIN}_PK`: @@ -93,47 +175,59 @@ When `type = "env"`, the operator loads `ORACLE_OPERATOR_PK` at runtime (for exa ## Make Commands -| Command | Description | -| ----------------- | -------------------------------------------------------- | -| `make start` | Start local EVM chain (Anvil) | -| `make stop` | Stop Anvil, solver, operator, and aggregator | -| `make setup` | Full setup: init + deploy + configure + fund | -| `make deploy` | Deploy contracts (use `CHAINS=a,b` to limit) | -| `make aggregator` | Start the OIF aggregator service (port 4000) | -| `make solver` | Start the solver service | -| `make operator` | Start the oracle operator service | -| `make mint` | Mint mock tokens (`CHAIN=`, `SYMBOL=`, `TO=`, `AMOUNT=`) | -| `make intent` | Submit intent (`FROM=`, `TO=`, `AMOUNT=`, `ASSET=`) | -| `make balances` | Check balances (use `CHAIN=name` to filter) | -| `make chain-list` | List configured chains | -| `make token-list` | List tokens across chains | -| `make clean` | Remove generated files | - - -Use `FORCE=1` to reinitialize or redeploy: `make setup FORCE=1` +| Command | Description | +| ------------------ | ------------------------------------------------------------ | +| `make start` | Start local Anvil chains + Hyperlane (Docker) | +| `make stop` | Stop Docker stack + solver + operator + aggregator | +| `make setup` | **Deploys contracts only** — no configure or fund. Run the follow-up steps yourself. | +| `make setup-demo` | One-button: `setup` + `configure` + `fund` + `fund-operator` + `fund-user`. | +| `make deploy` | Deploy contracts (use `CHAINS=a,b` to limit) | +| `make configure` | Generate `.config/{solver,oracle,rebalancer,aggregator}.*` | +| `make fund` | Fund solver with USDC on the collateral chain | +| `make fund-operator` | Send ETH to oracle operator on every chain | +| `make fund-user` | Send ETH to user on every chain | +| `make aggregator` | Start OIF aggregator (port 4000) | +| `make solver` | Start solver service | +| `make operator` | Start oracle operator | +| `make rebalancer` | Start rebalancer | +| `make mint` | Mint mock tokens (`CHAIN=`, `SYMBOL=`, `TO=`, `AMOUNT=`) | +| `make intent` | Submit intent (`FROM=`, `TO=`, `AMOUNT=`, `ASSET=`) | +| `make balances` | Check balances (use `CHAIN=name` to filter) | +| `make chain-list` | List configured chains | +| `make token-list` | List tokens across chains | +| `make clean` | Remove generated files | + + +Use `FORCE=1` to reinitialize or redeploy: `make setup FORCE=1` or `make setup-demo FORCE=1`. Run `make help` to see all available commands. ## CLI Commands -| Command | Description | -| ------------------------------------------ | ------------------------------------- | -| `solver-cli init` | Initialize project state | -| `solver-cli deploy` | Deploy contracts to all chains | -| `solver-cli deploy --chains a,b` | Deploy to specific chains | -| `solver-cli configure` | Generate solver config | -| `solver-cli fund` | Fund solver with tokens on all chains | -| `solver-cli fund --chain X` | Fund solver on specific chain | -| `solver-cli chain add` | Add a chain with existing contracts | -| `solver-cli chain list` | List configured chains | -| `solver-cli token add` | Add a token to a chain | -| `solver-cli token list` | List all tokens | -| `solver-cli token mint` | Mint mock tokens (MockERC20 only) | -| `solver-cli solver start` | Start the solver | -| `solver-cli intent submit` | Submit a cross-chain intent | -| `solver-cli intent submit --from a --to b` | Specify direction | -| `solver-cli balances` | Check balances on all chains | +| Command | Description | +| ------------------------------------------ | ------------------------------------------------------------ | +| `solver-cli init` | Initialize project state | +| `solver-cli deploy` | Deploy contracts to all chains in `.env` | +| `solver-cli deploy --chains a,b` | Deploy to specific chains | +| `solver-cli configure` | Generate `solver.toml` / `oracle.toml` / `rebalancer.toml` / `aggregator.json` | +| `solver-cli fund` | Fund solver with tokens on all chains | +| `solver-cli fund --chain X` | Fund solver on a specific chain | +| `solver-cli chain add` | Register a chain. Flags: `--rpc`, `--chain-id`, `--input-settler`, `--output-settler`, `--oracle`, `--warp-token`, `--warp-token-type`, `--mailbox`, `--igp`, `--domain-id` | +| `solver-cli chain list` | List configured chains | +| `solver-cli token add` | Add a token. Flags: `--chain`, `--symbol`, `--address`, `--decimals`, `--token-type`, `--warp-token`, `--warp-token-type` | +| `solver-cli token list` | List all tokens | +| `solver-cli token mint` | Mint mock tokens (MockERC20 only) | +| `solver-cli solver start` | Start the solver | +| `solver-cli intent submit` | Submit a cross-chain intent | +| `solver-cli intent submit --from a --to b` | Specify direction | +| `solver-cli balances` | Check balances on all chains | + +### Hyperlane domain ID override + +A chain's Hyperlane domain ID defaults to its EVM chain ID. Override per chain via either: +- env var: `EDEN_DOMAIN_ID=12345` (used by `solver-cli deploy`) +- CLI flag: `solver-cli chain add --domain-id 12345` ## Submitting Intents diff --git a/scripts/setup.sh b/scripts/setup.sh index 9da6802f..de5e4eff 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -11,8 +11,8 @@ cd "$PROJECT_ROOT" # ── Deploy + configure + fund ──────────────────────────────────────────────── -step "Running full setup (init + deploy OIF contracts + configure + fund)..." -make setup FORCE=1 +step "Running full demo setup (init + deploy + configure + fund)..." +make setup-demo FORCE=1 # Allow small losses on local dev (gas costs exceed spread for tiny orders) if grep -q 'min_profitability_pct = 0.0' .config/solver.toml 2>/dev/null; then From 4b0922b753b94c36cecbe02ed786dbf571e1215a Mon Sep 17 00:00:00 2001 From: jonas089 Date: Mon, 4 May 2026 17:47:10 +0200 Subject: [PATCH 05/10] docs+make: tighten setup walkthrough so a fresh clone can follow it Closes four rough edges users hit when actually trying to run things: 1. PATH for solver-cli was undefined. Add an explicit "Prep" section that builds the binary and shows three ways to put it on PATH (export, alias, cargo install). 2. Path A used to mix local-Anvil and external-chain steps in one flow, which made it impossible to follow for either case alone. Split into Path A (Local Anvil walkthrough), Path B (Real EVM chains, no Docker), and Path C (one-button local demo). Path B walks Sepolia/Eden-style setups end-to-end including manual funding via `cast send`. 3. `make fund`, `fund-operator`, `fund-user`, `mint`, `start`, `setup-demo`, `mvp` are anvil-only. Make-table now flags each anvil-only target explicitly and Path B explains the manual funding alternative. 4. `make chain-add` and `make token-add` did not expose the new CLI flags, leaving Make-path users stuck. Wrappers now accept DOMAIN_ID, MAILBOX, IGP, WARP_TOKEN, WARP_TOKEN_TYPE for chain-add, and TOKEN_TYPE, WARP_TOKEN, WARP_TOKEN_TYPE for token-add. Also collapsed the redundant "Adding external chains" section into a tight reference table covering the three warp-router types and where to find each address (Hyperlane registry, hyperlane-addresses.json, `cast call wrappedToken()`, etc.). Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 20 +++- README.md | 270 ++++++++++++++++++++++++++++++------------------------ 2 files changed, 165 insertions(+), 125 deletions(-) diff --git a/Makefile b/Makefile index 1006a9fc..a9ccdbab 100644 --- a/Makefile +++ b/Makefile @@ -153,7 +153,8 @@ fund: build @$(SOLVER_CLI) fund --amount 100000000 --chain anvil1 .PHONY: fund -## chain-add: Add a chain with existing contracts (use make chain-add NAME=arbitrum RPC=... INPUT_SETTLER=... OUTPUT_SETTLER=... ORACLE=...) +## chain-add: Register a chain with existing contracts. Required: NAME, RPC, INPUT_SETTLER, OUTPUT_SETTLER, ORACLE. +## Optional: CHAIN_ID, DOMAIN_ID, MAILBOX, IGP, WARP_TOKEN, WARP_TOKEN_TYPE, TOKEN_SYMBOL+TOKEN_ADDR. chain-add: build @$(SOLVER_CLI) chain add \ --name $(NAME) \ @@ -162,6 +163,11 @@ chain-add: build --input-settler $(INPUT_SETTLER) \ --output-settler $(OUTPUT_SETTLER) \ --oracle $(ORACLE) \ + $(if $(DOMAIN_ID),--domain-id $(DOMAIN_ID),) \ + $(if $(MAILBOX),--mailbox $(MAILBOX),) \ + $(if $(IGP),--igp $(IGP),) \ + $(if $(WARP_TOKEN),--warp-token $(WARP_TOKEN),) \ + $(if $(WARP_TOKEN_TYPE),--warp-token-type $(WARP_TOKEN_TYPE),) \ $(if $(TOKEN_ADDR),--token $(TOKEN_SYMBOL)=$(TOKEN_ADDR),) .PHONY: chain-add @@ -175,9 +181,17 @@ chain-remove: build @$(SOLVER_CLI) chain remove --chain $(CHAIN) .PHONY: chain-remove -## token-add: Add a token to a chain (use CHAIN=name SYMBOL=USDC ADDRESS=0x... DECIMALS=6) +## token-add: Add a token to a chain. Required: CHAIN, SYMBOL, ADDRESS. +## Optional: DECIMALS, TOKEN_TYPE (erc20|native), WARP_TOKEN, WARP_TOKEN_TYPE (collateral|synthetic|native). token-add: build - @$(SOLVER_CLI) token add --chain $(CHAIN) --symbol $(SYMBOL) --address $(ADDRESS) $(if $(DECIMALS),--decimals $(DECIMALS),) + @$(SOLVER_CLI) token add \ + --chain $(CHAIN) \ + --symbol $(SYMBOL) \ + --address $(ADDRESS) \ + $(if $(DECIMALS),--decimals $(DECIMALS),) \ + $(if $(TOKEN_TYPE),--token-type $(TOKEN_TYPE),) \ + $(if $(WARP_TOKEN),--warp-token $(WARP_TOKEN),) \ + $(if $(WARP_TOKEN_TYPE),--warp-token-type $(WARP_TOKEN_TYPE),) .PHONY: token-add ## token-list: List all tokens across chains (use CHAIN=name to filter) diff --git a/README.md b/README.md index ea82ab25..5e33e8ab 100644 --- a/README.md +++ b/README.md @@ -22,126 +22,164 @@ This CLI deploys OIF contracts, runs a solver, and executes cross-chain token tr ## Quick Start -There are two paths: the **CLI walkthrough** (recommended — teaches you the wiring) and the **one-button demo** (for someone who just wants to see it run end-to-end). +Pick one path: -### Path A — CLI walkthrough (recommended) +- **Path A — Local Anvil walkthrough.** Two-chain Docker stack. Best for learning the flow end-to-end and for development. +- **Path B — Real EVM chains (Sepolia/Eden/Arbitrum/...).** Skip Docker; deploy or register contracts on existing chains. +- **Path C — One-button demo.** Hands-off local stack with everything started for you. -`make setup` deliberately stops after deploying contracts; you generate configs and fund accounts yourself with `solver-cli` so you understand each step. This is also the only way to wire **external chains** (Eden, Arbitrum, etc.) and **non-default tokens**. +### Prep (all paths) ```bash -# 1. Configure environment +# Build the CLI binary (target/release/solver-cli) +make build + +# Put solver-cli on PATH for the rest of this session. +# Pick one: +export PATH="$PWD/target/release:$PATH" # quick session-only +# alias solver-cli="$PWD/target/release/solver-cli" # alternative +# cargo install --path solver-cli # ~/.cargo/bin (persistent) + +# Environment cp .env.example .env -# Edit .env: per-chain {NAME}_RPC + {NAME}_PK, plus SOLVER_PRIVATE_KEY, -# REBALANCER_PRIVATE_KEY (== SOLVER_PRIVATE_KEY), ORACLE_OPERATOR_PK (≠ solver). +# Edit .env. Required: +# per-chain {NAME}_RPC + {NAME}_PK (e.g. SEPOLIA_RPC, SEPOLIA_PK) +# SOLVER_PRIVATE_KEY (== REBALANCER_PRIVATE_KEY) +# ORACLE_OPERATOR_PK (must differ from SOLVER_PRIVATE_KEY) +# USER_PK (test-user wallet) +# INTEGRITY_SECRET (32+ random chars, for the aggregator) +``` + +### Path A — Local Anvil walkthrough -# 2. Start local Anvil chains + Hyperlane (Docker) +`make setup` stops after deploying contracts so you can see each follow-up step on its own. + +```bash +# 1. Start Docker stack (Anvil1, Anvil2, Hyperlane init, forwarding relayer) make start -# 3. Deploy OIF contracts to every chain in your .env +# 2. Deploy OIF contracts on both Anvil chains make setup # ↳ runs: init + deploy-permit2 + solver-cli deploy -# ↳ writes .config/state.json with chain IDs, contract addresses, -# and (for local stacks) Hyperlane warp router + token addresses -# pulled from .config/hyperlane-addresses.json +# ↳ writes .config/state.json with chain IDs, contract addresses, and +# Hyperlane warp router + token addresses pulled from +# .config/hyperlane-addresses.json (created by `make start`) -# 4. Inspect what landed in state.json +# 3. Inspect state solver-cli chain list solver-cli token list -# 5. Generate the four service configs from state.json +# 4. Generate the four service configs from state.json solver-cli configure -# ↳ writes: .config/solver.toml, .config/oracle.toml, -# .config/rebalancer.toml, .config/aggregator.json -# ↳ enforces every multi-chain asset has a Hyperlane warp router - -# 6. Fund accounts (each one is explicit so you can see what's happening) -make fund # mint USDC to the solver on the collateral chain -make fund-operator # send ETH to the oracle operator on every chain -make fund-user # send ETH to the test user on every chain - -# 7. Start services (separate terminals) -make aggregator # Terminal 1 -make solver # Terminal 2 -make operator # Terminal 3 -make rebalancer # Terminal 4 (optional) - -# 8. Submit a test intent and watch balances +# ↳ writes .config/solver.toml, .config/oracle.toml, +# .config/rebalancer.toml, .config/aggregator.json + +# 5. Fund accounts +make fund # mint USDC to the solver on anvil1 (collateral chain) +make fund-operator # ETH to oracle operator on every chain +make fund-user # ETH to test user on every chain + +# 6. Start services in separate terminals +make aggregator # T1 +make solver # T2 +make operator # T3 +make rebalancer # T4 (optional — Celestia rebalance loop) + +# 7. Submit intent + watch balances make balances make intent make balances ``` -### Path B — one-button demo +### Path B — Real EVM chains (no Docker) -```bash -make mvp # spawns chains, deploys, configures, funds, starts every service -# ... or, without the frontend / services: -make setup-demo # = setup + configure + fund + fund-operator + fund-user -``` - -`setup-demo` is the old all-in-one `setup` target; the new `setup` stops after deploy. - -## Adding external chains - -To wire a real testnet/mainnet (e.g. Eden, Arbitrum) you'll use `solver-cli chain add` and `solver-cli token add`. There is no Docker step — the chain already exists. +For a public testnet/mainnet, **do not run `make start`** — the Docker stack is local-only. Skip straight to `solver-cli deploy` (or `chain add` if contracts already exist), then add tokens, configure, and fund manually with `cast send` / your wallet. ```bash -# 1. Add the chain to .env (CLI auto-detects from {NAME}_RPC + {NAME}_PK): -echo 'EDEN_RPC=https://eden-rpc.example.com' >> .env -echo 'EDEN_PK=0x...your-deployer-key...' >> .env +# 1. Add the chain(s) to .env (auto-detected via {NAME}_RPC + {NAME}_PK). +# Optional: {NAME}_DOMAIN_ID if Hyperlane domain ≠ chain ID. +echo 'EDEN_RPC=https://eden-rpc.example' >> .env +echo 'EDEN_PK=0x' >> .env +# echo 'EDEN_DOMAIN_ID=12345' >> .env # only if needed -# Optional: if Eden's Hyperlane domain ID differs from its EVM chain ID, also: -echo 'EDEN_DOMAIN_ID=12345' >> .env +solver-cli init -# 2. (Option A) Deploy OIF contracts on Eden: -solver-cli deploy --chains eden +# 2a. Deploy fresh OIF contracts on each chain +solver-cli deploy --chains sepolia,eden --token ETH --decimals 18 -# 2. (Option B) If contracts are already deployed, register them: +# 2b. ...OR register existing deployments (one chain per invocation): solver-cli chain add \ - --name eden \ - --rpc https://eden-rpc.example.com \ - --input-settler 0x... \ - --output-settler 0x... \ - --oracle 0x... \ + --name eden --rpc "$EDEN_RPC" \ + --input-settler 0x... --output-settler 0x... --oracle 0x... \ --domain-id 12345 \ - --mailbox 0x... \ - --igp 0x... \ - --warp-token 0x...HypERC20Collateral... \ - --warp-token-type collateral - -# 3. Add a token. For an ERC20 with its own Hyperlane warp route: -solver-cli token add \ - --chain eden \ - --symbol USDC \ - --address 0x...UnderlyingERC20... \ - --decimals 6 \ - --token-type erc20 \ - --warp-token 0x...HypERC20Collateral... \ - --warp-token-type collateral - -# For a synthetic chain (router IS the ERC20), pass the same address as both: -solver-cli token add --chain eden --symbol USDC \ - --address 0x...HypSynthetic... --decimals 6 \ - --warp-token 0x...HypSynthetic... --warp-token-type synthetic - -# For a native warp route (HypNative wraps ETH): + --mailbox 0x \ + --igp 0x + +# 3. Add tokens. Pick the right warp_token_type for each chain: +# collateral = HypERC20Collateral wraps a vanilla ERC20 (two distinct addresses) +# synthetic = HypERC20Synthetic IS the ERC20 (same address in both fields) +# native = HypNative wraps the chain's gas token (no underlying ERC20) +solver-cli token add --chain sepolia --symbol ETH \ + --address 0x --decimals 18 --token-type erc20 \ + --warp-token 0x --warp-token-type synthetic solver-cli token add --chain eden --symbol ETH \ - --address 0x0000000000000000000000000000000000000000 \ - --decimals 18 --token-type native \ - --warp-token 0x...HypNative... --warp-token-type native + --address 0x --decimals 18 --token-type erc20 \ + --warp-token 0x --warp-token-type synthetic -# 4. Regenerate configs +# 4. Generate configs solver-cli configure + +# 5. Fund manually — `make fund` is anvil-only. +# Send native gas + token inventory to: +# - SOLVER_PRIVATE_KEY's address on every chain (gas + inventory on dest) +# - ORACLE_OPERATOR_PK's address on every chain (gas only) +# - USER_PK's address on the source chain (gas + tokens for the test intent) +# With `cast`: +cast send --rpc-url "$EDEN_RPC" --private-key "$EDEN_PK" \ + --value 0.05ether $(cast wallet address --private-key "$SOLVER_PRIVATE_KEY") +# ...or any wallet UI works. + +# 6. Start services (same as Path A step 6) +make aggregator +make solver +make operator +make rebalancer +make frontend # optional — http://localhost:3000 + +# 7. Submit + verify +solver-cli intent submit --asset ETH --from sepolia --to eden --amount 100000000000000000 # 0.1 ETH +solver-cli balances +``` + +### Path C — One-button local demo + +```bash +make mvp # full Docker stack + every service + frontend +# ...or just the wiring without the services / frontend: +make setup-demo # = setup + configure + fund + fund-operator + fund-user ``` +`setup-demo` is the old all-in-one `setup`; the new `setup` stops after deploy. + +## Reference + +### Hyperlane warp router types + +| Type | Underlying ERC20 (`token.address`) | Warp router (`warp_token`) | Set `token-type` to | Set `warp-token-type` to | +| --- | --- | --- | --- | --- | +| **Collateral** | vanilla ERC20 (e.g. real USDC) | separate `HypERC20Collateral` | `erc20` | `collateral` | +| **Synthetic** | router IS the ERC20 (same address) | same address | `erc20` | `synthetic` | +| **Native** | none — gas token (`0x0..0` placeholder) | `HypNative` | `native` | `native` | + ### Finding the addresses you need -| Address | Where to get it | +| Address | Source | | --- | --- | | Hyperlane mailbox / IGP / warp routers on real chains | Hyperlane registry: | | Hyperlane domain ID | Same registry — `metadata.yaml` has `domainId`. Defaults to chain ID if you don't override. | | Deployed OIF contracts on local stack | `solver-cli chain list` (reads `.config/state.json` written by `make setup`) | -| Token contract addresses | Token issuer docs, block explorer, or — for newly deployed warp routes — `cast call "wrappedToken()(address)"` | +| Local-stack Hyperlane addresses | `.config/hyperlane-addresses.json` (created by `make start`) | +| Token contract addresses | Token issuer docs, block explorer, or — for warp routes — `cast call "wrappedToken()(address)"` | | Underlying ERC20 vs warp router | On a **collateral** chain they differ; `cast call "wrappedToken()(address)"` returns the underlying ERC20. On a **synthetic** chain they are the same contract. | ### Per-token vs chain-level warp router @@ -150,52 +188,40 @@ solver-cli configure - The per-token `--warp-token` on `token add` is **per-asset** and overrides the chain-level value. - You need the per-token form when one chain has multiple tokens with different warp routers (e.g. USDC and USDT each on their own `HypERC20Collateral`). -## Environment Setup - -Chains are configured with the pattern `{CHAIN}_RPC` and `{CHAIN}_PK`: - -```bash -cp .env.example .env -# Edit with your keys -``` - -See [Deploy New Token](docs/deploy-new-token.md) for detailed environment setup. - -Oracle operator signer defaults to env-backed config. Generated `.config/oracle.toml` now contains: - -```toml -operator_address = "0x..." - -[signer] -type = "env" -``` +## Environment -When `type = "env"`, the operator loads `ORACLE_OPERATOR_PK` at runtime (for example via `.env`). +Chains are auto-detected from `{NAME}_RPC` + `{NAME}_PK` pairs in `.env`. See [Deploy New Token](docs/deploy-new-token.md) for an end-to-end example. The oracle operator signer defaults to `type = "env"` (loads `ORACLE_OPERATOR_PK`); switch to AWS KMS by setting `ORACLE_SIGNER_TYPE=aws_kms` + `ORACLE_KMS_KEY_ID` + `ORACLE_KMS_REGION`. Same pattern for `SOLVER_SIGNER_TYPE` and `REBALANCER_SIGNER_TYPE`. ## Make Commands -| Command | Description | -| ------------------ | ------------------------------------------------------------ | -| `make start` | Start local Anvil chains + Hyperlane (Docker) | -| `make stop` | Stop Docker stack + solver + operator + aggregator | -| `make setup` | **Deploys contracts only** — no configure or fund. Run the follow-up steps yourself. | -| `make setup-demo` | One-button: `setup` + `configure` + `fund` + `fund-operator` + `fund-user`. | -| `make deploy` | Deploy contracts (use `CHAINS=a,b` to limit) | -| `make configure` | Generate `.config/{solver,oracle,rebalancer,aggregator}.*` | -| `make fund` | Fund solver with USDC on the collateral chain | -| `make fund-operator` | Send ETH to oracle operator on every chain | -| `make fund-user` | Send ETH to user on every chain | -| `make aggregator` | Start OIF aggregator (port 4000) | -| `make solver` | Start solver service | -| `make operator` | Start oracle operator | -| `make rebalancer` | Start rebalancer | -| `make mint` | Mint mock tokens (`CHAIN=`, `SYMBOL=`, `TO=`, `AMOUNT=`) | -| `make intent` | Submit intent (`FROM=`, `TO=`, `AMOUNT=`, `ASSET=`) | -| `make balances` | Check balances (use `CHAIN=name` to filter) | -| `make chain-list` | List configured chains | -| `make token-list` | List tokens across chains | -| `make clean` | Remove generated files | +**Anvil-only** = depends on the local Docker stack (`make start`). + +| Command | Description | +| -------------------- | ----------------------------------------------------------------- | +| `make build` | Build the `solver-cli` binary (`target/release/solver-cli`) | +| `make start` | **Anvil-only.** Start Docker chains + Hyperlane | +| `make stop` | Stop Docker stack + every running service | +| `make setup` | **Deploys contracts only.** Run `solver-cli configure` and the fund targets yourself. | +| `make setup-demo` | **Anvil-only.** `setup` + `configure` + `fund` + `fund-operator` + `fund-user`. | +| `make mvp` | **Anvil-only.** Full Docker + every service + frontend. | +| `make deploy` | `solver-cli deploy` (use `CHAINS=a,b` to limit) | +| `make configure` | `solver-cli configure` | +| `make fund` | **Anvil-only.** Mint USDC to solver on anvil1 (hardcoded). For external chains, fund manually with `cast send` or your wallet. | +| `make fund-operator` | **Anvil-only.** Send ETH to oracle operator on anvil1/anvil2. | +| `make fund-user` | **Anvil-only.** Send ETH to user on anvil1/anvil2. | +| `make chain-add` | `solver-cli chain add` wrapper. Vars: `NAME`, `RPC`, `INPUT_SETTLER`, `OUTPUT_SETTLER`, `ORACLE`, optional `CHAIN_ID`, `DOMAIN_ID`, `MAILBOX`, `IGP`, `WARP_TOKEN`, `WARP_TOKEN_TYPE`. | +| `make token-add` | `solver-cli token add` wrapper. Vars: `CHAIN`, `SYMBOL`, `ADDRESS`, optional `DECIMALS`, `TOKEN_TYPE`, `WARP_TOKEN`, `WARP_TOKEN_TYPE`. | +| `make aggregator` | Start OIF aggregator (port 4000) | +| `make solver` | Start solver service | +| `make operator` | Start oracle operator | +| `make rebalancer` | Start rebalancer | +| `make mint` | **Anvil-only.** Mint mock tokens (`CHAIN=`, `SYMBOL=`, `TO=`, `AMOUNT=`) | +| `make intent` | Submit intent (`FROM=`, `TO=`, `AMOUNT=`, `ASSET=`) | +| `make balances` | Check balances (use `CHAIN=name` to filter) | +| `make chain-list` | List configured chains | +| `make token-list` | List tokens across chains | +| `make clean` | Remove generated files | Use `FORCE=1` to reinitialize or redeploy: `make setup FORCE=1` or `make setup-demo FORCE=1`. From f2d97caf8d5fe4f66dcf7065b818a98b27e6a273 Mon Sep 17 00:00:00 2001 From: jonas089 Date: Mon, 4 May 2026 17:50:35 +0200 Subject: [PATCH 06/10] docs: clarify Path B token registration, prefunding, and service order Triple-check audit surfaced four README ambiguities (no actual code bugs): - solver-cli deploy --token / --decimals only auto-registers tokens when a local hyperlane-addresses.json exists. On real chains the deploy writes contracts only; users must run token add. Annotate step 2a. - Pre-funding requirements were not spelled out. Add explicit gas budget for each role (deployer, solver, operator, user) in Prep. - INTEGRITY_SECRET was listed without explaining its purpose. Note that it signs aggregator quotes/orders so a forged quote cant be replayed. - Service start order matters (aggregator before solver/frontend). Annotate step 6 with order + ports + frontend dependency. Also clarify in step 5 that for synthetic warp tokens the inventory address equals the warp router address (a confusing edge case for fresh users). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 54 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5e33e8ab..54b88267 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,16 @@ cp .env.example .env # SOLVER_PRIVATE_KEY (== REBALANCER_PRIVATE_KEY) # ORACLE_OPERATOR_PK (must differ from SOLVER_PRIVATE_KEY) # USER_PK (test-user wallet) -# INTEGRITY_SECRET (32+ random chars, for the aggregator) +# INTEGRITY_SECRET (32+ random chars; signs aggregator +# quotes/orders end-to-end so a forged +# quote can't be replayed) + +# Pre-funding (real-chain users only): +# Each chain's {NAME}_PK needs native gas to deploy contracts (~0.05 ETH on +# Sepolia is plenty; per-chain deploys are 4 txs). +# SOLVER_PRIVATE_KEY needs gas on every chain (filling + claiming). +# ORACLE_OPERATOR_PK needs gas on every chain (attestation submission). +# USER_PK needs gas + token inventory on the source chain. ``` ### Path A — Local Anvil walkthrough @@ -104,10 +113,14 @@ echo 'EDEN_PK=0x' >> .env solver-cli init -# 2a. Deploy fresh OIF contracts on each chain -solver-cli deploy --chains sepolia,eden --token ETH --decimals 18 +# 2a. Deploy fresh OIF contracts on each chain. +# Note: --token / --decimals only auto-register tokens when +# .config/hyperlane-addresses.json exists (local Anvil only). On real +# chains, deploy writes the contract addresses but NOT tokens — you'll +# register tokens explicitly in step 3 below. +solver-cli deploy --chains sepolia,eden -# 2b. ...OR register existing deployments (one chain per invocation): +# 2b. ...OR if contracts are already deployed, register them per chain: solver-cli chain add \ --name eden --rpc "$EDEN_RPC" \ --input-settler 0x... --output-settler 0x... --oracle 0x... \ @@ -131,20 +144,29 @@ solver-cli configure # 5. Fund manually — `make fund` is anvil-only. # Send native gas + token inventory to: -# - SOLVER_PRIVATE_KEY's address on every chain (gas + inventory on dest) -# - ORACLE_OPERATOR_PK's address on every chain (gas only) -# - USER_PK's address on the source chain (gas + tokens for the test intent) -# With `cast`: +# - SOLVER_PRIVATE_KEY's address on every chain (gas + token inventory +# on the destination chain so the solver can fill). +# - ORACLE_OPERATOR_PK's address on every chain (gas only). +# - USER_PK's address on the source chain (gas + tokens for the test). +# +# Token inventory address: for synthetic warp tokens, deposit the warp +# token itself (token.address == warp_token in this case). For collateral, +# deposit the underlying ERC20 — the rebalancer/solver will approve the +# warp router as needed. +# +# With `cast` (gas only, repeat per chain/recipient): cast send --rpc-url "$EDEN_RPC" --private-key "$EDEN_PK" \ --value 0.05ether $(cast wallet address --private-key "$SOLVER_PRIVATE_KEY") -# ...or any wallet UI works. - -# 6. Start services (same as Path A step 6) -make aggregator -make solver -make operator -make rebalancer -make frontend # optional — http://localhost:3000 +# ...or use any wallet UI. + +# 6. Start services (same as Path A step 6, separate terminals). +# Order matters: aggregator first, then solver/operator, then frontend +# (frontend talks to the aggregator + solver HTTP APIs). +make aggregator # T1 — port 4000 +make solver # T2 — solver HTTP on 5001 +make operator # T3 +make rebalancer # T4 (optional — only useful if Celestia warp legs exist) +make frontend # T5 (optional — http://localhost:3000; needs T1+T2 up) # 7. Submit + verify solver-cli intent submit --asset ETH --from sepolia --to eden --amount 100000000000000000 # 0.1 ETH From 6f0c049ef6c3b4674a847253707d1ab284c491cf Mon Sep 17 00:00:00 2001 From: jonas089 Date: Mon, 4 May 2026 17:53:50 +0200 Subject: [PATCH 07/10] clippy --- solver-cli/src/solver/delivery.rs | 2 -- solver-cli/src/utils/api_error.rs | 7 ++----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/solver-cli/src/solver/delivery.rs b/solver-cli/src/solver/delivery.rs index 46810a4a..6aa516a0 100644 --- a/solver-cli/src/solver/delivery.rs +++ b/solver-cli/src/solver/delivery.rs @@ -10,8 +10,6 @@ //! transaction; the inner delivery then skips its own fee logic because //! alloy sees a fully-configured 1559 request. -#![cfg(feature = "solver-runtime")] - use std::collections::HashMap; use std::sync::Arc; diff --git a/solver-cli/src/utils/api_error.rs b/solver-cli/src/utils/api_error.rs index 1bcc2bbd..b333c3f2 100644 --- a/solver-cli/src/utils/api_error.rs +++ b/solver-cli/src/utils/api_error.rs @@ -90,11 +90,8 @@ pub async fn parse_response( } fn upstream_from_body(label: &str, status: u16, body: Value) -> ApiError { - let pick_str = |k: &str| -> Option { - body.get(k) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }; + let pick_str = + |k: &str| -> Option { body.get(k).and_then(|v| v.as_str()).map(|s| s.to_string()) }; let message = pick_str("message") .or_else(|| pick_str("error")) .or_else(|| pick_str("reason")) From 0b20ad8a13b69fe45d88aadf84b0aa487205d5c0 Mon Sep 17 00:00:00 2001 From: jonas089 Date: Mon, 4 May 2026 18:06:40 +0200 Subject: [PATCH 08/10] deprecated chain-level warp config --- rebalancer/src/config.rs | 79 ++++++++++++------------- solver-cli/src/commands/chain.rs | 52 +--------------- solver-cli/src/deployment/deployer.rs | 26 ++++---- solver-cli/src/rebalancer/config_gen.rs | 29 +++------ solver-cli/src/state/types.rs | 12 +--- 5 files changed, 62 insertions(+), 136 deletions(-) diff --git a/rebalancer/src/config.rs b/rebalancer/src/config.rs index 1e3743c8..540a73e6 100644 --- a/rebalancer/src/config.rs +++ b/rebalancer/src/config.rs @@ -4,8 +4,8 @@ use serde::Deserialize; use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; -/// (chain_id, token, optional warp_token_address) grouped by symbol. -type TokensBySymbol<'a> = BTreeMap)>>; +/// (chain_id, token) grouped by symbol. +type TokensBySymbol<'a> = BTreeMap>; const WEIGHT_TOLERANCE: f64 = 1e-6; const MIN_POLL_INTERVAL_SECONDS: u64 = 30; @@ -184,7 +184,6 @@ struct StateContracts { #[derive(Deserialize)] struct StateHyperlane { domain_id: Option, - warp_token: Option, } #[derive(Deserialize)] @@ -378,27 +377,17 @@ fn collect_assets( ) -> Result> { let chain_id_set: HashSet = chains.iter().map(|c| c.chain_id).collect(); - // Group tokens by symbol across chains. Per-token warp_token (set via - // `solver-cli token add --warp-token`) takes precedence over the chain-level - // `contracts.hyperlane.warp_token` fallback. This lets one chain host - // multiple tokens with distinct warp routers. + // Group tokens by symbol across chains. Each token carries its own Hyperlane + // warp router (set via `solver-cli token add --warp-token`); a chain may + // host multiple tokens with distinct routers. let mut by_symbol: TokensBySymbol<'_> = BTreeMap::new(); for (chain_id, chain) in &state.chains { - let chain_warp_token = chain - .contracts - .hyperlane - .as_ref() - .and_then(|h| h.warp_token.clone()); for token in chain.tokens.values() { - let warp_token = token - .warp_token - .clone() - .or_else(|| chain_warp_token.clone()); let normalized = token.symbol.to_ascii_uppercase(); by_symbol .entry(normalized) .or_default() - .push((*chain_id, token, warp_token)); + .push((*chain_id, token)); } } @@ -409,20 +398,17 @@ fn collect_assets( continue; } - entries.sort_by_key(|(chain_id, _, _)| *chain_id); + entries.sort_by_key(|(chain_id, _)| *chain_id); // Validate consistent decimals let expected_decimals = entries[0].1.decimals; - if entries - .iter() - .any(|(_, t, _)| t.decimals != expected_decimals) - { + if entries.iter().any(|(_, t)| t.decimals != expected_decimals) { bail!("Token {} has inconsistent decimals across chains", symbol); } // Build token configs let mut token_configs: HashMap = HashMap::new(); - for (chain_id, token, warp_token) in &entries { + for (chain_id, token) in &entries { if !chain_id_set.contains(chain_id) { continue; } @@ -435,17 +421,18 @@ fn collect_assets( // Refuse to fall back to the underlying ERC20 silently — that combination // produces "Native: amount exceeds msg.value" / missing-selector reverts // at submit time, far away from the misconfiguration. - let collateral_token: Address = match warp_token { + let collateral_token: Address = match &token.warp_token { Some(wt) => wt .parse() .with_context(|| format!("Invalid warp_token for chain {}", chain_id))?, None => bail!( "Asset {} on chain {} has no Hyperlane warp router configured. \ - Set `chains.{}.contracts.hyperlane.warp_token` in state.json \ - (or pass `--warp-token` to `solver-cli chain add`).", + Set `chains.{}.tokens.{}.warp_token` in state.json \ + (or pass `--warp-token` to `solver-cli token add`).", symbol, chain_id, - chain_id + chain_id, + symbol ), }; let asset_type = match token.token_type.as_deref() { @@ -651,10 +638,14 @@ mod tests { "name": "anvil1", "chain_id": 31337, "rpc": "http://127.0.0.1:8545", "contracts": { - "hyperlane": { "domain_id": 131337, "warp_token": "0x0000000000000000000000000000000000000A01" } + "hyperlane": { "domain_id": 131337 } }, "tokens": { - "USDC": { "address": "0x0000000000000000000000000000000000001111", "symbol": "USDC", "decimals": 6 } + "USDC": { + "address": "0x0000000000000000000000000000000000001111", + "symbol": "USDC", "decimals": 6, + "warp_token": "0x0000000000000000000000000000000000000A01" + } }, "deployer": null }, @@ -662,10 +653,14 @@ mod tests { "name": "anvil2", "chain_id": 31338, "rpc": "http://127.0.0.1:8546", "contracts": { - "hyperlane": { "domain_id": 31338, "warp_token": "0x0000000000000000000000000000000000000B01" } + "hyperlane": { "domain_id": 31338 } }, "tokens": { - "USDC": { "address": "0x0000000000000000000000000000000000002222", "symbol": "USDC", "decimals": 6 } + "USDC": { + "address": "0x0000000000000000000000000000000000002222", + "symbol": "USDC", "decimals": 6, + "warp_token": "0x0000000000000000000000000000000000000B01" + } }, "deployer": null } @@ -792,9 +787,9 @@ service_url = "http://127.0.0.1:8080" } #[test] - fn per_token_warp_token_overrides_chain_level() { - // Chain has no chain-level warp_token; the per-token warp_token must - // be honored so the rebalancer can find the warp router. + fn per_token_warp_token_resolves_router() { + // Each token carries its own Hyperlane warp router; the rebalancer + // surfaces it as `collateral_token` on the asset's per-chain config. let state = serde_json::json!({ "env": "local", "chains": { @@ -844,20 +839,22 @@ service_url = "http://127.0.0.1:8080" #[test] fn rejects_missing_warp_token() { - // State with USDC on both chains but no warp_token on chain 31338 — - // historically this silently fell back to using the ERC20 itself as - // the warp router, which then reverted at `transferRemote` time. + // USDC on both chains but chain 31338's token lacks a warp_token — + // the rebalancer must reject this rather than fall back to the ERC20 + // (which would revert at `transferRemote` time). let state = serde_json::json!({ "env": "local", "chains": { "31337": { "name": "anvil1", "chain_id": 31337, "rpc": "http://127.0.0.1:8545", - "contracts": { - "hyperlane": { "warp_token": "0x0000000000000000000000000000000000000A01" } - }, + "contracts": {}, "tokens": { - "USDC": { "address": "0x0000000000000000000000000000000000001111", "symbol": "USDC", "decimals": 6 } + "USDC": { + "address": "0x0000000000000000000000000000000000001111", + "symbol": "USDC", "decimals": 6, + "warp_token": "0x0000000000000000000000000000000000000A01" + } }, "deployer": null }, diff --git a/solver-cli/src/commands/chain.rs b/solver-cli/src/commands/chain.rs index c90ac174..4f5cefb5 100644 --- a/solver-cli/src/commands/chain.rs +++ b/solver-cli/src/commands/chain.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Result}; +use anyhow::Result; use clap::Subcommand; use std::collections::HashMap; use std::env; @@ -9,16 +9,6 @@ use crate::state::{ChainConfig, ContractAddresses, StateManager, TokenInfo}; use crate::utils::*; use crate::OutputFormat; -fn validate_warp_token_type(t: &str) -> Result<()> { - match t.to_ascii_lowercase().as_str() { - "collateral" | "synthetic" | "native" => Ok(()), - other => bail!( - "Invalid --warp-token-type {:?}; expected \"collateral\", \"synthetic\", or \"native\"", - other - ), - } -} - // `Add` carries a lot of optional Hyperlane fields by design (see `solver-cli // chain add --help`); boxing each one would just hurt ergonomics. #[allow(clippy::large_enum_variant)] @@ -58,17 +48,6 @@ pub enum ChainCommand { #[arg(long, default_value = "6")] decimals: u8, - /// Default Hyperlane warp router address for tokens on this chain. - /// Tokens with their own `warp_token` (set via `solver-cli token add`) - /// override this value. - #[arg(long)] - warp_token: Option, - - /// Default Hyperlane warp router type for this chain: - /// "collateral" | "synthetic" | "native". Per-token override via token add. - #[arg(long)] - warp_token_type: Option, - /// Hyperlane mailbox address on this chain. #[arg(long)] mailbox: Option, @@ -156,8 +135,6 @@ struct ChainAddParams { oracle: String, tokens: Vec, default_decimals: u8, - warp_token: Option, - warp_token_type: Option, mailbox: Option, igp: Option, domain_id: Option, @@ -176,8 +153,6 @@ impl ChainCommand { oracle, token, decimals, - warp_token, - warp_token_type, mailbox, igp, domain_id, @@ -193,8 +168,6 @@ impl ChainCommand { oracle, tokens: token, default_decimals: decimals, - warp_token, - warp_token_type, mailbox, igp, domain_id, @@ -219,16 +192,11 @@ impl ChainCommand { oracle, tokens, default_decimals, - warp_token, - warp_token_type, mailbox, igp, domain_id, dir, } = params; - if let Some(ref t) = warp_token_type { - validate_warp_token_type(t)?; - } let out = OutputFormatter::new(output); let project_dir = dir.unwrap_or_else(|| env::current_dir().unwrap()); let state_mgr = StateManager::new(&project_dir); @@ -262,11 +230,7 @@ impl ChainCommand { // Build contracts struct. // Construct HyperlaneAddresses if any Hyperlane field was supplied. - let any_hyperlane = warp_token.is_some() - || warp_token_type.is_some() - || domain_id.is_some() - || mailbox.is_some() - || igp.is_some(); + let any_hyperlane = domain_id.is_some() || mailbox.is_some() || igp.is_some(); let hyperlane = if any_hyperlane { Some(crate::state::HyperlaneAddresses { domain_id, @@ -274,12 +238,6 @@ impl ChainCommand { merkle_tree_hook: None, validator_announce: None, igp: igp.clone(), - warp_token: warp_token.clone(), - // Default chain-level type to "collateral" only when a warp_token - // is supplied without an explicit type — preserves prior behaviour. - warp_token_type: warp_token_type - .clone() - .or_else(|| warp_token.as_ref().map(|_| "collateral".to_string())), }) } else { None @@ -295,12 +253,6 @@ impl ChainCommand { print_address("InputSettlerEscrow", &input_settler); print_address("OutputSettlerSimple", &output_settler); print_address("CentralizedOracle", &oracle); - if let Some(ref addr) = warp_token { - print_address("Warp token router", addr); - } - if let Some(ref t) = warp_token_type { - print_kv("Warp router type", t); - } if let Some(ref addr) = mailbox { print_address("Mailbox", addr); } diff --git a/solver-cli/src/deployment/deployer.rs b/solver-cli/src/deployment/deployer.rs index f8b12b01..59333dff 100644 --- a/solver-cli/src/deployment/deployer.rs +++ b/solver-cli/src/deployment/deployer.rs @@ -225,25 +225,20 @@ impl Deployer { .map(|s| s.to_string()) }; - // Per-token warp router address from artifact (defaults to chain-level - // warp_token; on a collateral chain the underlying ERC20 differs from - // the warp router, so we need both). - let chain_warp_token = chain_data + // Warp router address for this token, read from the artifact. + // On a collateral chain (`mock_usdc` exists), `addr` is the + // underlying ERC20 and the warp router is a separate contract. + // On synthetic/native chains, `addr` is the warp router itself. + let warp_token = chain_data .get("warp_token") .and_then(|v| v.as_str()) .map(|s| s.to_string()); - let chain_warp_token_type = chain_data + let warp_token_type = chain_data .get("warp_token_type") .and_then(|v| v.as_str()) .map(|s| s.to_string()); if let Some(addr) = token_address { - // On a collateral chain (`mock_usdc` exists), `addr` is the - // underlying ERC20 and the warp router is a separate contract. - // On synthetic/native chains, `addr` is the warp router itself - // and equals chain_warp_token. - let token_warp = chain_warp_token.clone(); - let token_warp_type = chain_warp_token_type.clone(); chain_config.tokens.insert( token_symbol.to_string(), TokenInfo { @@ -251,8 +246,8 @@ impl Deployer { symbol: token_symbol.to_string(), decimals: token_decimals, token_type: "erc20".to_string(), - warp_token: token_warp, - warp_token_type: token_warp_type, + warp_token, + warp_token_type, }, ); info!( @@ -263,7 +258,8 @@ impl Deployer { ); } - // Store Hyperlane contract addresses (chain-level fallback) + // Store Hyperlane contract addresses (mailbox / IGP / etc). + // Warp router lives per-token, not here. let hyperlane = HyperlaneAddresses { domain_id: chain_data.get("domain_id").and_then(|v| v.as_u64()), mailbox: chain_data @@ -282,8 +278,6 @@ impl Deployer { .get("igp") .and_then(|v| v.as_str()) .map(|s| s.to_string()), - warp_token: chain_warp_token, - warp_token_type: chain_warp_token_type, }; chain_config.contracts.hyperlane = Some(hyperlane); } diff --git a/solver-cli/src/rebalancer/config_gen.rs b/solver-cli/src/rebalancer/config_gen.rs index 97d9d839..9230b867 100644 --- a/solver-cli/src/rebalancer/config_gen.rs +++ b/solver-cli/src/rebalancer/config_gen.rs @@ -73,10 +73,9 @@ impl RebalancerConfigGenerator { } // Every token participating in a multi-chain asset must have a Hyperlane - // warp router. Per-token warp_token (set via `solver-cli token add - // --warp-token`) takes precedence over the chain-level fallback. - // Catching this here means a misconfig surfaces at `solver-cli configure` - // instead of at rebalancer startup (where the same check is enforced). + // warp router. Catching this here means a misconfig surfaces at + // `solver-cli configure` instead of at rebalancer startup (where the same + // check is enforced). for (symbol, chains) in &by_symbol { if chains.len() < 2 { continue; @@ -88,16 +87,10 @@ impl RebalancerConfigGenerator { .values() .find(|t| t.symbol.eq_ignore_ascii_case(symbol)) .and_then(|t| t.warp_token.as_deref()); - let chain_warp = chain - .contracts - .hyperlane - .as_ref() - .and_then(|h| h.warp_token.as_deref()); - if token_warp.is_none() && chain_warp.is_none() { + if token_warp.is_none() { anyhow::bail!( "Asset {} on chain {} ({}) has no Hyperlane warp router configured. \ - Set it via `solver-cli token add --warp-token ` (per-token) \ - or `solver-cli chain add --warp-token ` (chain default).", + Set it via `solver-cli token add --warp-token `.", symbol, chain_name, chain_id, @@ -231,8 +224,6 @@ mod tests { merkle_tree_hook: None, validator_announce: None, igp: None, - warp_token: Some("0x0000000000000000000000000000000000009999".to_string()), - warp_token_type: Some("synthetic".to_string()), }), }, tokens: HashMap::from([( @@ -242,8 +233,8 @@ mod tests { symbol: "USDC".to_string(), decimals: 6, token_type: "erc20".to_string(), - warp_token: None, - warp_token_type: None, + warp_token: Some("0x0000000000000000000000000000000000009999".to_string()), + warp_token_type: Some("synthetic".to_string()), }, )]), deployer: None, @@ -267,8 +258,6 @@ mod tests { merkle_tree_hook: None, validator_announce: None, igp: None, - warp_token: Some("0x0000000000000000000000000000000000009999".to_string()), - warp_token_type: Some("synthetic".to_string()), }), }, tokens: HashMap::from([( @@ -278,8 +267,8 @@ mod tests { symbol: "USDC".to_string(), decimals: 6, token_type: "erc20".to_string(), - warp_token: None, - warp_token_type: None, + warp_token: Some("0x0000000000000000000000000000000000009998".to_string()), + warp_token_type: Some("synthetic".to_string()), }, )]), deployer: None, diff --git a/solver-cli/src/state/types.rs b/solver-cli/src/state/types.rs index 8a522a45..88a64cc6 100644 --- a/solver-cli/src/state/types.rs +++ b/solver-cli/src/state/types.rs @@ -118,12 +118,6 @@ pub struct HyperlaneAddresses { /// Interchain gas paymaster address pub igp: Option, - - /// Warp token address (HypCollateral or HypSynthetic) - pub warp_token: Option, - - /// Warp token type ("collateral" or "synthetic") - pub warp_token_type: Option, } impl ContractAddresses { @@ -149,9 +143,9 @@ pub struct TokenInfo { #[serde(default = "default_token_type")] pub token_type: String, - /// Hyperlane warp router address for this token, if any. - /// Takes precedence over `chain.contracts.hyperlane.warp_token`. - /// Set per-token when a chain hosts multiple assets with distinct routers. + /// Hyperlane warp router address for this token (HypERC20Collateral / + /// HypSynthetic / HypNative). Required for assets that the rebalancer + /// bridges via `transferRemote`. #[serde(default, skip_serializing_if = "Option::is_none")] pub warp_token: Option, From 1f3ada903b110419deb1826bbd1793ebe0ea3919 Mon Sep 17 00:00:00 2001 From: jonas089 Date: Tue, 5 May 2026 15:30:22 +0200 Subject: [PATCH 09/10] refactor(cli): use a clap ValueEnum for --token-type Replace the free-form String + ad-hoc validate_token_type() pair on `solver-cli token add` with a typed `TokenType` (Erc20 | Native) implementing clap::ValueEnum, so invalid values are rejected at parse time and `--help` self-documents the choices. Storage schema is unchanged: TokenType::as_str() emits the same lowercase strings written into state.json today. Co-Authored-By: Claude Opus 4.7 (1M context) --- solver-cli/src/commands/token.rs | 45 ++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/solver-cli/src/commands/token.rs b/solver-cli/src/commands/token.rs index aea91514..3d2e7944 100644 --- a/solver-cli/src/commands/token.rs +++ b/solver-cli/src/commands/token.rs @@ -1,6 +1,6 @@ use alloy::primitives::{Address, U256}; use anyhow::{bail, Result}; -use clap::Subcommand; +use clap::{Subcommand, ValueEnum}; use std::env; use std::path::PathBuf; use std::str::FromStr; @@ -10,27 +10,35 @@ use crate::state::{StateManager, TokenInfo}; use crate::utils::*; use crate::OutputFormat; +/// Token kind backing a registered asset. +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +#[clap(rename_all = "lowercase")] +pub enum TokenType { + Erc20, + Native, +} + +impl TokenType { + /// Canonical lowercase string used in state.json and downstream configs. + pub fn as_str(self) -> &'static str { + match self { + Self::Erc20 => "erc20", + Self::Native => "native", + } + } +} + struct AddTokenParams { chain_ref: String, symbol: String, address: String, decimals: u8, - token_type: String, + token_type: TokenType, warp_token: Option, warp_token_type: Option, dir: Option, } -fn validate_token_type(t: &str) -> Result<()> { - match t.to_ascii_lowercase().as_str() { - "erc20" | "native" => Ok(()), - other => bail!( - "Invalid --token-type {:?}; expected \"erc20\" or \"native\"", - other - ), - } -} - fn validate_warp_token_type(t: &str) -> Result<()> { match t.to_ascii_lowercase().as_str() { "collateral" | "synthetic" | "native" => Ok(()), @@ -61,9 +69,9 @@ pub enum TokenCommand { #[arg(long, default_value = "18")] decimals: u8, - /// Token type: "erc20" (default) or "native" - #[arg(long, default_value = "erc20")] - token_type: String, + /// Token type + #[arg(long, value_enum, default_value_t = TokenType::Erc20)] + token_type: TokenType, /// Hyperlane warp router address for this token (takes precedence over the /// chain-level warp_token). Required when a chain has multiple tokens with @@ -190,7 +198,6 @@ impl TokenCommand { out.header("Adding Token"); - validate_token_type(&token_type)?; if let Some(ref t) = warp_token_type { validate_warp_token_type(t)?; } @@ -227,7 +234,7 @@ impl TokenCommand { print_kv("Symbol", &symbol_upper); print_address("Address", &address); print_kv("Decimals", decimals); - print_kv("Token type", &token_type); + print_kv("Token type", token_type.as_str()); if let Some(ref wt) = warp_token { print_address("Warp router", wt); } @@ -242,7 +249,7 @@ impl TokenCommand { address: address.clone(), symbol: symbol_upper.clone(), decimals, - token_type: token_type.clone(), + token_type: token_type.as_str().to_string(), warp_token: warp_token.clone(), warp_token_type: warp_token_type.clone(), }, @@ -262,7 +269,7 @@ impl TokenCommand { "symbol": symbol_upper, "address": address, "decimals": decimals, - "token_type": token_type, + "token_type": token_type.as_str(), "warp_token": warp_token, "warp_token_type": warp_token_type, }))?; From 8639ddc1d5bf521780d7a288ca36e987570af643 Mon Sep 17 00:00:00 2001 From: jonas089 Date: Tue, 5 May 2026 15:44:34 +0200 Subject: [PATCH 10/10] refactor: introduce solver-shared crate with TokenType / WarpTokenType enums Adds a small workspace crate (`solver-shared`) that hosts typed enums shared by the solver-cli and rebalancer crates: TokenType = Erc20 | Native WarpTokenType = Collateral | Synthetic | Native Both derive `clap::ValueEnum` (with `rename_all = "lowercase"`) and `serde::{Serialize, Deserialize}` (with the same lowercase encoding), so the on-disk wire format in state.json and the CLI surface both stay identical to the v0.2.x string-based representation. Existing state.json files keep deserializing without migration; the new state-types tests cover this round-trip explicitly, and `solver-cli token add --help` now self-documents possible values for both `--token-type` and the previously free-form `--warp-token-type`. Replaces every internal `String` / `Option` site that represented these concepts: - solver-cli/src/state/types.rs: TokenInfo.token_type -> TokenType, warp_token_type -> Option; default helper removed in favor of `Default for TokenType`. - solver-cli/src/commands/token.rs: drops the local TokenType + the hand-rolled validate_warp_token_type() in favor of the shared enums; --warp-token-type is now `value_enum`-validated by clap. - solver-cli/src/commands/chain.rs: TokenInfo literal uses TokenType::Erc20 directly. - solver-cli/src/deployment/deployer.rs: parses warp_token_type out of the deployment artifact through serde so a malformed value fails loudly instead of being stored as an arbitrary string; populate_tokens_from_hyperlane now returns Result. - solver-cli/src/rebalancer/config_gen.rs: test fixture switches to enum literals. - rebalancer/src/config.rs: StateToken.token_type -> Option; the `eq_ignore_ascii_case("native")` runtime string match becomes an exhaustive `match` on the enum (None and Some(Erc20) both still map to AssetType::Erc20). The Hyperlane Dockerfile and entrypoint script are untouched: their `warp_token_type` references are JS-side configs fed to the Hyperlane TS CLI, not Rust types, and the lowercase string spelling is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 12 +++ Cargo.toml | 1 + rebalancer/Cargo.toml | 1 + rebalancer/src/config.rs | 9 +- solver-cli/Cargo.toml | 3 + solver-cli/src/commands/chain.rs | 3 +- solver-cli/src/commands/token.rs | 55 +++-------- solver-cli/src/deployment/deployer.rs | 16 +++- solver-cli/src/rebalancer/config_gen.rs | 9 +- solver-cli/src/state/types.rs | 69 ++++++++++++-- solver-shared/Cargo.toml | 14 +++ solver-shared/src/lib.rs | 6 ++ solver-shared/src/token.rs | 119 ++++++++++++++++++++++++ 13 files changed, 252 insertions(+), 65 deletions(-) create mode 100644 solver-shared/Cargo.toml create mode 100644 solver-shared/src/lib.rs create mode 100644 solver-shared/src/token.rs diff --git a/Cargo.lock b/Cargo.lock index 9e927931..6c98c861 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4456,6 +4456,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "solver-shared", "tempfile", "tokio", "toml 0.9.12+spec-1.1.0", @@ -5451,6 +5452,7 @@ dependencies = [ "solver-pricing", "solver-service", "solver-settlement-impls", + "solver-shared", "solver-storage", "solver-types", "tempfile", @@ -5689,6 +5691,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "solver-shared" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_json", + "toml 0.8.23", +] + [[package]] name = "solver-storage" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index db1494e5..fa62171f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,5 +4,6 @@ members = [ "rebalancer", "solver-cli", "solver-settlement", + "solver-shared", ] resolver = "2" diff --git a/rebalancer/Cargo.toml b/rebalancer/Cargo.toml index 0155b1d4..b91b7c86 100644 --- a/rebalancer/Cargo.toml +++ b/rebalancer/Cargo.toml @@ -28,6 +28,7 @@ reqwest = { version = "0.12", features = ["json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" +solver-shared = { path = "../solver-shared" } tokio = { version = "1.34", features = ["full"] } toml = "0.9" tracing = "0.1" diff --git a/rebalancer/src/config.rs b/rebalancer/src/config.rs index 540a73e6..fee54869 100644 --- a/rebalancer/src/config.rs +++ b/rebalancer/src/config.rs @@ -1,6 +1,7 @@ use alloy::primitives::Address; use anyhow::{bail, Context, Result}; use serde::Deserialize; +use solver_shared::TokenType; use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; @@ -192,7 +193,7 @@ struct StateToken { symbol: String, decimals: u8, #[serde(default)] - token_type: Option, + token_type: Option, #[serde(default)] warp_token: Option, } @@ -435,9 +436,9 @@ fn collect_assets( symbol ), }; - let asset_type = match token.token_type.as_deref() { - Some(t) if t.eq_ignore_ascii_case("native") => AssetType::Native, - _ => AssetType::Erc20, + let asset_type = match token.token_type { + Some(TokenType::Native) => AssetType::Native, + Some(TokenType::Erc20) | None => AssetType::Erc20, }; // Native warp routes (HypNative) have no separate ERC20 underlying; // the warp router itself is the collateral path and `address` field diff --git a/solver-cli/Cargo.toml b/solver-cli/Cargo.toml index 88b25c26..edd7a548 100644 --- a/solver-cli/Cargo.toml +++ b/solver-cli/Cargo.toml @@ -83,6 +83,9 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } uuid = { version = "1.6", features = ["v4", "serde"] } +# Shared workspace types +solver-shared = { path = "../solver-shared" } + # Unix process management [target.'cfg(unix)'.dependencies] alloy-primitives = { version = "1.0.37", features = ["std", "serde"], optional = true } diff --git a/solver-cli/src/commands/chain.rs b/solver-cli/src/commands/chain.rs index 4f5cefb5..8a17c32e 100644 --- a/solver-cli/src/commands/chain.rs +++ b/solver-cli/src/commands/chain.rs @@ -1,5 +1,6 @@ use anyhow::Result; use clap::Subcommand; +use solver_shared::TokenType; use std::collections::HashMap; use std::env; use std::path::PathBuf; @@ -277,7 +278,7 @@ impl ChainCommand { address: parsed.address, symbol: parsed.symbol, decimals, - token_type: "erc20".to_string(), + token_type: TokenType::Erc20, warp_token: None, warp_token_type: None, }, diff --git a/solver-cli/src/commands/token.rs b/solver-cli/src/commands/token.rs index 3d2e7944..0be5e9f1 100644 --- a/solver-cli/src/commands/token.rs +++ b/solver-cli/src/commands/token.rs @@ -1,6 +1,7 @@ use alloy::primitives::{Address, U256}; -use anyhow::{bail, Result}; -use clap::{Subcommand, ValueEnum}; +use anyhow::Result; +use clap::Subcommand; +use solver_shared::{TokenType, WarpTokenType}; use std::env; use std::path::PathBuf; use std::str::FromStr; @@ -10,24 +11,6 @@ use crate::state::{StateManager, TokenInfo}; use crate::utils::*; use crate::OutputFormat; -/// Token kind backing a registered asset. -#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] -#[clap(rename_all = "lowercase")] -pub enum TokenType { - Erc20, - Native, -} - -impl TokenType { - /// Canonical lowercase string used in state.json and downstream configs. - pub fn as_str(self) -> &'static str { - match self { - Self::Erc20 => "erc20", - Self::Native => "native", - } - } -} - struct AddTokenParams { chain_ref: String, symbol: String, @@ -35,20 +18,10 @@ struct AddTokenParams { decimals: u8, token_type: TokenType, warp_token: Option, - warp_token_type: Option, + warp_token_type: Option, dir: Option, } -fn validate_warp_token_type(t: &str) -> Result<()> { - match t.to_ascii_lowercase().as_str() { - "collateral" | "synthetic" | "native" => Ok(()), - other => bail!( - "Invalid --warp-token-type {:?}; expected \"collateral\", \"synthetic\", or \"native\"", - other - ), - } -} - #[derive(Subcommand)] pub enum TokenCommand { /// Add a token to a chain @@ -79,9 +52,9 @@ pub enum TokenCommand { #[arg(long)] warp_token: Option, - /// Hyperlane warp router type: "collateral", "synthetic", or "native". - #[arg(long)] - warp_token_type: Option, + /// Hyperlane warp router type + #[arg(long, value_enum)] + warp_token_type: Option, /// Project directory #[arg(long)] @@ -198,10 +171,6 @@ impl TokenCommand { out.header("Adding Token"); - if let Some(ref t) = warp_token_type { - validate_warp_token_type(t)?; - } - // Load state let mut state = state_mgr.load_or_error().await?; @@ -238,8 +207,8 @@ impl TokenCommand { if let Some(ref wt) = warp_token { print_address("Warp router", wt); } - if let Some(ref wtt) = warp_token_type { - print_kv("Warp router type", wtt); + if let Some(wtt) = warp_token_type { + print_kv("Warp router type", wtt.as_str()); } // Add token @@ -249,9 +218,9 @@ impl TokenCommand { address: address.clone(), symbol: symbol_upper.clone(), decimals, - token_type: token_type.as_str().to_string(), + token_type, warp_token: warp_token.clone(), - warp_token_type: warp_token_type.clone(), + warp_token_type, }, ); } @@ -269,7 +238,7 @@ impl TokenCommand { "symbol": symbol_upper, "address": address, "decimals": decimals, - "token_type": token_type.as_str(), + "token_type": token_type, "warp_token": warp_token, "warp_token_type": warp_token_type, }))?; diff --git a/solver-cli/src/deployment/deployer.rs b/solver-cli/src/deployment/deployer.rs index 59333dff..03171acf 100644 --- a/solver-cli/src/deployment/deployer.rs +++ b/solver-cli/src/deployment/deployer.rs @@ -1,6 +1,7 @@ #![allow(clippy::too_many_arguments)] use anyhow::{Context, Result}; +use solver_shared::{TokenType, WarpTokenType}; use std::path::Path; use tracing::info; @@ -148,7 +149,7 @@ impl Deployer { hyp_addrs, token_symbol, token_decimals, - ); + )?; } // Apply {NAME}_DOMAIN_ID env-var override (takes precedence over the @@ -206,7 +207,7 @@ impl Deployer { hyp_addrs: &serde_json::Value, token_symbol: &str, token_decimals: u8, - ) { + ) -> Result<()> { let chain_name = chain_config.name.to_lowercase(); // Look up this chain in the Hyperlane addresses @@ -236,7 +237,13 @@ impl Deployer { let warp_token_type = chain_data .get("warp_token_type") .and_then(|v| v.as_str()) - .map(|s| s.to_string()); + .map(|s| { + serde_json::from_value::(serde_json::Value::String( + s.to_string(), + )) + .with_context(|| format!("Invalid warp_token_type in deployment artifact: {s}")) + }) + .transpose()?; if let Some(addr) = token_address { chain_config.tokens.insert( @@ -245,7 +252,7 @@ impl Deployer { address: addr, symbol: token_symbol.to_string(), decimals: token_decimals, - token_type: "erc20".to_string(), + token_type: TokenType::Erc20, warp_token, warp_token_type, }, @@ -281,6 +288,7 @@ impl Deployer { }; chain_config.contracts.hyperlane = Some(hyperlane); } + Ok(()) } } diff --git a/solver-cli/src/rebalancer/config_gen.rs b/solver-cli/src/rebalancer/config_gen.rs index 9230b867..49d01ab7 100644 --- a/solver-cli/src/rebalancer/config_gen.rs +++ b/solver-cli/src/rebalancer/config_gen.rs @@ -194,6 +194,7 @@ mod tests { use crate::state::{ ChainConfig, ContractAddresses, HyperlaneAddresses, SolverState, TokenInfo, }; + use solver_shared::{TokenType, WarpTokenType}; use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; @@ -232,9 +233,9 @@ mod tests { address: "0x0000000000000000000000000000000000001111".to_string(), symbol: "USDC".to_string(), decimals: 6, - token_type: "erc20".to_string(), + token_type: TokenType::Erc20, warp_token: Some("0x0000000000000000000000000000000000009999".to_string()), - warp_token_type: Some("synthetic".to_string()), + warp_token_type: Some(WarpTokenType::Synthetic), }, )]), deployer: None, @@ -266,9 +267,9 @@ mod tests { address: "0x0000000000000000000000000000000000002222".to_string(), symbol: "USDC".to_string(), decimals: 6, - token_type: "erc20".to_string(), + token_type: TokenType::Erc20, warp_token: Some("0x0000000000000000000000000000000000009998".to_string()), - warp_token_type: Some("synthetic".to_string()), + warp_token_type: Some(WarpTokenType::Synthetic), }, )]), deployer: None, diff --git a/solver-cli/src/state/types.rs b/solver-cli/src/state/types.rs index 88a64cc6..6dfda4f7 100644 --- a/solver-cli/src/state/types.rs +++ b/solver-cli/src/state/types.rs @@ -1,5 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use solver_shared::{TokenType, WarpTokenType}; use std::collections::HashMap; /// The main state file structure @@ -139,9 +140,9 @@ pub struct TokenInfo { /// Token decimals pub decimals: u8, - /// Token type ("erc20" or "native") - #[serde(default = "default_token_type")] - pub token_type: String, + /// Token kind (defaults to ERC20 for backward compatibility with existing state files). + #[serde(default)] + pub token_type: TokenType, /// Hyperlane warp router address for this token (HypERC20Collateral / /// HypSynthetic / HypNative). Required for assets that the rebalancer @@ -149,13 +150,10 @@ pub struct TokenInfo { #[serde(default, skip_serializing_if = "Option::is_none")] pub warp_token: Option, - /// Hyperlane warp router type: "collateral" | "synthetic" | "native". + /// Hyperlane warp router variant; mirrors the on-chain HypERC20Collateral / + /// HypSynthetic / HypNative contract kind. #[serde(default, skip_serializing_if = "Option::is_none")] - pub warp_token_type: Option, -} - -fn default_token_type() -> String { - "erc20".to_string() + pub warp_token_type: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -266,3 +264,56 @@ impl SolverState { !self.chains.is_empty() && self.chains.values().all(|c| c.contracts.is_complete()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Existing v0.2.x `state.json` files store `token_type` and `warp_token_type` + /// as plain lowercase strings. The shared enums use `serde(rename_all = "lowercase")`, + /// so loading those old files must keep working without migration. + #[test] + fn token_info_deserializes_legacy_string_values() { + let json = r#"{ + "address": "0x0000000000000000000000000000000000000001", + "symbol": "USDC", + "decimals": 6, + "token_type": "native", + "warp_token": "0x0000000000000000000000000000000000000002", + "warp_token_type": "collateral" + }"#; + + let info: TokenInfo = serde_json::from_str(json).expect("legacy state.json must parse"); + assert_eq!(info.token_type, TokenType::Native); + assert_eq!(info.warp_token_type, Some(WarpTokenType::Collateral)); + } + + #[test] + fn token_info_defaults_token_type_to_erc20_when_missing() { + let json = r#"{ + "address": "0x0000000000000000000000000000000000000001", + "symbol": "USDC", + "decimals": 6 + }"#; + + let info: TokenInfo = serde_json::from_str(json).expect("missing token_type must default"); + assert_eq!(info.token_type, TokenType::Erc20); + assert_eq!(info.warp_token_type, None); + } + + #[test] + fn token_info_serializes_to_lowercase_strings() { + let info = TokenInfo { + address: "0x0000000000000000000000000000000000000001".to_string(), + symbol: "USDC".to_string(), + decimals: 6, + token_type: TokenType::Native, + warp_token: None, + warp_token_type: Some(WarpTokenType::Synthetic), + }; + + let value = serde_json::to_value(&info).unwrap(); + assert_eq!(value["token_type"], "native"); + assert_eq!(value["warp_token_type"], "synthetic"); + } +} diff --git a/solver-shared/Cargo.toml b/solver-shared/Cargo.toml new file mode 100644 index 00000000..5b332524 --- /dev/null +++ b/solver-shared/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "solver-shared" +version = "0.1.0" +edition = "2021" +description = "Shared types reused across the solver-cli, rebalancer, and oracle-operator crates" +license = "MIT" + +[dependencies] +clap = { version = "4.4", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } + +[dev-dependencies] +serde_json = "1.0" +toml = "0.8" diff --git a/solver-shared/src/lib.rs b/solver-shared/src/lib.rs new file mode 100644 index 00000000..7eaf9b29 --- /dev/null +++ b/solver-shared/src/lib.rs @@ -0,0 +1,6 @@ +//! Shared types used across `solver-cli`, `rebalancer`, and any future workspace +//! crates that need to agree on token-related schema. + +pub mod token; + +pub use token::{TokenType, WarpTokenType}; diff --git a/solver-shared/src/token.rs b/solver-shared/src/token.rs new file mode 100644 index 00000000..81298af6 --- /dev/null +++ b/solver-shared/src/token.rs @@ -0,0 +1,119 @@ +//! Token kind enums shared between the CLI surface (`clap`) and the persisted +//! state.json / TOML schemas (`serde`). Both encodings use the same lowercase +//! string spellings, so existing config and state files keep parsing. + +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Underlying asset kind for a registered token. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum)] +#[serde(rename_all = "lowercase")] +#[clap(rename_all = "lowercase")] +pub enum TokenType { + #[default] + Erc20, + Native, +} + +impl TokenType { + /// Canonical lowercase identifier used in state.json and downstream configs. + pub const fn as_str(self) -> &'static str { + match self { + Self::Erc20 => "erc20", + Self::Native => "native", + } + } +} + +impl fmt::Display for TokenType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Hyperlane warp router variant for a token. Maps to the on-chain contract +/// kind: `HypERC20Collateral`, `HypSynthetic`, or `HypNative`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum)] +#[serde(rename_all = "lowercase")] +#[clap(rename_all = "lowercase")] +pub enum WarpTokenType { + Collateral, + Synthetic, + Native, +} + +impl WarpTokenType { + /// Canonical lowercase identifier used in state.json and downstream configs. + pub const fn as_str(self) -> &'static str { + match self { + Self::Collateral => "collateral", + Self::Synthetic => "synthetic", + Self::Native => "native", + } + } +} + +impl fmt::Display for WarpTokenType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_type_serde_roundtrip() { + for (variant, encoded) in [ + (TokenType::Erc20, "\"erc20\""), + (TokenType::Native, "\"native\""), + ] { + let json = serde_json::to_string(&variant).unwrap(); + assert_eq!(json, encoded); + let back: TokenType = serde_json::from_str(encoded).unwrap(); + assert_eq!(back, variant); + } + } + + #[test] + fn warp_token_type_serde_roundtrip() { + for (variant, encoded) in [ + (WarpTokenType::Collateral, "\"collateral\""), + (WarpTokenType::Synthetic, "\"synthetic\""), + (WarpTokenType::Native, "\"native\""), + ] { + let json = serde_json::to_string(&variant).unwrap(); + assert_eq!(json, encoded); + let back: WarpTokenType = serde_json::from_str(encoded).unwrap(); + assert_eq!(back, variant); + } + } + + #[test] + fn token_type_rejects_unknown() { + let err = serde_json::from_str::("\"weird\"").unwrap_err(); + assert!(err.to_string().contains("unknown variant")); + } + + #[test] + fn warp_token_type_rejects_unknown() { + let err = serde_json::from_str::("\"weird\"").unwrap_err(); + assert!(err.to_string().contains("unknown variant")); + } + + #[test] + fn clap_value_enum_parses_lowercase() { + let parsed = TokenType::from_str("erc20", true).unwrap(); + assert_eq!(parsed, TokenType::Erc20); + let parsed = WarpTokenType::from_str("synthetic", true).unwrap(); + assert_eq!(parsed, WarpTokenType::Synthetic); + } + + #[test] + fn display_matches_as_str() { + assert_eq!(TokenType::Erc20.to_string(), "erc20"); + assert_eq!(WarpTokenType::Collateral.to_string(), "collateral"); + } +}