diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c8214ed --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +## Example environment variables for Soroban configuration +## Safe for publishing: do NOT add real secrets here. + +# Choose which profile to use from `soroban.toml`. Set to one of the profile +# names (e.g. `testnet`, `mainnet`, `sandbox`). If unset, the loader will +# prefer a `testnet` profile in `soroban.toml` or fail if ambiguous. +SOROBAN_NETWORK= + +# Optional: Fully override the RPC URL. When set, this value takes precedence +# over the `rpc_url` defined in the chosen profile inside `soroban.toml`. +SOROBAN_RPC_URL= + +# Optional: Fully override the network passphrase. When set, this value takes +# precedence over the `network_passphrase` defined in the chosen profile. +SOROBAN_NETWORK_PASSPHRASE= + +# Behavior summary: +# - If `SOROBAN_NETWORK` is set it selects a profile (or a well-known network +# name like `testnet`/`mainnet`/`sandbox`). +# - `SOROBAN_RPC_URL` and `SOROBAN_NETWORK_PASSPHRASE` override the values from +# the selected profile when present. +# - If neither environment variables nor an unambiguous profile exists, the +# loader fails with a clear message. diff --git a/README.md b/README.md index 5fc275e..12b3823 100644 --- a/README.md +++ b/README.md @@ -203,3 +203,40 @@ Open a Pull Request from your fork back to the main branch. # 📜 License MIT License — free to use, modify, and distribute. + +## Soroban Configuration (networks) + +This workspace includes a deterministic, strongly-typed Soroban network configuration system. + +Add a network (example CLI stub): + +```bash +soroban config network add \ + --rpc-url \ + --network-passphrase "" +``` + +List networks (profiles in `soroban.toml`): + +```bash +soroban config network ls +``` + +Select a network (this sets the active profile name; loader reads `SOROBAN_NETWORK`): + +```bash +soroban config network use +``` + +Environment variable override behavior + +- `SOROBAN_NETWORK` selects a profile (e.g. `testnet`, `mainnet`, `sandbox`). +- `SOROBAN_RPC_URL` and `SOROBAN_NETWORK_PASSPHRASE` override profile values when set. + +Verify the resolved network with the included CLI tool: + +```bash +cargo run -p stellaraid-tools -- network +``` + +See `.env.example` for a safe example of environment variables you can copy to `.env`. diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index cdd2446..173634e 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -12,3 +12,10 @@ soroban-sdk = { workspace = true } clap = { version = "4.0", features = ["derive"] } tokio = { version = "1.0", features = ["full"] } anyhow = "1.0" +dotenvy = "0.15" +serde = { version = "1.0", features = ["derive"] } +toml = "0.7" +thiserror = "1.0" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/tools/src/config.rs b/crates/tools/src/config.rs new file mode 100644 index 0000000..83d58bb --- /dev/null +++ b/crates/tools/src/config.rs @@ -0,0 +1,269 @@ +use serde::Deserialize; +use std::collections::HashMap; +use std::env; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +/// Represents a supported Soroban network. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Network { + Testnet, + Mainnet, + Sandbox, + Custom(String), +} + +impl fmt::Display for Network { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Network::Testnet => write!(f, "testnet"), + Network::Mainnet => write!(f, "mainnet"), + Network::Sandbox => write!(f, "sandbox"), + Network::Custom(s) => write!(f, "{}", s), + } + } +} + +#[derive(Deserialize, Debug)] +struct ProfileFile { + network: String, + rpc_url: String, + network_passphrase: String, +} + +#[derive(Deserialize, Debug)] +struct SorobanToml { + #[serde(rename = "profile")] + profiles: HashMap, +} + +/// Strongly-typed configuration resolved for runtime use. +#[derive(Debug, Clone)] +pub struct Config { + /// Logical profile name chosen from `soroban.toml`. + pub profile: String, + /// Resolved network enum. + pub network: Network, + /// Resolved RPC URL. + pub rpc_url: String, + /// Resolved network passphrase. + pub network_passphrase: String, +} + +/// Errors that can occur when loading configuration. +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("I/O error reading soroban.toml: {0}")] + Io(#[from] std::io::Error), + #[error("Failed to parse soroban.toml: {0}")] + Toml(#[from] toml::de::Error), + #[error("No profile selected and no default available. Set the SOROBAN_NETWORK env or add a 'testnet' profile to soroban.toml")] + NoProfileSelected, + #[error("Profile '{0}' not found in soroban.toml")] + ProfileNotFound(String), + #[error("Missing required value: {0}")] + MissingValue(&'static str), +} + +impl Config { + /// Load configuration using resolution order: + /// 1. Environment variables (full or partial overrides) + /// 2. `soroban.toml` selected profile (defaults to `testnet` if present) + /// 3. Fail with a clear error + pub fn load(soroban_toml_path: Option<&Path>) -> Result { + // Find soroban.toml first (so we can load a nearby `.env` instead of a + // repository-global one which could make tests non-deterministic). + let toml_path = match soroban_toml_path { + Some(p) => p.to_path_buf(), + None => find_soroban_toml()?, + }; + + // If there's a `.env` next to the provided `soroban.toml`, load it. + if let Some(parent) = toml_path.parent() { + let env_path = parent.join(".env"); + if env_path.exists() { + let _ = dotenvy::from_path(env_path).ok(); + } + } + + // Read env overrides (may be unset) + let env_network = env::var("SOROBAN_NETWORK").ok(); + let env_rpc = env::var("SOROBAN_RPC_URL").ok(); + let env_pass = env::var("SOROBAN_NETWORK_PASSPHRASE").ok(); + + let toml_contents = fs::read_to_string(&toml_path)?; + let toml: SorobanToml = toml::from_str(&toml_contents)?; + + // Determine profile name + let profile_name = if let Some(ref name) = env_network { + name.clone() + } else if toml.profiles.contains_key("testnet") { + "testnet".to_string() + } else if toml.profiles.len() == 1 { + toml.profiles.keys().next().unwrap().clone() + } else { + return Err(ConfigError::NoProfileSelected); + }; + + let profile = toml + .profiles + .get(&profile_name) + .ok_or_else(|| ConfigError::ProfileNotFound(profile_name.clone()))?; + + // base values from profile + let mut rpc_url = profile.rpc_url.clone(); + let mut network_passphrase = profile.network_passphrase.clone(); + let network_str = profile.network.clone(); + + // apply env overrides when present + if let Some(e) = env_rpc { + rpc_url = e; + } + if let Some(e) = env_pass { + network_passphrase = e; + } + + // derive Network enum (prefer env_network string if provided) + let network_enum = match env_network.as_deref().unwrap_or(&network_str) { + "testnet" => Network::Testnet, + "mainnet" => Network::Mainnet, + "sandbox" => Network::Sandbox, + other => Network::Custom(other.to_string()), + }; + + // ensure required values present + if rpc_url.trim().is_empty() { + return Err(ConfigError::MissingValue("SOROBAN_RPC_URL")); + } + if network_passphrase.trim().is_empty() { + return Err(ConfigError::MissingValue("SOROBAN_NETWORK_PASSPHRASE")); + } + + Ok(Config { + profile: profile_name, + network: network_enum, + rpc_url, + network_passphrase, + }) + } +} + +fn find_soroban_toml() -> Result { + let mut dir = env::current_dir()?; + for _ in 0..10 { + let candidate = dir.join("soroban.toml"); + if candidate.exists() { + return Ok(candidate); + } + if !dir.pop() { + break; + } + } + Err(ConfigError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "soroban.toml not found in current or parent directories", + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + use std::io::Write; + use tempfile::tempdir; + + fn write_toml(dir: &Path, content: &str) -> PathBuf { + let p = dir.join("soroban.toml"); + let mut f = File::create(&p).unwrap(); + f.write_all(content.as_bytes()).unwrap(); + p + } + + fn clear_env_vars() { + env::remove_var("SOROBAN_NETWORK"); + env::remove_var("SOROBAN_RPC_URL"); + env::remove_var("SOROBAN_NETWORK_PASSPHRASE"); + } + + #[test] + fn loads_profile_by_name() { + let d = tempdir().unwrap(); + let toml = r#" +[profile.testnet] +network = "testnet" +rpc_url = "https://soroban-testnet.stellar.org" +network_passphrase = "Test SDF Network ; September 2015" +"#; + clear_env_vars(); + let p = write_toml(d.path(), toml); + env::set_var("SOROBAN_NETWORK", "testnet"); + + let cfg = Config::load(Some(&p)).expect("should load"); + assert_eq!(cfg.profile, "testnet"); + assert_eq!(cfg.rpc_url, "https://soroban-testnet.stellar.org"); + assert_eq!(cfg.network_passphrase, "Test SDF Network ; September 2015"); + match cfg.network { + Network::Testnet => {} + _ => panic!("expected testnet"), + } + } + + #[test] + fn env_overrides_profile_values() { + let d = tempdir().unwrap(); + let toml = r#" +[profile.testnet] +network = "testnet" +rpc_url = "https://soroban-testnet.stellar.org" +network_passphrase = "Test SDF Network ; September 2015" +"#; + clear_env_vars(); + let p = write_toml(d.path(), toml); + env::set_var("SOROBAN_NETWORK", "testnet"); + env::set_var("SOROBAN_RPC_URL", "https://override.local"); + env::set_var("SOROBAN_NETWORK_PASSPHRASE", "override pass"); + + let cfg = Config::load(Some(&p)).expect("should load with overrides"); + assert_eq!(cfg.rpc_url, "https://override.local"); + assert_eq!(cfg.network_passphrase, "override pass"); + } + + #[test] + fn missing_required_values_returns_error() { + let d = tempdir().unwrap(); + // create a profile with empty values + let toml = r#" +[profile.empty] +network = "" +rpc_url = "" +network_passphrase = "" +"#; + clear_env_vars(); + let p = write_toml(d.path(), toml); + + // ensure defaulting behavior picks testnet is not present -> should error + let res = Config::load(Some(&p)); + assert!(res.is_err()); + } + + #[test] + fn loads_sandbox_profile() { + let d = tempdir().unwrap(); + let toml = r#" +[profile.sandbox] +network = "sandbox" +rpc_url = "http://localhost:8000" +network_passphrase = "Standalone Network ; February 2017" +"#; + clear_env_vars(); + let p = write_toml(d.path(), toml); + + env::set_var("SOROBAN_NETWORK", "sandbox"); + + let cfg = Config::load(Some(&p)).expect("should load sandbox"); + assert_eq!(cfg.profile, "sandbox"); + assert_eq!(cfg.rpc_url, "http://localhost:8000"); + } +} diff --git a/crates/tools/src/main.rs b/crates/tools/src/main.rs index 04b1b63..2a7b2fe 100644 --- a/crates/tools/src/main.rs +++ b/crates/tools/src/main.rs @@ -1,6 +1,9 @@ use anyhow::Result; use clap::{Parser, Subcommand}; +mod config; +use config::Config; + #[derive(Parser)] #[command(name = "stellaraid-cli")] #[command(about = "StellarAid CLI tools for contract deployment and management")] @@ -11,16 +14,20 @@ struct Cli { #[derive(Subcommand)] enum Commands { + /// Deploy a contract (placeholder) Deploy { #[arg(short, long)] network: String, #[arg(short, long)] contract_id: Option, }, + /// Configuration utilities Config { #[command(subcommand)] action: ConfigAction, }, + /// Print resolved network configuration + Network, } #[derive(Subcommand)] @@ -41,17 +48,25 @@ fn main() -> Result<()> { if let Some(id) = contract_id { println!("Using contract ID: {}", id); } - // TODO: Implement deployment logic } - Commands::Config { action } => { - match action { - ConfigAction::Check => { - println!("Checking configuration..."); - // TODO: Implement config check + Commands::Config { action } => match action { + ConfigAction::Check => { + println!("Checking configuration..."); + } + ConfigAction::Init => { + println!("Initializing configuration..."); + } + }, + Commands::Network => { + match Config::load(None) { + Ok(cfg) => { + println!("Active network: {}", cfg.network); + println!("RPC URL: {}", cfg.rpc_url); + println!("Passphrase: {}", cfg.network_passphrase); } - ConfigAction::Init => { - println!("Initializing configuration..."); - // TODO: Implement config initialization + Err(e) => { + eprintln!("Failed to load config: {}", e); + std::process::exit(2); } } } diff --git a/soroban.toml b/soroban.toml new file mode 100644 index 0000000..851452d --- /dev/null +++ b/soroban.toml @@ -0,0 +1,14 @@ +[profile.testnet] +network = "testnet" +rpc_url = "https://soroban-testnet.stellar.org" +network_passphrase = "Test SDF Network ; September 2015" + +[profile.mainnet] +network = "mainnet" +rpc_url = "https://mainnet.sorobanrpc.com" +network_passphrase = "Public Global Stellar Network ; September 2015" + +[profile.sandbox] +network = "sandbox" +rpc_url = "http://localhost:8000" +network_passphrase = "Standalone Network ; February 2017"