use alloy_primitives::{keccak256, Address, U256};
use alloy_sol_types::{sol, SolCall};
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::path::Path;
use std::time::Duration;
use thiserror::Error;
sol! {
function buckets(bytes32 key) external view returns (bool);
function hasDeclaredSecret(bytes32 key) external view returns (bool);
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub adapter: AdapterSection,
pub scheduler: SchedulerSection,
#[serde(default)] pub buckets: Vec<String>,
#[serde(default)] pub secret_keys: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdapterSection { pub address: String, pub chain: String, pub rpc_url: String }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchedulerSection {
pub address: String,
#[serde(default)] pub rpc_url: Option<String>,
}
#[derive(Debug, Error)]
pub enum VerifyError {
#[error("read manifest: {0}")] Read(#[from] std::io::Error),
#[error("parse manifest: {0}")] ParseToml(#[from] toml::de::Error),
#[error("bad address {field}={value}: {reason}")]
BadAddress { field: String, value: String, reason: String },
#[error("rpc: {0}")] Rpc(String),
#[error("bucket {name:?} declared in isocline.toml but buckets[0x{hash}]=false on adapter {adapter}\n call UniversalAdapter.addRiskBucket(0x{hash}) and rerun")]
BucketMissing { name: String, hash: String, adapter: String },
#[error("secret key {name:?} declared in isocline.toml but hasDeclaredSecret(0x{hash})=false on scheduler {scheduler}\n call RitualTickScheduler.setDeclaredSecretKeys([...]) and rerun")]
SecretMissing { name: String, hash: String, scheduler: String },
#[error("env var {0} referenced in manifest but not set")]
MissingEnv(String),
}
pub fn verify_manifest(path: impl AsRef<Path>) -> Result<(), VerifyError> {
let path = path.as_ref();
println!("cargo:rerun-if-changed={}", path.display());
println!("cargo:rerun-if-env-changed=ISOCLINE_VERIFY");
if std::env::var("ISOCLINE_VERIFY").as_deref() == Ok("skip") {
println!("cargo:warning=isocline-build: ISOCLINE_VERIFY=skip — on-chain verification BYPASSED. Your strategy may reference buckets or secret keys that do not exist.");
return Ok(());
}
let m: Manifest = toml::from_str(&std::fs::read_to_string(path)?)?;
let adapter_rpc = expand_env(&m.adapter.rpc_url)?;
let scheduler_rpc = match m.scheduler.rpc_url.as_deref() {
Some(u) => expand_env(u)?,
None => adapter_rpc.clone(),
};
let adapter = parse_addr("adapter.address", &m.adapter.address)?;
let scheduler = parse_addr("scheduler.address", &m.scheduler.address)?;
for name in &m.buckets {
let hash = keccak256(name.as_bytes());
let data = bucketsCall { key: hash }.abi_encode();
let exists = decode_bool(ð_call(&adapter_rpc, adapter, &data)?);
if !exists {
return Err(VerifyError::BucketMissing {
name: name.clone(), hash: hex::encode(hash.as_slice()),
adapter: format!("{adapter:?}"),
});
}
println!("cargo:warning=isocline-build: bucket {name:?} ok");
}
for name in &m.secret_keys {
let hash = keccak256(name.as_bytes());
let data = hasDeclaredSecretCall { key: hash }.abi_encode();
let exists = decode_bool(ð_call(&scheduler_rpc, scheduler, &data)?);
if !exists {
return Err(VerifyError::SecretMissing {
name: name.clone(), hash: hex::encode(hash.as_slice()),
scheduler: format!("{scheduler:?}"),
});
}
println!("cargo:warning=isocline-build: secret key {name:?} ok");
}
Ok(())
}
fn parse_addr(field: &str, value: &str) -> Result<Address, VerifyError> {
value.parse().map_err(|e: <Address as std::str::FromStr>::Err| VerifyError::BadAddress {
field: field.into(), value: value.into(), reason: e.to_string(),
})
}
fn expand_env(s: &str) -> Result<String, VerifyError> {
let mut out = String::with_capacity(s.len());
let mut it = s.chars().peekable();
while let Some(c) = it.next() {
if c != '$' { out.push(c); continue; }
let mut name = String::new();
while let Some(&n) = it.peek() {
if n.is_ascii_alphanumeric() || n == '_' { name.push(n); it.next(); } else { break; }
}
if name.is_empty() { out.push('$'); continue; }
println!("cargo:rerun-if-env-changed={name}");
out.push_str(&std::env::var(&name).map_err(|_| VerifyError::MissingEnv(name))?);
}
Ok(out)
}
fn eth_call(rpc: &str, to: Address, data: &[u8]) -> Result<Vec<u8>, VerifyError> {
let payload = serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "eth_call",
"params": [{ "to": format!("{to:?}"), "data": format!("0x{}", hex::encode(data)) }, "latest"],
});
let agent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(10))
.timeout_read(Duration::from_secs(15))
.build();
let resp = agent.post(rpc).set("content-type", "application/json").send_json(payload)
.map_err(|e| VerifyError::Rpc(format!("POST {rpc}: {e}")))?;
let mut body = String::new();
resp.into_reader().take(1 << 20).read_to_string(&mut body)
.map_err(|e| VerifyError::Rpc(e.to_string()))?;
let j: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| VerifyError::Rpc(format!("parse {e}; body={body}")))?;
if let Some(err) = j.get("error") { return Err(VerifyError::Rpc(format!("rpc error: {err}"))); }
let hex_str = j.get("result").and_then(|v| v.as_str())
.ok_or_else(|| VerifyError::Rpc(format!("no result in response: {body}")))?;
hex::decode(hex_str.trim_start_matches("0x"))
.map_err(|e| VerifyError::Rpc(format!("decode hex: {e}")))
}
fn decode_bool(bytes: &[u8]) -> bool {
bytes.len() >= 32 && !U256::from_be_slice(&bytes[..32]).is_zero()
}