use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use starknet_core::{
types::Felt,
utils::{UdcUniqueSettings, UdcUniqueness, get_udc_deployed_address},
};
use crate::DeployerError;
pub const LEGACY_UDC_ADDRESS_HEX: &str =
"0x041a78e741e5af2fec34b695679bc6891742439f7afb8484ecd7766661ad02bf";
#[must_use]
pub fn legacy_udc_address() -> Felt {
Felt::from_hex(LEGACY_UDC_ADDRESS_HEX).expect("static UDC address parses")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Family {
Normal,
Lognormal,
Multinoulli,
Bivariate,
}
impl Family {
#[must_use]
pub const fn all() -> [Self; 4] {
[
Self::Normal,
Self::Lognormal,
Self::Multinoulli,
Self::Bivariate,
]
}
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::Normal => "normal",
Self::Lognormal => "lognormal",
Self::Multinoulli => "multinoulli",
Self::Bivariate => "bivariate",
}
}
#[must_use]
pub const fn env_infix(self) -> &'static str {
match self {
Self::Normal => "NORMAL",
Self::Lognormal => "LOGNORMAL",
Self::Multinoulli => "MULTINOULLI",
Self::Bivariate => "BIVARIATE",
}
}
#[must_use]
pub fn env_var_name(self) -> String {
format!("DEADEYE_{}_RUNTIME_ADDR", self.env_infix())
}
#[must_use]
pub fn from_slug(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"normal" => Some(Self::Normal),
"lognormal" => Some(Self::Lognormal),
"multinoulli" => Some(Self::Multinoulli),
"bivariate" => Some(Self::Bivariate),
_ => None,
}
}
}
pub mod mainnet_class_hashes {
pub const NORMAL: &str = "0x046d492bbef6f8034b1647a95a96580555742fd4655e766dee04e442a778a753";
pub const LOGNORMAL: &str =
"0x031239619293d85ba74eb616a6b63db6e1409a8f444f2b1c79129a6d8a65e8e6";
pub const BIVARIATE: &str =
"0x0569dad379af349c3d677483ad38fb02ba90f2206082cc3e8d24d82add2924b4";
pub const MULTINOULLI: &str =
"0x055a9154ab0c75739e769d69b6d350fa94b2bbbc4a03b9dcaeef1a1f820f7ba1";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ChainKey {
Mainnet,
Sepolia,
Other,
}
impl ChainKey {
#[must_use]
pub fn from_chain_id_hex(hex: &str) -> Self {
let normalised = hex.trim_start_matches("0x").trim_start_matches('0');
let lower = normalised.to_ascii_lowercase();
if lower == "534e5f4d41494e" {
Self::Mainnet
} else if lower == "534e5f5345504f4c4941" {
Self::Sepolia
} else {
Self::Other
}
}
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::Mainnet => "mainnet",
Self::Sepolia => "sepolia",
Self::Other => "devnet",
}
}
}
pub fn runtime_class_hash(chain: ChainKey, family: Family) -> Result<Felt, DeployerError> {
let raw: String = match (chain, family) {
(ChainKey::Mainnet, Family::Normal) => mainnet_class_hashes::NORMAL.to_owned(),
(ChainKey::Mainnet, Family::Lognormal) => mainnet_class_hashes::LOGNORMAL.to_owned(),
(ChainKey::Mainnet, Family::Bivariate) => mainnet_class_hashes::BIVARIATE.to_owned(),
(ChainKey::Mainnet, Family::Multinoulli) => mainnet_class_hashes::MULTINOULLI.to_owned(),
(ChainKey::Sepolia, family) => {
let d = crate::Deployment::sepolia()?;
match family {
Family::Normal => d.class_hashes.normal_math_runtime,
Family::Lognormal => d.class_hashes.lognormal_math_runtime,
Family::Multinoulli => d.class_hashes.multinoulli_math_runtime,
Family::Bivariate => d.class_hashes.bivariate_math_runtime,
}
},
(ChainKey::Other, _) => {
return Err(DeployerError::InvalidFelt {
field: "runtime_class_hash:chain",
value: "devnet/other — pass class hash explicitly".to_owned(),
});
},
};
Felt::from_hex(&raw).map_err(|_| DeployerError::InvalidFelt {
field: "runtime_class_hash",
value: raw,
})
}
#[must_use]
pub fn projected_deploy_address(class_hash: Felt, salt: Felt, _deployer: Felt) -> Felt {
let calldata: &[Felt] = &[];
get_udc_deployed_address(salt, class_hash, &UdcUniqueness::NotUnique, calldata)
}
#[must_use]
pub fn projected_deploy_address_unique(class_hash: Felt, salt: Felt, deployer: Felt) -> Felt {
let calldata: &[Felt] = &[];
get_udc_deployed_address(
salt,
class_hash,
&UdcUniqueness::Unique(UdcUniqueSettings {
deployer_address: deployer,
udc_contract_address: legacy_udc_address(),
}),
calldata,
)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeEntry {
pub address: String,
pub class_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deployed_at_block: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deployed_tx: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(transparent)]
pub struct RuntimeCache {
pub chains: BTreeMap<String, BTreeMap<String, RuntimeEntry>>,
}
impl RuntimeCache {
pub fn default_path() -> Result<PathBuf, DeployerError> {
if let Some(p) = std::env::var_os("DEADEYE_RUNTIMES_PATH") {
return Ok(PathBuf::from(p));
}
let base = dirs_next_config_dir().ok_or_else(|| DeployerError::InvalidFelt {
field: "runtimes_path",
value: "could not locate user config dir; set DEADEYE_RUNTIMES_PATH".to_owned(),
})?;
Ok(base.join("deadeye").join("runtimes.toml"))
}
pub fn load(path: &Path) -> Result<Self, DeployerError> {
if !path.exists() {
return Ok(Self::default());
}
let raw = fs::read_to_string(path).map_err(|e| DeployerError::InvalidFelt {
field: "runtimes_path",
value: format!("reading {}: {e}", path.display()),
})?;
if raw.trim().is_empty() {
return Ok(Self::default());
}
toml::from_str::<Self>(&raw).map_err(|e| DeployerError::InvalidFelt {
field: "runtimes_toml",
value: format!("parsing {}: {e}", path.display()),
})
}
pub fn save(&self, path: &Path) -> Result<(), DeployerError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| DeployerError::InvalidFelt {
field: "runtimes_path",
value: format!("mkdir {}: {e}", parent.display()),
})?;
}
let body = toml::to_string_pretty(self).map_err(|e| DeployerError::InvalidFelt {
field: "runtimes_toml",
value: format!("serializing: {e}"),
})?;
let mut header = String::from(
"# Deadeye runtime cache.\n\
# Populated by `deadeye admin deploy-math-runtime`; safe to delete\n\
# and re-derive (the command is idempotent + verifies on each run).\n\n",
);
header.push_str(&body);
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp_name = format!(
".{}.{pid}.{nanos}.tmp",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("runtimes.toml")
);
let tmp_path = path
.parent()
.map_or_else(|| PathBuf::from(&tmp_name), |p| p.join(&tmp_name));
fs::write(&tmp_path, header).map_err(|e| DeployerError::InvalidFelt {
field: "runtimes_path",
value: format!("writing {}: {e}", tmp_path.display()),
})?;
match fs::rename(&tmp_path, path) {
Ok(()) => Ok(()),
Err(e) => {
let _ = fs::remove_file(&tmp_path);
Err(DeployerError::InvalidFelt {
field: "runtimes_path",
value: format!("rename {} → {}: {e}", tmp_path.display(), path.display()),
})
},
}
}
#[must_use]
pub fn get(&self, chain: ChainKey, family: Family) -> Option<&RuntimeEntry> {
self.chains.get(chain.slug())?.get(family.slug())
}
pub fn upsert(
&mut self,
chain: ChainKey,
family: Family,
entry: RuntimeEntry,
) -> Option<RuntimeEntry> {
self.chains
.entry(chain.slug().to_owned())
.or_default()
.insert(family.slug().to_owned(), entry)
}
}
fn dirs_next_config_dir() -> Option<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
let p = PathBuf::from(xdg);
if !p.as_os_str().is_empty() {
return Some(p);
}
}
#[cfg(target_os = "macos")]
{
if let Some(home) = std::env::var_os("HOME") {
return Some(
PathBuf::from(home)
.join("Library")
.join("Application Support"),
);
}
}
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::print_stderr,
reason = "test code: panic-style failures are the assertion mechanism"
)]
mod tests {
use super::*;
#[test]
fn family_slug_roundtrips() {
for f in Family::all() {
assert_eq!(Family::from_slug(f.slug()), Some(f));
}
assert_eq!(Family::from_slug("Normal"), Some(Family::Normal));
assert_eq!(Family::from_slug("nope"), None);
}
#[test]
fn env_var_name_is_canonical() {
assert_eq!(Family::Normal.env_var_name(), "DEADEYE_NORMAL_RUNTIME_ADDR");
assert_eq!(
Family::Lognormal.env_var_name(),
"DEADEYE_LOGNORMAL_RUNTIME_ADDR"
);
assert_eq!(
Family::Multinoulli.env_var_name(),
"DEADEYE_MULTINOULLI_RUNTIME_ADDR"
);
assert_eq!(
Family::Bivariate.env_var_name(),
"DEADEYE_BIVARIATE_RUNTIME_ADDR"
);
}
#[test]
fn chain_key_detects_mainnet_and_sepolia() {
assert_eq!(
ChainKey::from_chain_id_hex("0x534e5f4d41494e"),
ChainKey::Mainnet
);
assert_eq!(
ChainKey::from_chain_id_hex("0x534e5f5345504f4c4941"),
ChainKey::Sepolia
);
assert_eq!(ChainKey::from_chain_id_hex("0xdeadbeef"), ChainKey::Other);
assert_eq!(
ChainKey::from_chain_id_hex("0x00534e5f4d41494e"),
ChainKey::Mainnet
);
}
#[test]
fn mainnet_class_hashes_parse() {
for raw in [
mainnet_class_hashes::NORMAL,
mainnet_class_hashes::LOGNORMAL,
mainnet_class_hashes::BIVARIATE,
mainnet_class_hashes::MULTINOULLI,
] {
Felt::from_hex(raw).expect("class hash parses");
}
}
#[test]
fn mainnet_lookup_returns_pinned_constants() {
let h = runtime_class_hash(ChainKey::Mainnet, Family::Normal).expect("mainnet lookup");
assert_eq!(h, Felt::from_hex(mainnet_class_hashes::NORMAL).unwrap());
}
#[test]
fn pinned_mainnet_constants_match_bundled_manifest() {
let d = crate::Deployment::mainnet().expect("mainnet manifest parses");
for (family, pinned) in [
(Family::Normal, mainnet_class_hashes::NORMAL),
(Family::Lognormal, mainnet_class_hashes::LOGNORMAL),
(Family::Bivariate, mainnet_class_hashes::BIVARIATE),
(Family::Multinoulli, mainnet_class_hashes::MULTINOULLI),
] {
let manifest_raw = match family {
Family::Normal => &d.class_hashes.normal_math_runtime,
Family::Lognormal => &d.class_hashes.lognormal_math_runtime,
Family::Bivariate => &d.class_hashes.bivariate_math_runtime,
Family::Multinoulli => &d.class_hashes.multinoulli_math_runtime,
};
let pinned_felt = Felt::from_hex(pinned).expect("pinned parses");
let manifest_felt = Felt::from_hex(manifest_raw).expect("manifest parses");
assert_eq!(
pinned_felt,
manifest_felt,
"drift in {family:?}: pinned={pinned} manifest={manifest_raw}"
);
}
}
#[test]
fn projected_address_is_deterministic() {
let class_hash = Felt::from_hex(mainnet_class_hashes::NORMAL).unwrap();
let salt = Felt::from(0x0000_ABCD_u64);
let deployer = Felt::from_hex("0x0000beef").unwrap();
let a = projected_deploy_address(class_hash, salt, deployer);
let b = projected_deploy_address(class_hash, salt, deployer);
assert_eq!(a, b, "must be deterministic");
let other_deployer = Felt::from_hex("0xbaadf00d").unwrap();
let c = projected_deploy_address(class_hash, salt, other_deployer);
assert_eq!(a, c, "unique=false → deployer ignored");
let d = projected_deploy_address(class_hash, Felt::from(0x0000_DCBA_u64), deployer);
assert_ne!(a, d, "salt must influence address");
}
#[test]
fn projected_address_unique_branch_differs() {
let class_hash = Felt::from_hex(mainnet_class_hashes::NORMAL).unwrap();
let salt = Felt::from(0x0000_ABCD_u64);
let deployer = Felt::from_hex("0x0000beef").unwrap();
let not_unique = projected_deploy_address(class_hash, salt, deployer);
let unique = projected_deploy_address_unique(class_hash, salt, deployer);
assert_ne!(
not_unique, unique,
"unique=true must hash the deployer into the salt"
);
}
#[test]
fn cache_roundtrips_via_toml() {
let mut cache = RuntimeCache::default();
cache.upsert(
ChainKey::Mainnet,
Family::Normal,
RuntimeEntry {
address: "0xabc".to_owned(),
class_hash: mainnet_class_hashes::NORMAL.to_owned(),
deployed_at_block: Some(1_234_567),
deployed_tx: Some("0xdef".to_owned()),
},
);
cache.upsert(
ChainKey::Sepolia,
Family::Lognormal,
RuntimeEntry {
address: "0xfeed".to_owned(),
class_hash: "0xface".to_owned(),
deployed_at_block: None,
deployed_tx: None,
},
);
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("runtimes.toml");
cache.save(&path).expect("save");
assert!(path.exists());
let loaded = RuntimeCache::load(&path).expect("load");
assert_eq!(loaded, cache);
let raw = fs::read_to_string(&path).expect("read back");
assert!(raw.contains("[mainnet.normal]"));
assert!(raw.contains("[sepolia.lognormal]"));
}
#[test]
fn cache_load_missing_returns_empty() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("nonexistent.toml");
let loaded = RuntimeCache::load(&path).expect("missing → empty");
assert!(loaded.chains.is_empty());
}
#[test]
fn cache_load_malformed_returns_typed_error() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("bad.toml");
fs::write(&path, "this is not = valid = toml === ").expect("write");
let err = RuntimeCache::load(&path).expect_err("must reject malformed");
let DeployerError::InvalidFelt { field, .. } = err else {
panic!("expected InvalidFelt-tagged error from malformed TOML");
};
assert_eq!(field, "runtimes_toml");
}
}