Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> \
--rpc-url <url> \
--network-passphrase "<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 <name>
```

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`.
7 changes: 7 additions & 0 deletions crates/tools/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
269 changes: 269 additions & 0 deletions crates/tools/src/config.rs
Original file line number Diff line number Diff line change
@@ -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<String, ProfileFile>,
}

/// 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<Self, ConfigError> {
// 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<PathBuf, ConfigError> {
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");
}
}
Loading
Loading