diff --git a/README.md b/README.md index 92f5deb..a80ebef 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ DODO_PROJECT_ID=your-id ## Configuration -Supported environment variables are intentionally limited. Runtime config is read only from `PRIVATE_KEY`, `KEYSTORE_PATH`, `KEYSTORE_PASSWORD_FILE`, `KEYSTORE_PASSWORD_ENV`, `KEYSTORE_PASSWORD`, `WALLET_ADDRESS`, `CHAIN_ID`, `DODO_API_KEY`, `DODO_PROJECT_ID`, `DODO_API_URL`, `COINGECKO_API_KEY`, `COINGECKO_API_URL`, `DEXSCREENER_API_URL`, `DEBANK_API_KEY`, `DEBANK_API_URL`, `ZERION_API_KEY`, `ZERION_API_URL`, `GOLDRUSH_API_KEY`, and `GOLDRUSH_API_URL`. CLI flags still override where available. +Supported environment variables are intentionally limited. Runtime config is read only from `PRIVATE_KEY`, `KEYSTORE_PATH`, `KEYSTORE_PASSWORD_FILE`, `KEYSTORE_PASSWORD_ENV`, `KEYSTORE_PASSWORD`, `WALLET_ADDRESS`, `CHAIN_ID`, `RPC_URL_`, `DODO_API_KEY`, `DODO_PROJECT_ID`, `DODO_API_URL`, `COINGECKO_API_KEY`, `COINGECKO_API_URL`, `DEXSCREENER_API_URL`, `DEBANK_API_KEY`, `DEBANK_API_URL`, `ZERION_API_KEY`, `ZERION_API_URL`, `GOLDRUSH_API_KEY`, and `GOLDRUSH_API_URL`. CLI flags still override where available. | Variable | CLI flag | Default | Description | |------------------------|-----------------------|--------------------------------|------------------------------------------------| @@ -79,7 +79,8 @@ Supported environment variables are intentionally limited. Runtime config is rea | `KEYSTORE_PASSWORD_ENV` | `--password-env` | — | Read keystore password from named env var | | `KEYSTORE_PASSWORD` | — | — | Default env var used for keystore password | | `WALLET_ADDRESS` | `--wallet-address` | — | Wallet address for balance/simulate and dry-run sender fallback | -| `--rpc-url` | CLI only | Chain's built-in public RPC | Explicit JSON-RPC override | +| `--rpc-url` | CLI only | Chain's built-in public RPC | Explicit JSON-RPC override (all chains) | +| `RPC_URL_` | `config set rpc_url` | Chain's built-in public RPC | Persisted per-chain RPC override | | `CHAIN_ID` | `--chain-id` | `1` (Ethereum mainnet) | Active chain ID | | `DODO_API_KEY` | — | Compiled-in default | DODO routing API key | | `DODO_PROJECT_ID` | — | Compiled-in default | DODO project ID for token list lookup | @@ -144,7 +145,35 @@ Custom token behavior: | Plume | 98866 | | Sepolia Testnet | 11155111 | -For unsupported chain IDs, pass `--rpc-url` manually. +For unsupported chain IDs, pass `--rpc-url` manually, or persist a per-chain RPC +with `chainpilot config set rpc_url` (see [Custom RPC endpoints](#custom-rpc-endpoints)). + +### Custom RPC endpoints + +Each chain uses a built-in public RPC by default. To point a chain at your own +node, persist a per-chain override (stored as `RPC_URL_` in the config +file). Precedence is **`--rpc-url` flag > configured `RPC_URL_` > chain +default**; the `--rpc-url` flag still overrides everything for all chains. + +```bash +# Set the RPC for one chain (bare `rpc_url` targets the active --chain-id) +chainpilot --chain-id 56 config set rpc_url https://my-bsc-node +chainpilot config set rpc_url.56 https://my-bsc-node # equivalent + +# Set several chains at once with a JSON map (merges, keeping other chains) +chainpilot config set rpc_url '{"1":"https://my-eth-node","56":"https://my-bsc-node"}' + +# Inspect / remove +chainpilot config get rpc_url # all configured chains +chainpilot config get rpc_url.56 # one chain +chainpilot config unset rpc_url.56 # one chain +chainpilot config unset rpc_url # all chains +``` + +Note: `set` without a `.` suffix targets the active chain +(`--chain-id`, default 1). Bare `get`/`unset rpc_url`, however, always operate on +**all** configured chains regardless of `--chain-id` / `CHAIN_ID`; use the +`rpc_url.` form to target a single chain. ## Non-EVM Support (Solana / Bitcoin) diff --git a/README_CN.md b/README_CN.md index f683f3d..6fb0962 100644 --- a/README_CN.md +++ b/README_CN.md @@ -63,7 +63,7 @@ DODO_PROJECT_ID=your-id ## 配置 -环境变量刻意收敛为少数几个。运行时只读取 `PRIVATE_KEY`、`KEYSTORE_PATH`、`KEYSTORE_PASSWORD_FILE`、`KEYSTORE_PASSWORD_ENV`、`KEYSTORE_PASSWORD`、`WALLET_ADDRESS`、`CHAIN_ID`、`DODO_API_KEY`、`DODO_PROJECT_ID`、`DODO_API_URL`、`COINGECKO_API_KEY`、`COINGECKO_API_URL`、`DEXSCREENER_API_URL`、`DEBANK_API_KEY`、`DEBANK_API_URL`、`ZERION_API_KEY`、`ZERION_API_URL`、`GOLDRUSH_API_KEY`、`GOLDRUSH_API_URL`;有对应 CLI 标志的情况下,CLI 仍然优先。 +环境变量刻意收敛为少数几个。运行时只读取 `PRIVATE_KEY`、`KEYSTORE_PATH`、`KEYSTORE_PASSWORD_FILE`、`KEYSTORE_PASSWORD_ENV`、`KEYSTORE_PASSWORD`、`WALLET_ADDRESS`、`CHAIN_ID`、`RPC_URL_`、`DODO_API_KEY`、`DODO_PROJECT_ID`、`DODO_API_URL`、`COINGECKO_API_KEY`、`COINGECKO_API_URL`、`DEXSCREENER_API_URL`、`DEBANK_API_KEY`、`DEBANK_API_URL`、`ZERION_API_KEY`、`ZERION_API_URL`、`GOLDRUSH_API_KEY`、`GOLDRUSH_API_URL`;有对应 CLI 标志的情况下,CLI 仍然优先。 | 变量名 | CLI 标志 | 默认值 | 说明 | |------------------------|-----------------------|------------------------------|------------------------------------| @@ -73,7 +73,8 @@ DODO_PROJECT_ID=your-id | `KEYSTORE_PASSWORD_ENV` | `--password-env` | — | 从指定环境变量读取 keystore 密码 | | `KEYSTORE_PASSWORD` | — | — | keystore 密码的默认环境变量 | | `WALLET_ADDRESS` | `--wallet-address` | — | 余额查询 / 模拟 / dry-run sender fallback 钱包地址 | -| `--rpc-url` | 仅 CLI | 链内置公共 RPC | 显式覆盖 JSON-RPC 端点 | +| `--rpc-url` | 仅 CLI | 链内置公共 RPC | 显式覆盖 JSON-RPC 端点(所有链) | +| `RPC_URL_` | `config set rpc_url` | 链内置公共 RPC | 持久化的按链 RPC 覆盖 | | `CHAIN_ID` | `--chain-id` | `1`(以太坊主网) | 当前链 ID | | `DODO_API_KEY` | — | 编译时内嵌默认值 | DODO 路由 API Key | | `DODO_PROJECT_ID` | — | 编译时内嵌默认值 | DODO 项目 ID,用于代币列表查询 | @@ -138,7 +139,34 @@ RUST_LOG=debug chainpilot ... | Plume | 98866 | | Sepolia Testnet | 11155111 | -不在列表中的链,请手动传入 `--rpc-url`。 +不在列表中的链,请手动传入 `--rpc-url`,或用 `chainpilot config set rpc_url` +持久化一个按链的 RPC(见下方[自定义 RPC 端点](#自定义-rpc-端点))。 + +### 自定义 RPC 端点 + +每条链默认使用内置的公共 RPC。如需指向你自己的节点,可持久化一个按链的覆盖 +(以 `RPC_URL_` 存储在配置文件中)。优先级为 **`--rpc-url` 标志 > +已配置的 `RPC_URL_` > 链默认值**;`--rpc-url` 标志仍会对所有链生效、 +优先级最高。 + +```bash +# 设置某条链的 RPC(不带后缀的 `rpc_url` 作用于当前 --chain-id) +chainpilot --chain-id 56 config set rpc_url https://my-bsc-node +chainpilot config set rpc_url.56 https://my-bsc-node # 等价写法 + +# 用 JSON map 一次配置多条链(合并写入,保留其他链) +chainpilot config set rpc_url '{"1":"https://my-eth-node","56":"https://my-bsc-node"}' + +# 查看 / 删除 +chainpilot config get rpc_url # 所有已配置的链 +chainpilot config get rpc_url.56 # 单条链 +chainpilot config unset rpc_url.56 # 单条链 +chainpilot config unset rpc_url # 全部 +``` + +注意:`set` 不带 `.` 后缀时作用于当前链(`--chain-id`,默认 1);但 +`get`/`unset rpc_url` 不带后缀时**始终**作用于**所有**已配置的链,与 +`--chain-id` / `CHAIN_ID` 无关;如需针对单条链,请使用 `rpc_url.` 形式。 ## 非 EVM 支持(Solana / 比特币) diff --git a/skills/chainpilot/SKILL.md b/skills/chainpilot/SKILL.md index 832be46..b0662ae 100644 --- a/skills/chainpilot/SKILL.md +++ b/skills/chainpilot/SKILL.md @@ -101,7 +101,8 @@ chainpilot [GLOBAL_FLAGS] [SUBcommand_FLAGS] | `--password-file ` | `KEYSTORE_PASSWORD_FILE` | — | Read keystore password from file | | `--password-env ` | `KEYSTORE_PASSWORD_ENV` | — | Read keystore password from the named env var | | `--wallet-address ` | `WALLET_ADDRESS` | — | Read-only wallet context and dry-run sender fallback | -| `--rpc-url ` | — | Chain's public RPC | Explicit JSON-RPC override | +| `--rpc-url ` | — | Chain's public RPC | Explicit JSON-RPC override (all chains) | +| `config set rpc_url` | `RPC_URL_` | Chain's public RPC | Persisted per-chain RPC override | | `--chain-id ` | `CHAIN_ID` | `1` | Global chain context | **Context propagation**: Once the user specifies a `--wallet-address` or `--chain-id`, @@ -119,7 +120,7 @@ the user explicitly asks for a different one. Runtime env vars are intentionally limited to `PRIVATE_KEY`, `KEYSTORE_PATH`, `KEYSTORE_PASSWORD_FILE`, `KEYSTORE_PASSWORD_ENV`, `KEYSTORE_PASSWORD`, -`WALLET_ADDRESS`, `CHAIN_ID`, `DODO_API_KEY`, `DODO_PROJECT_ID`, +`WALLET_ADDRESS`, `CHAIN_ID`, `RPC_URL_`, `DODO_API_KEY`, `DODO_PROJECT_ID`, `DODO_API_URL`, `COINGECKO_API_URL`, `COINGECKO_API_KEY`, `DEXSCREENER_API_URL`, `DEBANK_API_KEY`, `DEBANK_API_URL`, `ZERION_API_KEY`, `ZERION_API_URL`, `GOLDRUSH_API_KEY`, @@ -1018,7 +1019,8 @@ confirm with the user before using any candidate address in a swap or approval. | Plume | 98866 | | Sepolia Testnet | 11155111 | -For unsupported chain IDs, pass `--rpc-url` manually. +For unsupported chain IDs, pass `--rpc-url` manually, or persist a per-chain RPC +with `chainpilot config set rpc_url` (see the `config` subcommands below). **Non-EVM analytics**: `chain` subcommands (and `protocol info/tvl/revenue/chains`) additionally accept `solana` / `svm` and `bitcoin` / `bvm` — read-only via @@ -1043,6 +1045,19 @@ Save an API key or configuration value. The value is written to the persistent c and immediately available to the current process. On Unix, the config file is written with owner-only permissions (`0600`). +**Per-chain RPC endpoints** use the special `rpc_url` key, stored as +`RPC_URL_`. Precedence is `--rpc-url` flag > configured +`RPC_URL_` > chain default RPC. + +```bash +# Active chain (--chain-id, default 1); or name the chain in the key +chainpilot --chain-id 56 config set rpc_url https://my-bsc-node +chainpilot config set rpc_url.56 https://my-bsc-node + +# Batch several chains with a JSON map (merges; unlisted chains untouched) +chainpilot config set rpc_url '{"1":"https://my-eth-node","56":"https://my-bsc-node"}' +``` + ### `config get` ```bash @@ -1050,6 +1065,9 @@ chainpilot config get ``` Show the current value of a configuration key. Sensitive values are partially masked. +For RPC endpoints, `config get rpc_url` lists every configured chain, while +`config get rpc_url.56` shows one chain. Bare `rpc_url` always covers all chains +regardless of `--chain-id` / `CHAIN_ID`. ### `config list` @@ -1065,7 +1083,10 @@ Show all configurable keys with their current values (sensitive values masked). chainpilot config unset ``` -Remove a configuration key from the config file. +Remove a configuration key from the config file. For RPC endpoints, +`config unset rpc_url.56` removes one chain, while a bare `config unset rpc_url` +always clears every configured chain — it ignores `--chain-id` / `CHAIN_ID`, so +it never silently removes just one. ### Configurable Keys @@ -1078,6 +1099,7 @@ Remove a configuration key from the config file. | `zerion_api_key` | `ZERION_API_KEY` | Yes | Zerion API key — second-tier wallet aggregator | | `goldrush_api_key` | `GOLDRUSH_API_KEY` | Yes | Goldrush / Covalent API key — third-tier wallet aggregator | | `dune_api_key` | `DUNE_API_KEY` | Yes | Dune Analytics API key — wallet labels fallback | +| `rpc_url[.]` | `RPC_URL_` | No | Per-chain RPC endpoint; accepts a single URL or a `{chainId: url}` JSON map | Only these keys are supported by `chainpilot config` today. Other runtime settings, such as `COINGECKO_API_URL`, `DEXSCREENER_API_URL`, diff --git a/src/chain/rpc.rs b/src/chain/rpc.rs index 6e8bd59..cc669cf 100644 --- a/src/chain/rpc.rs +++ b/src/chain/rpc.rs @@ -277,6 +277,7 @@ mod tests { AppConfig { rpc_url: "https://ethereum-rpc.publicnode.com".to_string(), rpc_url_overridden: false, + rpc_overrides: std::collections::HashMap::new(), chain_id: 1, chain_id_overridden: false, private_key: None, diff --git a/src/cli/config.rs b/src/cli/config.rs index 636a5af..6b3f676 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -21,14 +21,18 @@ pub enum ConfigAction { #[derive(Args)] pub struct ConfigSetArgs { - /// Configuration key name + /// Configuration key: an API key name, or `rpc_url[.]` for a + /// per-chain RPC endpoint (bare `rpc_url` targets the active `--chain-id`). pub key: String, - /// Value to set + /// Value to set. For `rpc_url`, a single URL, or a JSON map of + /// chainId -> URL to configure several chains at once + /// (e.g. '{"1":"https://eth","56":"https://bsc"}'). pub value: String, } #[derive(Args)] pub struct ConfigKeyArg { - /// Configuration key name + /// Configuration key: an API key name, or `rpc_url[.]`. Bare + /// `rpc_url` always targets all chains; use `rpc_url.` for one. pub key: String, } diff --git a/src/commands/config.rs b/src/commands/config.rs index f8b052c..13677d5 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -28,6 +28,164 @@ fn find_key(key: &str) -> Option<(&'static str, &'static str, bool)> { .map(|&(name, env, sensitive)| (name, env, sensitive)) } +/// A config key resolved to how it is stored. +enum ResolvedKey { + /// A fixed API-key style entry: (user-facing key, env var, sensitive). + Simple(&'static str, &'static str, bool), + /// A per-chain RPC entry. `Some(id)` targets one chain via a `.` suffix; + /// `None` means the key carried no chain, so each action decides the scope. + Rpc { chain: Option }, +} + +/// Parse a user-facing config key into its storage form. +/// Accepts the fixed API keys, plus `rpc_url` / `rpc` optionally suffixed with +/// a chain id (`rpc_url.56`). +fn resolve_key(key: &str) -> Result { + let lower = key.trim().to_lowercase(); + if let Some((name, env, sensitive)) = find_key(&lower) { + return Ok(ResolvedKey::Simple(name, env, sensitive)); + } + + let (head, chain) = match lower.split_once('.') { + Some((head, suffix)) => { + let id: u64 = suffix.parse().map_err(|_| { + ChainError::Config(format!("Invalid chain id '{}' in key '{}'", suffix, key)) + })?; + (head, Some(id)) + } + None => (lower.as_str(), None), + }; + + if head == "rpc_url" || head == "rpc" { + return Ok(ResolvedKey::Rpc { chain }); + } + + Err(unknown_key_error(key)) +} + +fn unknown_key_error(key: &str) -> ChainError { + ChainError::Config(format!( + "Unknown config key '{}'. Valid keys: {}, rpc_url[.]", + key, + CONFIGURABLE_KEYS + .iter() + .map(|(k, _, _)| *k) + .collect::>() + .join(", ") + )) +} + +/// Env var name that persists the RPC URL for a chain, e.g. `RPC_URL_56`. +fn rpc_env_var(chain_id: u64) -> String { + format!("{}{}", crate::config::RPC_URL_ENV_PREFIX, chain_id) +} + +/// If `key` is an `RPC_URL_` env var, return the chain id. +fn rpc_key_chain_id(key: &str) -> Option { + key.strip_prefix(crate::config::RPC_URL_ENV_PREFIX) + .and_then(|suffix| suffix.parse().ok()) +} + +/// Reject anything that is not a valid http(s) RPC URL, matching the parse +/// `OnChainClient::new` performs so misconfiguration fails here, not at first use. +fn validate_rpc_url(value: &str) -> Result<()> { + let url = url::Url::parse(value) + .map_err(|e| ChainError::Config(format!("Invalid RPC URL '{}': {}", value, e)))?; + match url.scheme() { + "http" | "https" => Ok(()), + other => Err(ChainError::Config(format!( + "Invalid RPC URL '{}': scheme '{}' is not http or https", + value, other + ))), + } +} + +/// Insert or overwrite `key` in the parsed config-file entries. +fn upsert(entries: &mut Vec<(String, String)>, key: &str, value: &str) { + if let Some(entry) = entries.iter_mut().find(|(k, _)| k == key) { + entry.1 = value.to_string(); + } else { + entries.push((key.to_string(), value.to_string())); + } +} + +/// Look up a key's value in the parsed config-file entries. +fn lookup<'a>(entries: &'a [(String, String)], key: &str) -> Option<&'a str> { + entries + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) +} + +/// The effective value for a simple key: env var wins over the config file. +fn effective_simple_value(env_path: &std::path::Path, env_var: &str) -> Option { + std::env::var(env_var) + .ok() + .filter(|v| !v.is_empty()) + .or_else(|| lookup(&read_config_file(env_path), env_var).map(str::to_string)) +} + +/// The effective per-chain RPC map: config-file entries as the base, with +/// `RPC_URL_` environment variables taking precedence (matching the +/// runtime precedence the app itself resolves). Sorted by chain id. +fn effective_rpc_overrides(env_path: &std::path::Path) -> std::collections::BTreeMap { + let mut map: std::collections::BTreeMap = read_config_file(env_path) + .into_iter() + .filter_map(|(k, v)| rpc_key_chain_id(&k).map(|id| (id, v))) + .collect(); + // Environment overrides the file (env wins at runtime). + for (k, v) in std::env::vars() { + if let Some(id) = rpc_key_chain_id(&k) { + if !v.trim().is_empty() { + map.insert(id, v); + } + } + } + map +} + +/// The effective configured RPC URL for a single chain (env over file). +fn effective_rpc_for(env_path: &std::path::Path, chain_id: u64) -> Option { + let env_var = rpc_env_var(chain_id); + std::env::var(&env_var) + .ok() + .filter(|v| !v.trim().is_empty()) + .or_else(|| lookup(&read_config_file(env_path), &env_var).map(str::to_string)) +} + +/// All configured `rpc_url.` rows (effective: env over file), sorted by chain id. +fn rpc_entries(env_path: &std::path::Path) -> Vec { + effective_rpc_overrides(env_path) + .into_iter() + .map(|(id, url)| ConfigEntry { + key: format!("rpc_url.{}", id), + value: Some(url), + masked: false, + }) + .collect() +} + +/// Emit a `ConfigStatus` result through the standard output path. +fn status_output( + key: &str, + action: &str, + message: &str, + output_mode: OutputMode, + config: &AppConfig, +) -> ExitCode { + let status = ConfigStatus { + key: key.to_string(), + action: action.to_string(), + message: message.to_string(), + }; + crate::output::print_output::( + Ok(status), + &format!("config.{}", action), + output_mode, + OutputContext::new(config.chain_id, false), + ) +} + fn mask_value(value: &str) -> String { if value.len() <= 8 { return "*".repeat(value.len()); @@ -95,39 +253,92 @@ fn set( output_mode: OutputMode, config: &AppConfig, ) -> Result { - let (key, env_var, _sensitive) = find_key(&args.key).ok_or_else(|| { - ChainError::Config(format!( - "Unknown config key '{}'. Valid keys: {}", - args.key, - CONFIGURABLE_KEYS - .iter() - .map(|(k, _, _)| *k) - .collect::>() - .join(", ") - )) - })?; + match resolve_key(&args.key)? { + ResolvedKey::Simple(key, env_var, _sensitive) => { + let mut entries = read_config_file(env_path); + upsert(&mut entries, env_var, &args.value); + write_config_file(env_path, &entries).map_err(|e| ChainError::Config(e.to_string()))?; + // Also set in current process so subsequent commands in the same session can use it. + std::env::set_var(env_var, &args.value); + Ok(status_output( + key, + "set", + "Saved successfully", + output_mode, + config, + )) + } + ResolvedKey::Rpc { chain } => set_rpc(chain, &args.value, env_path, output_mode, config), + } +} +/// Handle `config set rpc_url[.] `. +fn set_rpc( + chain: Option, + value: &str, + env_path: &std::path::Path, + output_mode: OutputMode, + config: &AppConfig, +) -> Result { + let value = value.trim(); let mut entries = read_config_file(env_path); - if let Some(entry) = entries.iter_mut().find(|(k, _)| k == env_var) { - entry.1 = args.value.clone(); - } else { - entries.push((env_var.to_string(), args.value.clone())); - } - write_config_file(env_path, &entries).map_err(|e| ChainError::Config(e.to_string()))?; - // Also set in current process so subsequent commands in the same session can use it. - std::env::set_var(env_var, &args.value); + // A leading `{` selects the batch JSON-map form: {"1":"https://...","56":"..."}. + if value.starts_with('{') { + if chain.is_some() { + return Err(ChainError::Config( + "A JSON map cannot be combined with a chain-specific key; use `rpc_url` with the map or `rpc_url.` with a single URL".to_string(), + )); + } + let map: std::collections::BTreeMap = serde_json::from_str(value) + .map_err(|e| ChainError::Config(format!("Invalid JSON RPC map: {}", e)))?; + if map.is_empty() { + return Err(ChainError::Config("JSON RPC map is empty".to_string())); + } + // Validate everything before mutating so a bad entry leaves config untouched. + let mut parsed: Vec<(u64, String)> = Vec::with_capacity(map.len()); + for (k, url) in &map { + let id: u64 = k + .parse() + .map_err(|_| ChainError::Config(format!("Invalid chain id '{}' in JSON map", k)))?; + validate_rpc_url(url)?; + parsed.push((id, url.clone())); + } + for (id, url) in &parsed { + let env_var = rpc_env_var(*id); + upsert(&mut entries, &env_var, url); + std::env::set_var(&env_var, url); + } + write_config_file(env_path, &entries).map_err(|e| ChainError::Config(e.to_string()))?; + let mut ids: Vec = parsed.iter().map(|(id, _)| *id).collect(); + ids.sort_unstable(); + let ids = ids + .iter() + .map(u64::to_string) + .collect::>() + .join(", "); + return Ok(status_output( + "rpc_url", + "set", + &format!("Saved RPC endpoints for chain(s): {}", ids), + output_mode, + config, + )); + } - let status = ConfigStatus { - key: key.to_string(), - action: "set".to_string(), - message: "Saved successfully".to_string(), - }; - Ok(crate::output::print_output::( - Ok(status), - "config.set", + // Single URL form: target chain from the key suffix, else the active chain. + validate_rpc_url(value)?; + let id = chain.unwrap_or(config.chain_id); + let env_var = rpc_env_var(id); + upsert(&mut entries, &env_var, value); + write_config_file(env_path, &entries).map_err(|e| ChainError::Config(e.to_string()))?; + std::env::set_var(&env_var, value); + Ok(status_output( + &format!("rpc_url.{}", id), + "set", + "Saved successfully", output_mode, - OutputContext::new(config.chain_id, false), + config, )) } @@ -137,43 +348,58 @@ fn get( output_mode: OutputMode, config: &AppConfig, ) -> Result { - let (key, env_var, sensitive) = find_key(&args.key).ok_or_else(|| { - ChainError::Config(format!( - "Unknown config key '{}'. Valid keys: {}", - args.key, - CONFIGURABLE_KEYS - .iter() - .map(|(k, _, _)| *k) - .collect::>() - .join(", ") - )) - })?; - - // Read from config file first, then fall back to env var. - let entries = read_config_file(env_path); - let raw_value = entries - .iter() - .find(|(k, _)| k == env_var) - .map(|(_, v)| v.clone()) - .or_else(|| std::env::var(env_var).ok()); + match resolve_key(&args.key)? { + ResolvedKey::Simple(key, env_var, sensitive) => { + // Report the effective value: env var wins over the config file, + // matching the precedence the app resolves at runtime. + let raw_value = effective_simple_value(env_path, env_var); - let display_value = if sensitive { - raw_value.map(|v| mask_value(&v)) - } else { - raw_value - }; + let display_value = if sensitive { + raw_value.map(|v| mask_value(&v)) + } else { + raw_value + }; - let entry = ConfigEntry { - key: key.to_string(), - value: display_value, - masked: sensitive, - }; - Ok(crate::output::print_output::( - Ok(entry), - "config.get", - output_mode, - OutputContext::new(config.chain_id, false), - )) + let entry = ConfigEntry { + key: key.to_string(), + value: display_value, + masked: sensitive, + }; + Ok(crate::output::print_output::( + Ok(entry), + "config.get", + output_mode, + OutputContext::new(config.chain_id, false), + )) + } + ResolvedKey::Rpc { chain } => { + // A `.` suffix targets one chain; bare `rpc_url` always shows the whole + // map. Scope deliberately ignores the active chain id (including an + // ambient CHAIN_ID) so bare get/unset behavior is predictable. + let target = chain; + match target { + Some(id) => { + let entry = ConfigEntry { + key: format!("rpc_url.{}", id), + value: effective_rpc_for(env_path, id), + masked: false, + }; + Ok(crate::output::print_output::( + Ok(entry), + "config.get", + output_mode, + OutputContext::new(config.chain_id, false), + )) + } + None => Ok(crate::output::print_output::>( + Ok(rpc_entries(env_path)), + "config.get", + output_mode, + OutputContext::new(config.chain_id, false), + )), + } + } + } } fn list( @@ -181,15 +407,10 @@ fn list( output_mode: OutputMode, config: &AppConfig, ) -> Result { - let entries = read_config_file(env_path); - let result: Vec = CONFIGURABLE_KEYS + let mut result: Vec = CONFIGURABLE_KEYS .iter() .map(|&(key, env_var, sensitive)| { - let raw_value = entries - .iter() - .find(|(k, _)| k == env_var) - .map(|(_, v)| v.clone()) - .or_else(|| std::env::var(env_var).ok()); + let raw_value = effective_simple_value(env_path, env_var); let display_value = if sensitive { raw_value.map(|v| mask_value(&v)) @@ -205,6 +426,9 @@ fn list( }) .collect(); + // Append configured per-chain RPC endpoints (sorted by chain id). + result.extend(rpc_entries(env_path)); + Ok(crate::output::print_output::>( Ok(result), "config.list", @@ -219,52 +443,92 @@ fn unset( output_mode: OutputMode, config: &AppConfig, ) -> Result { - let (key, env_var, _sensitive) = find_key(&args.key).ok_or_else(|| { - ChainError::Config(format!( - "Unknown config key '{}'. Valid keys: {}", - args.key, - CONFIGURABLE_KEYS - .iter() - .map(|(k, _, _)| *k) - .collect::>() - .join(", ") - )) - })?; + match resolve_key(&args.key)? { + ResolvedKey::Simple(key, env_var, _sensitive) => { + let mut entries = read_config_file(env_path); + let before_len = entries.len(); + entries.retain(|(k, _)| k != env_var); + + if entries.len() == before_len { + return Ok(status_output( + key, + "unset", + "Key was not set in config file", + output_mode, + config, + )); + } - let mut entries = read_config_file(env_path); - let before_len = entries.len(); - entries.retain(|(k, _)| k != env_var); - - if entries.len() == before_len { - let status = ConfigStatus { - key: key.to_string(), - action: "unset".to_string(), - message: "Key was not set in config file".to_string(), - }; - return Ok(crate::output::print_output::( - Ok(status), - "config.unset", - output_mode, - OutputContext::new(config.chain_id, false), - )); + write_config_file(env_path, &entries).map_err(|e| ChainError::Config(e.to_string()))?; + // Remove from current process env so it falls back to default. + std::env::remove_var(env_var); + Ok(status_output( + key, + "unset", + "Removed successfully", + output_mode, + config, + )) + } + ResolvedKey::Rpc { chain } => { + // A `.` suffix removes one chain; bare `rpc_url` always clears every + // configured RPC. Scope deliberately ignores the active chain id + // (including an ambient CHAIN_ID) so this destructive op is predictable. + let target = chain; + let mut entries = read_config_file(env_path); + + match target { + Some(id) => { + let env_var = rpc_env_var(id); + let key = format!("rpc_url.{}", id); + let before_len = entries.len(); + entries.retain(|(k, _)| k != &env_var); + if entries.len() == before_len { + return Ok(status_output( + &key, + "unset", + "Key was not set in config file", + output_mode, + config, + )); + } + write_config_file(env_path, &entries) + .map_err(|e| ChainError::Config(e.to_string()))?; + std::env::remove_var(&env_var); + Ok(status_output(&key, "unset", "Removed successfully", output_mode, config)) + } + None => { + let removed: Vec = entries + .iter() + .filter(|(k, _)| rpc_key_chain_id(k).is_some()) + .map(|(k, _)| k.clone()) + .collect(); + if removed.is_empty() { + return Ok(status_output( + "rpc_url", + "unset", + "No RPC endpoints were set", + output_mode, + config, + )); + } + entries.retain(|(k, _)| rpc_key_chain_id(k).is_none()); + write_config_file(env_path, &entries) + .map_err(|e| ChainError::Config(e.to_string()))?; + for env_var in &removed { + std::env::remove_var(env_var); + } + Ok(status_output( + "rpc_url", + "unset", + &format!("Removed {} RPC endpoint(s)", removed.len()), + output_mode, + config, + )) + } + } + } } - - write_config_file(env_path, &entries).map_err(|e| ChainError::Config(e.to_string()))?; - - // Remove from current process env so it falls back to default. - std::env::remove_var(env_var); - - let status = ConfigStatus { - key: key.to_string(), - action: "unset".to_string(), - message: "Removed successfully".to_string(), - }; - Ok(crate::output::print_output::( - Ok(status), - "config.unset", - output_mode, - OutputContext::new(config.chain_id, false), - )) } #[cfg(test)] @@ -282,4 +546,223 @@ mod tests { let mode = std::fs::metadata(path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600); } + + // Config-command tests use high, otherwise-unused chain ids so the process-env + // side effects of set/unset cannot collide with `config::mod` env tests. + fn tmp_path() -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.env"); + (dir, path) + } + + fn test_cfg(chain_id: u64, overridden: bool) -> AppConfig { + let mut c = AppConfig::load().unwrap(); + c.chain_id = chain_id; + c.chain_id_overridden = overridden; + c + } + + #[test] + fn resolve_key_parses_simple_and_rpc_forms() { + assert!(matches!( + resolve_key("dodo_api_key").unwrap(), + ResolvedKey::Simple(..) + )); + assert!(matches!( + resolve_key("rpc_url").unwrap(), + ResolvedKey::Rpc { chain: None } + )); + assert!(matches!( + resolve_key("rpc_url.56").unwrap(), + ResolvedKey::Rpc { chain: Some(56) } + )); + assert!(matches!( + resolve_key("RPC.42").unwrap(), + ResolvedKey::Rpc { chain: Some(42) } + )); + assert!(resolve_key("rpc_url.abc").is_err()); + assert!(resolve_key("totally_unknown").is_err()); + } + + #[test] + fn validate_rpc_url_accepts_http_rejects_others() { + assert!(validate_rpc_url("https://node.example").is_ok()); + assert!(validate_rpc_url("http://localhost:8545").is_ok()); + assert!(validate_rpc_url("ftp://node.example").is_err()); + assert!(validate_rpc_url("not a url").is_err()); + } + + #[test] + fn set_single_writes_active_chain_rpc() { + let (_d, path) = tmp_path(); + let cfg = test_cfg(700001, true); + set_rpc(None, "https://active-node", &path, OutputMode::Quiet, &cfg).unwrap(); + let entries = read_config_file(&path); + assert_eq!(lookup(&entries, "RPC_URL_700001"), Some("https://active-node")); + std::env::remove_var("RPC_URL_700001"); + } + + #[test] + fn set_suffixed_key_writes_that_chain() { + let (_d, path) = tmp_path(); + let cfg = test_cfg(1, false); + set( + ConfigSetArgs { + key: "rpc_url.700002".to_string(), + value: "https://n2".to_string(), + }, + &path, + OutputMode::Quiet, + &cfg, + ) + .unwrap(); + let entries = read_config_file(&path); + assert_eq!(lookup(&entries, "RPC_URL_700002"), Some("https://n2")); + std::env::remove_var("RPC_URL_700002"); + } + + #[test] + fn set_json_map_merges_without_dropping_existing() { + let (_d, path) = tmp_path(); + let cfg = test_cfg(1, false); + // Seed one chain, then merge a map that adds another. + set_rpc(Some(700003), "https://keep", &path, OutputMode::Quiet, &cfg).unwrap(); + set_rpc( + None, + "{\"700004\":\"https://added\"}", + &path, + OutputMode::Quiet, + &cfg, + ) + .unwrap(); + let entries = read_config_file(&path); + assert_eq!(lookup(&entries, "RPC_URL_700003"), Some("https://keep")); + assert_eq!(lookup(&entries, "RPC_URL_700004"), Some("https://added")); + std::env::remove_var("RPC_URL_700003"); + std::env::remove_var("RPC_URL_700004"); + } + + #[test] + fn set_rejects_invalid_url_and_bad_json() { + let (_d, path) = tmp_path(); + let cfg = test_cfg(700005, true); + assert!(set_rpc(None, "ftp://bad", &path, OutputMode::Quiet, &cfg).is_err()); + assert!(set_rpc(Some(1), "{\"1\":\"https://x\"}", &path, OutputMode::Quiet, &cfg).is_err()); + // Nothing was written. + assert!(read_config_file(&path).is_empty()); + } + + #[test] + fn unset_all_clears_every_rpc_entry() { + let (_d, path) = tmp_path(); + let cfg = test_cfg(700006, false); + set_rpc(Some(700006), "https://a", &path, OutputMode::Quiet, &cfg).unwrap(); + set_rpc(Some(700007), "https://b", &path, OutputMode::Quiet, &cfg).unwrap(); + // chain not overridden and no suffix -> clears all. + unset( + ConfigKeyArg { + key: "rpc_url".to_string(), + }, + &path, + OutputMode::Quiet, + &cfg, + ) + .unwrap(); + // unset edits the file; assert the file (not the env-merged view, which + // other parallel tests may pollute) has no RPC entries left. + assert!(read_config_file(&path) + .iter() + .all(|(k, _)| rpc_key_chain_id(k).is_none())); + std::env::remove_var("RPC_URL_700006"); + std::env::remove_var("RPC_URL_700007"); + } + + #[test] + fn bare_unset_clears_all_even_when_active_chain_is_set() { + let (_d, path) = tmp_path(); + // chain_id_overridden = true (as if CHAIN_ID / --chain-id 700012 is set). + let cfg = test_cfg(700012, true); + set_rpc(Some(700012), "https://a", &path, OutputMode::Quiet, &cfg).unwrap(); + set_rpc(Some(700013), "https://b", &path, OutputMode::Quiet, &cfg).unwrap(); + unset( + ConfigKeyArg { + key: "rpc_url".to_string(), + }, + &path, + OutputMode::Quiet, + &cfg, + ) + .unwrap(); + // Bare unset must clear BOTH, not just the active chain 700012. + assert!(read_config_file(&path) + .iter() + .all(|(k, _)| rpc_key_chain_id(k).is_none())); + std::env::remove_var("RPC_URL_700012"); + std::env::remove_var("RPC_URL_700013"); + } + + #[test] + fn rpc_entries_are_sorted_by_chain_id() { + let (_d, path) = tmp_path(); + write_config_file( + &path, + &[ + ("RPC_URL_700009".to_string(), "https://b".to_string()), + ("DODO_API_KEY".to_string(), "secret".to_string()), + ("RPC_URL_700008".to_string(), "https://a".to_string()), + ], + ) + .unwrap(); + // rpc_entries merges the environment, so other RPC_URL_* vars may exist; + // assert the two file ids appear in ascending relative order. + let keys: Vec = rpc_entries(&path).into_iter().map(|e| e.key).collect(); + let a = keys.iter().position(|k| k == "rpc_url.700008"); + let b = keys.iter().position(|k| k == "rpc_url.700009"); + assert!(a.is_some() && b.is_some(), "both file ids present"); + assert!(a < b, "sorted by chain id"); + } + + #[test] + fn inspection_reports_env_over_file() { + let (_d, path) = tmp_path(); + write_config_file(&path, &[("RPC_URL_700010".to_string(), "https://from-file".to_string())]) + .unwrap(); + std::env::set_var("RPC_URL_700010", "https://from-env"); + + // Single-chain get and the full map both prefer the env value. + assert_eq!( + effective_rpc_for(&path, 700010).as_deref(), + Some("https://from-env") + ); + let map = effective_rpc_overrides(&path); + assert_eq!(map.get(&700010).map(String::as_str), Some("https://from-env")); + + std::env::remove_var("RPC_URL_700010"); + } + + #[test] + fn inspection_includes_env_only_override() { + let (_d, path) = tmp_path(); + // Nothing in the file; the override exists only in the environment. + std::env::set_var("RPC_URL_700011", "https://env-only"); + + assert!(rpc_entries(&path).iter().any(|e| e.key == "rpc_url.700011" + && e.value.as_deref() == Some("https://env-only"))); + + std::env::remove_var("RPC_URL_700011"); + } + + #[test] + fn effective_simple_value_prefers_env_over_file() { + // Use a unique, non-config env var name so this cannot race with other + // tests that read real config keys via AppConfig::load(). + const KEY: &str = "CHAINPILOT_TEST_SIMPLE_KEY_700100"; + let (_d, path) = tmp_path(); + write_config_file(&path, &[(KEY.to_string(), "file-key".to_string())]).unwrap(); + std::env::set_var(KEY, "env-key"); + assert_eq!(effective_simple_value(&path, KEY).as_deref(), Some("env-key")); + std::env::remove_var(KEY); + // With no env var, falls back to the file. + assert_eq!(effective_simple_value(&path, KEY).as_deref(), Some("file-key")); + } } diff --git a/src/commands/swap.rs b/src/commands/swap.rs index 67995e6..ff09b88 100644 --- a/src/commands/swap.rs +++ b/src/commands/swap.rs @@ -2080,6 +2080,7 @@ mod tests { AppConfig { rpc_url: "https://rpc.example.com".to_string(), rpc_url_overridden: false, + rpc_overrides: std::collections::HashMap::new(), chain_id, chain_id_overridden: false, private_key: None, @@ -3043,6 +3044,7 @@ mod tests { let signer = crate::chain::resolve_signer(&AppConfig { rpc_url: String::new(), rpc_url_overridden: false, + rpc_overrides: std::collections::HashMap::new(), chain_id: 1, chain_id_overridden: false, private_key: Some(private_key.to_string()), diff --git a/src/commands/token.rs b/src/commands/token.rs index 148ccc2..a4bb150 100644 --- a/src/commands/token.rs +++ b/src/commands/token.rs @@ -1518,6 +1518,7 @@ mod tests { AppConfig { rpc_url: "https://ethereum-rpc.publicnode.com".to_string(), rpc_url_overridden: false, + rpc_overrides: std::collections::HashMap::new(), chain_id: 1, chain_id_overridden: false, private_key: None, diff --git a/src/config/chains/polygon.rs b/src/config/chains/polygon.rs index 9cfd837..3cd2074 100644 --- a/src/config/chains/polygon.rs +++ b/src/config/chains/polygon.rs @@ -4,9 +4,8 @@ pub static CONFIG: ChainConfig = ChainConfig { chain_id: 137, name: "Polygon", rpc_urls: &[ - "https://polygon-rpc.com", - "https://rpc-mainnet.maticvigil.com", - "https://rpc.ankr.com/polygon", + "https://polygon-bor-rpc.publicnode.com", + "https://1rpc.io/matic", ], contracts: ChainContracts { dodo_approve: "0x6D310348d5c12009854DFCf72e0DF9027e8cb4f4", diff --git a/src/config/mod.rs b/src/config/mod.rs index a2f3a65..3e21370 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::PathBuf; use crate::error::Result; @@ -43,6 +44,10 @@ pub const DEFAULT_QUOTE_TTL_SECS: u64 = 1080; pub struct AppConfig { pub rpc_url: String, pub rpc_url_overridden: bool, + /// Per-chain RPC overrides configured via `RPC_URL_` env vars + /// (persisted in `config.env`). Lower precedence than the `--rpc-url` flag, + /// higher than the chain's built-in default RPC. + pub rpc_overrides: HashMap, pub chain_id: u64, pub chain_id_overridden: bool, pub private_key: Option, @@ -69,6 +74,32 @@ pub struct AppConfig { pub data_dir: PathBuf, } +/// Prefix for per-chain RPC override env vars, e.g. `RPC_URL_56`. +pub const RPC_URL_ENV_PREFIX: &str = "RPC_URL_"; + +/// The chain's built-in default RPC, or the global fallback for unknown chains. +fn chain_default_rpc(chain_id: u64) -> &'static str { + chains::chain_config(chain_id) + .and_then(|c| c.rpc_urls.first().copied()) + .unwrap_or(FALLBACK_RPC_URL) +} + +/// Scan the environment for `RPC_URL_` entries and build a chain_id → url map. +/// Keys whose suffix is not a valid `u64` (e.g. a bare `RPC_URL`) are skipped. +fn collect_rpc_overrides() -> HashMap { + std::env::vars() + .filter_map(|(key, value)| { + let suffix = key.strip_prefix(RPC_URL_ENV_PREFIX)?; + let chain_id: u64 = suffix.parse().ok()?; + let value = value.trim(); + if value.is_empty() { + return None; + } + Some((chain_id, value.to_string())) + }) + .collect() +} + impl AppConfig { pub fn load() -> Result { let chain_id_raw = std::env::var("CHAIN_ID").ok(); @@ -82,13 +113,17 @@ impl AppConfig { .as_deref() .is_some_and(|s| s.parse::().is_ok()); + // Collect per-chain RPC overrides from `RPC_URL_` env vars. + // These come from `config.env` (loaded into the environment at startup) or + // a real env var. `RPC_URL` without a numeric suffix is intentionally ignored: + // there is no single "all chains" RPC — that role belongs to the `--rpc-url` flag. + let rpc_overrides = collect_rpc_overrides(); + let rpc_url_overridden = false; - let rpc_url = { - chains::chain_config(chain_id) - .and_then(|c| c.rpc_urls.first().copied()) - .unwrap_or(FALLBACK_RPC_URL) - .to_string() - }; + let rpc_url = rpc_overrides + .get(&chain_id) + .cloned() + .unwrap_or_else(|| chain_default_rpc(chain_id).to_string()); let private_key = std::env::var("PRIVATE_KEY").ok(); let keystore_path = std::env::var("KEYSTORE_PATH").ok(); @@ -135,6 +170,7 @@ impl AppConfig { rpc_url_overridden, chain_id, chain_id_overridden, + rpc_overrides, private_key, keystore_path, keystore_password_file, @@ -170,12 +206,21 @@ impl AppConfig { } /// Resolve the RPC URL for a target chain. - /// Precedence: explicit CLI/env override > chain default RPC > configured fallback RPC. + /// Precedence: `--rpc-url` flag > configured `RPC_URL_` > chain default RPC + /// > current `rpc_url` fallback. pub fn rpc_url_for_chain(&self, chain_id: u64) -> String { - if self.rpc_url_overridden || chain_id == self.chain_id { + // The `--rpc-url` flag wins for every chain. + if self.rpc_url_overridden { return self.rpc_url.clone(); } - + // The active chain's `rpc_url` was already resolved (override-or-default) in `load`. + if chain_id == self.chain_id { + return self.rpc_url.clone(); + } + // Per-chain configured override, then the chain's built-in default. + if let Some(url) = self.rpc_overrides.get(&chain_id) { + return url.clone(); + } chains::chain_config(chain_id) .and_then(|c| c.rpc_urls.first().copied()) .unwrap_or(self.rpc_url.as_str()) @@ -412,6 +457,96 @@ mod tests { }); } + #[test] + fn configured_rpc_override_sets_active_chain_rpc() { + with_env( + &[ + ("CHAIN_ID", Some("1")), + ("RPC_URL_1", Some("https://my-eth-node.example")), + ], + || { + let cfg = AppConfig::load().unwrap(); + assert_eq!(cfg.rpc_url, "https://my-eth-node.example"); + assert!(!cfg.rpc_url_overridden); + assert_eq!( + cfg.rpc_overrides.get(&1).map(String::as_str), + Some("https://my-eth-node.example") + ); + }, + ); + } + + #[test] + fn configured_rpc_override_used_for_non_active_chain() { + with_env( + &[ + ("CHAIN_ID", Some("1")), + ("RPC_URL_56", Some("https://my-bsc-node.example")), + ("RPC_URL_1", None), + ], + || { + let cfg = AppConfig::load().unwrap(); + // Active chain keeps its built-in default... + assert!(cfg.rpc_url.starts_with("https://")); + assert_ne!(cfg.rpc_url, "https://my-bsc-node.example"); + // ...while chain 56 resolves to the configured override. + assert_eq!(cfg.rpc_url_for_chain(56), "https://my-bsc-node.example"); + }, + ); + } + + #[test] + fn explicit_rpc_flag_beats_configured_override() { + with_env( + &[ + ("CHAIN_ID", Some("1")), + ("RPC_URL_56", Some("https://my-bsc-node.example")), + ], + || { + let mut cfg = AppConfig::load().unwrap(); + cfg.rpc_url = "https://flag-override.example".to_string(); + cfg.rpc_url_overridden = true; + // The flag wins for every chain, including one with a configured override. + assert_eq!(cfg.rpc_url_for_chain(56), "https://flag-override.example"); + }, + ); + } + + #[test] + fn configured_rpc_override_resolves_unknown_chain() { + with_env( + &[ + ("CHAIN_ID", Some("1")), + ("RPC_URL_999999", Some("https://custom-unknown.example")), + ], + || { + let cfg = AppConfig::load().unwrap(); + assert_eq!( + cfg.rpc_url_for_chain(999999), + "https://custom-unknown.example" + ); + }, + ); + } + + #[test] + fn bare_rpc_url_env_is_ignored_as_override() { + with_env( + &[ + ("CHAIN_ID", Some("1")), + ("RPC_URL_1", None), + ("RPC_URL", Some("https://should-be-ignored.example")), + ], + || { + let cfg = AppConfig::load().unwrap(); + // No numeric suffix means no per-chain override; falls back to chain default. + assert!(cfg.rpc_overrides.get(&1).is_none()); + assert!(cfg.rpc_url.starts_with("https://")); + assert_ne!(cfg.rpc_url, "https://should-be-ignored.example"); + }, + ); + } + #[test] fn timeout_and_ttl_use_defaults() { with_env(&[], || { diff --git a/src/store/quote.rs b/src/store/quote.rs index c8b0a1c..cf7e76d 100644 --- a/src/store/quote.rs +++ b/src/store/quote.rs @@ -194,6 +194,7 @@ mod tests { let config = AppConfig { rpc_url: "https://test.example.com".to_string(), rpc_url_overridden: false, + rpc_overrides: std::collections::HashMap::new(), chain_id: 1, chain_id_overridden: false, private_key: None,