use aleph_sdk::credit::{EthereumConfig, PriceSource};
use alloy_primitives::Address;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub const BUILTIN_CCN_NAME: &str = "official";
pub const BUILTIN_CCN_URL: &str = "https://api.aleph.im";
pub const BUILTIN_NETWORK_NAME: &str = "mainnet";
pub const BUILTIN_SCHEDULER_URL: &str = "https://scheduler.api.aleph.cloud";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CcnEntry {
pub name: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkEntry {
pub name: String,
pub default_ccn: Option<String>,
#[serde(default)]
pub ccns: Vec<CcnEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ethereum: Option<EthereumConfig>,
pub scheduler_url: String,
}
#[derive(Debug, Clone, Default)]
pub struct EthereumPatch {
pub rpc_url: Option<String>,
pub credit_contract: Option<Address>,
pub aleph_token: Option<Address>,
pub usdc_token: Option<Address>,
pub price_source: Option<PriceSource>,
pub explorer_tx_base: Option<String>,
}
impl EthereumPatch {
pub fn is_empty(&self) -> bool {
self.rpc_url.is_none()
&& self.credit_contract.is_none()
&& self.aleph_token.is_none()
&& self.usdc_token.is_none()
&& self.price_source.is_none()
&& self.explorer_tx_base.is_none()
}
pub fn apply(&self, config: &mut EthereumConfig) {
if let Some(v) = &self.rpc_url {
config.rpc_url = v.clone();
}
if let Some(v) = self.credit_contract {
config.credit_contract = v;
}
if let Some(v) = self.aleph_token {
config.aleph_token = v;
}
if let Some(v) = self.usdc_token {
config.usdc_token = v;
}
if let Some(v) = &self.price_source {
config.price_source = v.clone();
}
if let Some(v) = &self.explorer_tx_base {
config.explorer_tx_base = Some(v.clone());
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConfigManifest {
pub default_network: Option<String>,
#[serde(default)]
pub networks: Vec<NetworkEntry>,
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("network '{0}' already exists")]
NetworkAlreadyExists(String),
#[error("network '{0}' not found")]
NetworkNotFound(String),
#[error(
"cannot remove network '{0}': it is the default network; use 'aleph config network use <name>' to switch first"
)]
CannotRemoveDefaultNetwork(String),
#[error("ccn '{ccn}' already exists in network '{network}'")]
CcnAlreadyExists { network: String, ccn: String },
#[error("ccn '{ccn}' not found in network '{network}'")]
CcnNotFound { network: String, ccn: String },
#[error(
"invalid name '{0}': names must be non-empty and contain only alphanumeric characters, hyphens, and underscores"
)]
InvalidName(String),
#[error("invalid URL '{0}': {1}")]
InvalidUrl(String, String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("failed to parse config: {0}")]
Parse(String),
}
pub struct ConfigStore {
manifest_path: PathBuf,
}
impl ConfigStore {
#[cfg(test)]
pub fn with_manifest_path(manifest_path: PathBuf) -> Self {
Self { manifest_path }
}
pub fn open() -> Result<Self, ConfigError> {
let proj = directories::ProjectDirs::from("", "", "aleph").ok_or_else(|| {
ConfigError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not determine home directory",
))
})?;
let config_dir = proj.config_dir();
std::fs::create_dir_all(config_dir)?;
let store = Self {
manifest_path: config_dir.join("config.toml"),
};
store.ensure_builtin()?;
Ok(store)
}
pub fn load_manifest(&self) -> Result<ConfigManifest, ConfigError> {
match std::fs::read_to_string(&self.manifest_path) {
Ok(contents) => {
toml::from_str(&contents).map_err(|e| ConfigError::Parse(e.to_string()))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ConfigManifest::default()),
Err(e) => Err(ConfigError::Io(e)),
}
}
fn save_manifest(&self, manifest: &ConfigManifest) -> Result<(), ConfigError> {
let content =
toml::to_string_pretty(manifest).map_err(|e| ConfigError::Parse(e.to_string()))?;
if let Some(parent) = self.manifest_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&self.manifest_path, content)?;
Ok(())
}
fn validate_name(name: &str) -> Result<(), ConfigError> {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(ConfigError::InvalidName(name.to_string()));
}
Ok(())
}
fn validate_url(raw: &str) -> Result<(), ConfigError> {
let parsed = url::Url::parse(raw)
.map_err(|e| ConfigError::InvalidUrl(raw.to_string(), e.to_string()))?;
match parsed.scheme() {
"http" | "https" => Ok(()),
other => Err(ConfigError::InvalidUrl(
raw.to_string(),
format!("scheme must be http or https, got '{other}'"),
)),
}
}
pub fn add_network(&self, name: &str) -> Result<(), ConfigError> {
Self::validate_name(name)?;
let mut manifest = self.load_manifest()?;
if manifest.networks.iter().any(|n| n.name == name) {
return Err(ConfigError::NetworkAlreadyExists(name.to_string()));
}
manifest.networks.push(NetworkEntry {
name: name.to_string(),
default_ccn: None,
ccns: Vec::new(),
ethereum: None,
scheduler_url: BUILTIN_SCHEDULER_URL.to_string(),
});
if manifest.default_network.is_none() {
manifest.default_network = Some(name.to_string());
}
self.save_manifest(&manifest)
}
pub fn update_network_ethereum(
&self,
network: &str,
patch: &EthereumPatch,
) -> Result<(), ConfigError> {
let mut manifest = self.load_manifest()?;
let net = manifest
.networks
.iter_mut()
.find(|n| n.name == network)
.ok_or_else(|| ConfigError::NetworkNotFound(network.to_string()))?;
let current = net
.ethereum
.get_or_insert_with(EthereumConfig::mainnet_defaults);
patch.apply(current);
self.save_manifest(&manifest)
}
pub fn set_network_scheduler_url(&self, network: &str, url: &str) -> Result<(), ConfigError> {
Self::validate_url(url)?;
let mut manifest = self.load_manifest()?;
let net = manifest
.networks
.iter_mut()
.find(|n| n.name == network)
.ok_or_else(|| ConfigError::NetworkNotFound(network.to_string()))?;
net.scheduler_url = url.to_string();
self.save_manifest(&manifest)
}
pub fn get_network(&self, name: &str) -> Result<NetworkEntry, ConfigError> {
self.load_manifest()?
.networks
.into_iter()
.find(|n| n.name == name)
.ok_or_else(|| ConfigError::NetworkNotFound(name.to_string()))
}
pub fn remove_network(&self, name: &str) -> Result<(), ConfigError> {
let mut manifest = self.load_manifest()?;
if !manifest.networks.iter().any(|n| n.name == name) {
return Err(ConfigError::NetworkNotFound(name.to_string()));
}
if manifest.default_network.as_deref() == Some(name) {
return Err(ConfigError::CannotRemoveDefaultNetwork(name.to_string()));
}
manifest.networks.retain(|n| n.name != name);
self.save_manifest(&manifest)
}
pub fn set_default_network(&self, name: &str) -> Result<(), ConfigError> {
let mut manifest = self.load_manifest()?;
if !manifest.networks.iter().any(|n| n.name == name) {
return Err(ConfigError::NetworkNotFound(name.to_string()));
}
manifest.default_network = Some(name.to_string());
self.save_manifest(&manifest)
}
pub fn default_network_name(&self) -> Result<Option<String>, ConfigError> {
Ok(self.load_manifest()?.default_network)
}
pub fn list_networks(&self) -> Result<Vec<NetworkEntry>, ConfigError> {
Ok(self.load_manifest()?.networks)
}
pub fn add_ccn(&self, network: &str, name: &str, url: &str) -> Result<(), ConfigError> {
Self::validate_name(name)?;
Self::validate_url(url)?;
let mut manifest = self.load_manifest()?;
let net = manifest
.networks
.iter_mut()
.find(|n| n.name == network)
.ok_or_else(|| ConfigError::NetworkNotFound(network.to_string()))?;
if net.ccns.iter().any(|c| c.name == name) {
return Err(ConfigError::CcnAlreadyExists {
network: network.to_string(),
ccn: name.to_string(),
});
}
net.ccns.push(CcnEntry {
name: name.to_string(),
url: url.to_string(),
});
if net.default_ccn.is_none() {
net.default_ccn = Some(name.to_string());
}
self.save_manifest(&manifest)
}
pub fn get_ccn(&self, network: &str, name: &str) -> Result<CcnEntry, ConfigError> {
let net = self.get_network(network)?;
net.ccns
.into_iter()
.find(|c| c.name == name)
.ok_or_else(|| ConfigError::CcnNotFound {
network: network.to_string(),
ccn: name.to_string(),
})
}
pub fn remove_ccn(&self, network: &str, name: &str) -> Result<(), ConfigError> {
let mut manifest = self.load_manifest()?;
let net = manifest
.networks
.iter_mut()
.find(|n| n.name == network)
.ok_or_else(|| ConfigError::NetworkNotFound(network.to_string()))?;
let len_before = net.ccns.len();
net.ccns.retain(|c| c.name != name);
if net.ccns.len() == len_before {
return Err(ConfigError::CcnNotFound {
network: network.to_string(),
ccn: name.to_string(),
});
}
if net.default_ccn.as_deref() == Some(name) {
net.default_ccn = None;
}
self.save_manifest(&manifest)
}
pub fn set_default_ccn(&self, network: &str, name: &str) -> Result<(), ConfigError> {
let mut manifest = self.load_manifest()?;
let net = manifest
.networks
.iter_mut()
.find(|n| n.name == network)
.ok_or_else(|| ConfigError::NetworkNotFound(network.to_string()))?;
if !net.ccns.iter().any(|c| c.name == name) {
return Err(ConfigError::CcnNotFound {
network: network.to_string(),
ccn: name.to_string(),
});
}
net.default_ccn = Some(name.to_string());
self.save_manifest(&manifest)
}
pub fn list_all_ccns(&self) -> Result<Vec<(String, CcnEntry)>, ConfigError> {
let manifest = self.load_manifest()?;
let mut out = Vec::new();
for net in manifest.networks {
for ccn in net.ccns {
out.push((net.name.clone(), ccn));
}
}
Ok(out)
}
fn ensure_builtin(&self) -> Result<(), ConfigError> {
let mut manifest = self.load_manifest()?;
if !manifest.networks.is_empty() {
return Ok(());
}
manifest.networks.push(NetworkEntry {
name: BUILTIN_NETWORK_NAME.to_string(),
default_ccn: Some(BUILTIN_CCN_NAME.to_string()),
ccns: vec![CcnEntry {
name: BUILTIN_CCN_NAME.to_string(),
url: BUILTIN_CCN_URL.to_string(),
}],
ethereum: Some(EthereumConfig::mainnet_defaults()),
scheduler_url: BUILTIN_SCHEDULER_URL.to_string(),
});
if manifest.default_network.is_none() {
manifest.default_network = Some(BUILTIN_NETWORK_NAME.to_string());
}
self.save_manifest(&manifest)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_store() -> (tempfile::TempDir, ConfigStore) {
let dir = tempfile::tempdir().unwrap();
let manifest_path = dir.path().join("config.toml");
let store = ConfigStore::with_manifest_path(manifest_path);
(dir, store)
}
#[test]
fn roundtrip_manifest_serde_without_ethereum() {
let manifest = ConfigManifest {
default_network: Some("mainnet".to_string()),
networks: vec![NetworkEntry {
name: "mainnet".to_string(),
default_ccn: Some("official".to_string()),
ccns: vec![CcnEntry {
name: "official".to_string(),
url: "https://api.aleph.im".to_string(),
}],
ethereum: None,
scheduler_url: BUILTIN_SCHEDULER_URL.to_string(),
}],
};
let serialized = toml::to_string_pretty(&manifest).unwrap();
assert!(
!serialized.contains("ethereum"),
"None should skip serialization"
);
let deserialized: ConfigManifest = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.default_network.as_deref(), Some("mainnet"));
assert_eq!(deserialized.networks.len(), 1);
assert_eq!(deserialized.networks[0].name, "mainnet");
assert_eq!(deserialized.networks[0].ccns[0].url, "https://api.aleph.im");
assert!(deserialized.networks[0].ethereum.is_none());
assert_eq!(
deserialized.networks[0].scheduler_url,
BUILTIN_SCHEDULER_URL
);
}
#[test]
fn roundtrip_manifest_serde_with_ethereum() {
let manifest = ConfigManifest {
default_network: Some("mainnet".to_string()),
networks: vec![NetworkEntry {
name: "mainnet".to_string(),
default_ccn: Some("official".to_string()),
ccns: vec![CcnEntry {
name: "official".to_string(),
url: "https://api.aleph.im".to_string(),
}],
ethereum: Some(EthereumConfig::mainnet_defaults()),
scheduler_url: BUILTIN_SCHEDULER_URL.to_string(),
}],
};
let serialized = toml::to_string_pretty(&manifest).unwrap();
assert!(serialized.contains("[networks.ethereum]"));
let back: ConfigManifest = toml::from_str(&serialized).unwrap();
assert_eq!(
back.networks[0].ethereum.as_ref().unwrap(),
&EthereumConfig::mainnet_defaults()
);
}
#[test]
fn load_empty_manifest_returns_default() {
let (_dir, store) = temp_store();
let manifest = store.load_manifest().unwrap();
assert!(manifest.default_network.is_none());
assert!(manifest.networks.is_empty());
}
#[test]
fn validate_name_rejects_empty() {
assert!(ConfigStore::validate_name("").is_err());
}
#[test]
fn validate_name_rejects_spaces() {
assert!(ConfigStore::validate_name("my node").is_err());
}
#[test]
fn validate_name_rejects_special_chars() {
assert!(ConfigStore::validate_name("my@node").is_err());
}
#[test]
fn validate_name_accepts_valid() {
assert!(ConfigStore::validate_name("my-node_01").is_ok());
}
#[test]
fn validate_url_rejects_ftp() {
assert!(ConfigStore::validate_url("ftp://example.com").is_err());
}
#[test]
fn validate_url_rejects_garbage() {
assert!(ConfigStore::validate_url("not a url").is_err());
}
#[test]
fn validate_url_accepts_https() {
assert!(ConfigStore::validate_url("https://api3.aleph.im").is_ok());
}
#[test]
fn validate_url_accepts_http() {
assert!(ConfigStore::validate_url("http://localhost:4024").is_ok());
}
#[test]
fn add_network_basic() {
let (_dir, store) = temp_store();
store.add_network("testnet").unwrap();
let nets = store.list_networks().unwrap();
assert_eq!(nets.len(), 1);
assert_eq!(nets[0].name, "testnet");
assert!(nets[0].ccns.is_empty());
assert!(nets[0].default_ccn.is_none());
}
#[test]
fn first_network_becomes_default() {
let (_dir, store) = temp_store();
store.add_network("testnet").unwrap();
assert_eq!(
store.default_network_name().unwrap().as_deref(),
Some("testnet")
);
}
#[test]
fn second_network_does_not_override_default() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
store.add_network("testnet").unwrap();
assert_eq!(
store.default_network_name().unwrap().as_deref(),
Some("mainnet")
);
}
#[test]
fn add_duplicate_network_errors() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
let err = store.add_network("mainnet").unwrap_err();
assert!(matches!(err, ConfigError::NetworkAlreadyExists(_)));
}
#[test]
fn add_network_invalid_name_errors() {
let (_dir, store) = temp_store();
let err = store.add_network("bad name!").unwrap_err();
assert!(matches!(err, ConfigError::InvalidName(_)));
}
#[test]
fn get_nonexistent_network_errors() {
let (_dir, store) = temp_store();
let err = store.get_network("nope").unwrap_err();
assert!(matches!(err, ConfigError::NetworkNotFound(_)));
}
#[test]
fn set_default_network() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
store.add_network("testnet").unwrap();
store.set_default_network("testnet").unwrap();
assert_eq!(
store.default_network_name().unwrap().as_deref(),
Some("testnet")
);
}
#[test]
fn set_default_network_unknown_errors() {
let (_dir, store) = temp_store();
let err = store.set_default_network("nope").unwrap_err();
assert!(matches!(err, ConfigError::NetworkNotFound(_)));
}
#[test]
fn remove_default_network_refused() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
let err = store.remove_network("mainnet").unwrap_err();
assert!(matches!(err, ConfigError::CannotRemoveDefaultNetwork(_)));
}
#[test]
fn remove_non_default_network() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
store.add_network("testnet").unwrap();
store.remove_network("testnet").unwrap();
let nets = store.list_networks().unwrap();
assert_eq!(nets.len(), 1);
assert_eq!(nets[0].name, "mainnet");
}
#[test]
fn remove_unknown_network_errors() {
let (_dir, store) = temp_store();
let err = store.remove_network("nope").unwrap_err();
assert!(matches!(err, ConfigError::NetworkNotFound(_)));
}
#[test]
fn add_ccn_to_network() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
store
.add_ccn("mainnet", "official", "https://api.aleph.im")
.unwrap();
let entry = store.get_ccn("mainnet", "official").unwrap();
assert_eq!(entry.url, "https://api.aleph.im");
}
#[test]
fn first_ccn_becomes_network_default() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
store
.add_ccn("mainnet", "official", "https://api.aleph.im")
.unwrap();
let net = store.get_network("mainnet").unwrap();
assert_eq!(net.default_ccn.as_deref(), Some("official"));
}
#[test]
fn second_ccn_does_not_override_network_default() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
store
.add_ccn("mainnet", "official", "https://api.aleph.im")
.unwrap();
store
.add_ccn("mainnet", "api3", "https://api3.aleph.im")
.unwrap();
let net = store.get_network("mainnet").unwrap();
assert_eq!(net.default_ccn.as_deref(), Some("official"));
}
#[test]
fn add_ccn_unknown_network_errors() {
let (_dir, store) = temp_store();
let err = store
.add_ccn("nope", "official", "https://api.aleph.im")
.unwrap_err();
assert!(matches!(err, ConfigError::NetworkNotFound(_)));
}
#[test]
fn add_duplicate_ccn_same_network_errors() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
store
.add_ccn("mainnet", "official", "https://api.aleph.im")
.unwrap();
let err = store
.add_ccn("mainnet", "official", "https://other.aleph.im")
.unwrap_err();
assert!(matches!(err, ConfigError::CcnAlreadyExists { .. }));
}
#[test]
fn same_ccn_name_in_different_networks_allowed() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
store.add_network("testnet").unwrap();
store
.add_ccn("mainnet", "local", "http://one:4024")
.unwrap();
store
.add_ccn("testnet", "local", "http://two:4024")
.unwrap();
assert_eq!(
store.get_ccn("mainnet", "local").unwrap().url,
"http://one:4024"
);
assert_eq!(
store.get_ccn("testnet", "local").unwrap().url,
"http://two:4024"
);
}
#[test]
fn get_ccn_not_in_network_errors() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
let err = store.get_ccn("mainnet", "nope").unwrap_err();
assert!(matches!(err, ConfigError::CcnNotFound { .. }));
}
#[test]
fn remove_ccn_from_network() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
store.add_ccn("mainnet", "a", "https://a.example").unwrap();
store.add_ccn("mainnet", "b", "https://b.example").unwrap();
store.remove_ccn("mainnet", "b").unwrap();
let ccns = store.get_network("mainnet").unwrap().ccns;
assert_eq!(ccns.len(), 1);
assert_eq!(ccns[0].name, "a");
}
#[test]
fn remove_default_ccn_clears_network_default() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
store.add_ccn("mainnet", "a", "https://a.example").unwrap();
store.remove_ccn("mainnet", "a").unwrap();
let net = store.get_network("mainnet").unwrap();
assert_eq!(net.default_ccn, None);
}
#[test]
fn remove_ccn_unknown_errors() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
let err = store.remove_ccn("mainnet", "nope").unwrap_err();
assert!(matches!(err, ConfigError::CcnNotFound { .. }));
}
#[test]
fn set_default_ccn_basic() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
store.add_ccn("mainnet", "a", "https://a.example").unwrap();
store.add_ccn("mainnet", "b", "https://b.example").unwrap();
store.set_default_ccn("mainnet", "b").unwrap();
let net = store.get_network("mainnet").unwrap();
assert_eq!(net.default_ccn.as_deref(), Some("b"));
}
#[test]
fn set_default_ccn_unknown_errors() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
let err = store.set_default_ccn("mainnet", "nope").unwrap_err();
assert!(matches!(err, ConfigError::CcnNotFound { .. }));
}
#[test]
fn list_all_ccns_across_networks() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
store.add_network("testnet").unwrap();
store
.add_ccn("mainnet", "official", "https://api.aleph.im")
.unwrap();
store
.add_ccn("testnet", "local", "http://localhost:4024")
.unwrap();
let all = store.list_all_ccns().unwrap();
assert_eq!(all.len(), 2);
assert!(
all.iter()
.any(|(n, c)| n == "mainnet" && c.name == "official")
);
assert!(all.iter().any(|(n, c)| n == "testnet" && c.name == "local"));
}
#[test]
fn remove_network_cascades_ccns() {
let (_dir, store) = temp_store();
store.add_network("mainnet").unwrap();
store.add_network("testnet").unwrap();
store
.add_ccn("testnet", "local", "http://localhost:4024")
.unwrap();
store.remove_network("testnet").unwrap();
assert!(store.get_network("testnet").is_err());
let all = store.list_all_ccns().unwrap();
assert!(all.iter().all(|(n, _)| n != "testnet"));
}
#[test]
fn ensure_builtin_seeds_mainnet() {
let (_dir, store) = temp_store();
store.ensure_builtin().unwrap();
let nets = store.list_networks().unwrap();
assert_eq!(nets.len(), 1);
assert_eq!(nets[0].name, "mainnet");
assert_eq!(nets[0].default_ccn.as_deref(), Some(BUILTIN_CCN_NAME));
assert_eq!(nets[0].ccns.len(), 1);
assert_eq!(nets[0].ccns[0].name, BUILTIN_CCN_NAME);
assert_eq!(nets[0].ccns[0].url, BUILTIN_CCN_URL);
assert_eq!(
store.default_network_name().unwrap().as_deref(),
Some("mainnet")
);
}
#[test]
fn ensure_builtin_seeds_mainnet_ethereum_defaults() {
let (_dir, store) = temp_store();
store.ensure_builtin().unwrap();
let eth = store.get_network("mainnet").unwrap().ethereum.unwrap();
assert_eq!(eth, EthereumConfig::mainnet_defaults());
}
#[test]
fn update_network_ethereum_creates_from_defaults_then_patches() {
let (_dir, store) = temp_store();
store.add_network("testnet").unwrap();
assert!(store.get_network("testnet").unwrap().ethereum.is_none());
let patch = EthereumPatch {
rpc_url: Some("http://localhost:8545".to_string()),
price_source: Some(PriceSource::Fixed { usd: 1.0 }),
..Default::default()
};
store.update_network_ethereum("testnet", &patch).unwrap();
let eth = store.get_network("testnet").unwrap().ethereum.unwrap();
assert_eq!(eth.rpc_url, "http://localhost:8545");
assert_eq!(eth.price_source, PriceSource::Fixed { usd: 1.0 });
assert_eq!(
eth.credit_contract,
EthereumConfig::mainnet_defaults().credit_contract
);
}
#[test]
fn update_network_ethereum_unknown_network_errors() {
let (_dir, store) = temp_store();
let patch = EthereumPatch {
rpc_url: Some("http://example".to_string()),
..Default::default()
};
let err = store.update_network_ethereum("nope", &patch).unwrap_err();
assert!(matches!(err, ConfigError::NetworkNotFound(_)));
}
#[test]
fn add_network_seeds_builtin_scheduler_url() {
let (_dir, store) = temp_store();
store.add_network("testnet").unwrap();
assert_eq!(
store.get_network("testnet").unwrap().scheduler_url,
BUILTIN_SCHEDULER_URL
);
}
#[test]
fn set_network_scheduler_url_overrides_default() {
let (_dir, store) = temp_store();
store.add_network("testnet").unwrap();
store
.set_network_scheduler_url("testnet", "https://scheduler.test")
.unwrap();
assert_eq!(
store.get_network("testnet").unwrap().scheduler_url,
"https://scheduler.test"
);
}
#[test]
fn set_network_scheduler_url_unknown_network_errors() {
let (_dir, store) = temp_store();
let err = store
.set_network_scheduler_url("nope", "https://scheduler.test")
.unwrap_err();
assert!(matches!(err, ConfigError::NetworkNotFound(_)));
}
#[test]
fn set_network_scheduler_url_rejects_bad_scheme() {
let (_dir, store) = temp_store();
store.add_network("testnet").unwrap();
let err = store
.set_network_scheduler_url("testnet", "ftp://scheduler.test")
.unwrap_err();
assert!(matches!(err, ConfigError::InvalidUrl(_, _)));
}
#[test]
fn ethereum_patch_is_empty_detects_no_fields() {
assert!(EthereumPatch::default().is_empty());
let patch = EthereumPatch {
rpc_url: Some("x".to_string()),
..Default::default()
};
assert!(!patch.is_empty());
}
#[test]
fn ensure_builtin_is_idempotent() {
let (_dir, store) = temp_store();
store.ensure_builtin().unwrap();
store.ensure_builtin().unwrap();
let nets = store.list_networks().unwrap();
assert_eq!(nets.len(), 1);
assert_eq!(nets[0].ccns.len(), 1);
}
#[test]
fn ensure_builtin_noop_when_networks_exist() {
let (_dir, store) = temp_store();
store.add_network("testnet").unwrap();
store.ensure_builtin().unwrap();
let nets = store.list_networks().unwrap();
assert_eq!(nets.len(), 1);
assert_eq!(nets[0].name, "testnet");
}
}