Skip to main content

isocline_build/
lib.rs

1//! Build-time manifest verification. Parses `isocline.toml`, RPCs
2//! `UniversalAdapter.buckets(keccak256(name))` and
3//! `RitualTickScheduler.hasDeclaredSecret(keccak256(name))`, errors out on mismatch.
4//! Set `ISOCLINE_VERIFY=skip` to bypass (prints a loud cargo warning).
5
6use alloy_primitives::{keccak256, Address, U256};
7use alloy_sol_types::{sol, SolCall};
8use serde::{Deserialize, Serialize};
9use std::io::Read;
10use std::path::Path;
11use std::time::Duration;
12use thiserror::Error;
13
14sol! {
15    function buckets(bytes32 key) external view returns (bool);
16    function hasDeclaredSecret(bytes32 key) external view returns (bool);
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Manifest {
21    pub adapter: AdapterSection,
22    pub scheduler: SchedulerSection,
23    #[serde(default)] pub buckets: Vec<String>,
24    #[serde(default)] pub secret_keys: Vec<String>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct AdapterSection { pub address: String, pub chain: String, pub rpc_url: String }
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct SchedulerSection {
32    pub address: String,
33    #[serde(default)] pub rpc_url: Option<String>,
34}
35
36#[derive(Debug, Error)]
37pub enum VerifyError {
38    #[error("read manifest: {0}")] Read(#[from] std::io::Error),
39    #[error("parse manifest: {0}")] ParseToml(#[from] toml::de::Error),
40    #[error("bad address {field}={value}: {reason}")]
41    BadAddress { field: String, value: String, reason: String },
42    #[error("rpc: {0}")] Rpc(String),
43    #[error("bucket {name:?} declared in isocline.toml but buckets[0x{hash}]=false on adapter {adapter}\n       call UniversalAdapter.addRiskBucket(0x{hash}) and rerun")]
44    BucketMissing { name: String, hash: String, adapter: String },
45    #[error("secret key {name:?} declared in isocline.toml but hasDeclaredSecret(0x{hash})=false on scheduler {scheduler}\n       call RitualTickScheduler.setDeclaredSecretKeys([...]) and rerun")]
46    SecretMissing { name: String, hash: String, scheduler: String },
47    #[error("env var {0} referenced in manifest but not set")]
48    MissingEnv(String),
49}
50
51pub fn verify_manifest(path: impl AsRef<Path>) -> Result<(), VerifyError> {
52    let path = path.as_ref();
53    println!("cargo:rerun-if-changed={}", path.display());
54    println!("cargo:rerun-if-env-changed=ISOCLINE_VERIFY");
55
56    if std::env::var("ISOCLINE_VERIFY").as_deref() == Ok("skip") {
57        println!("cargo:warning=isocline-build: ISOCLINE_VERIFY=skip — on-chain verification BYPASSED. Your strategy may reference buckets or secret keys that do not exist.");
58        return Ok(());
59    }
60
61    let m: Manifest = toml::from_str(&std::fs::read_to_string(path)?)?;
62    let adapter_rpc = expand_env(&m.adapter.rpc_url)?;
63    let scheduler_rpc = match m.scheduler.rpc_url.as_deref() {
64        Some(u) => expand_env(u)?,
65        None => adapter_rpc.clone(),
66    };
67    let adapter = parse_addr("adapter.address", &m.adapter.address)?;
68    let scheduler = parse_addr("scheduler.address", &m.scheduler.address)?;
69
70    for name in &m.buckets {
71        let hash = keccak256(name.as_bytes());
72        let data = bucketsCall { key: hash }.abi_encode();
73        let exists = decode_bool(&eth_call(&adapter_rpc, adapter, &data)?);
74        if !exists {
75            return Err(VerifyError::BucketMissing {
76                name: name.clone(), hash: hex::encode(hash.as_slice()),
77                adapter: format!("{adapter:?}"),
78            });
79        }
80        println!("cargo:warning=isocline-build: bucket {name:?} ok");
81    }
82    for name in &m.secret_keys {
83        let hash = keccak256(name.as_bytes());
84        let data = hasDeclaredSecretCall { key: hash }.abi_encode();
85        let exists = decode_bool(&eth_call(&scheduler_rpc, scheduler, &data)?);
86        if !exists {
87            return Err(VerifyError::SecretMissing {
88                name: name.clone(), hash: hex::encode(hash.as_slice()),
89                scheduler: format!("{scheduler:?}"),
90            });
91        }
92        println!("cargo:warning=isocline-build: secret key {name:?} ok");
93    }
94    Ok(())
95}
96
97fn parse_addr(field: &str, value: &str) -> Result<Address, VerifyError> {
98    value.parse().map_err(|e: <Address as std::str::FromStr>::Err| VerifyError::BadAddress {
99        field: field.into(), value: value.into(), reason: e.to_string(),
100    })
101}
102
103fn expand_env(s: &str) -> Result<String, VerifyError> {
104    let mut out = String::with_capacity(s.len());
105    let mut it = s.chars().peekable();
106    while let Some(c) = it.next() {
107        if c != '$' { out.push(c); continue; }
108        let mut name = String::new();
109        while let Some(&n) = it.peek() {
110            if n.is_ascii_alphanumeric() || n == '_' { name.push(n); it.next(); } else { break; }
111        }
112        if name.is_empty() { out.push('$'); continue; }
113        println!("cargo:rerun-if-env-changed={name}");
114        out.push_str(&std::env::var(&name).map_err(|_| VerifyError::MissingEnv(name))?);
115    }
116    Ok(out)
117}
118
119fn eth_call(rpc: &str, to: Address, data: &[u8]) -> Result<Vec<u8>, VerifyError> {
120    let payload = serde_json::json!({
121        "jsonrpc": "2.0", "id": 1, "method": "eth_call",
122        "params": [{ "to": format!("{to:?}"), "data": format!("0x{}", hex::encode(data)) }, "latest"],
123    });
124    let agent = ureq::AgentBuilder::new()
125        .timeout_connect(Duration::from_secs(10))
126        .timeout_read(Duration::from_secs(15))
127        .build();
128    let resp = agent.post(rpc).set("content-type", "application/json").send_json(payload)
129        .map_err(|e| VerifyError::Rpc(format!("POST {rpc}: {e}")))?;
130    let mut body = String::new();
131    resp.into_reader().take(1 << 20).read_to_string(&mut body)
132        .map_err(|e| VerifyError::Rpc(e.to_string()))?;
133    let j: serde_json::Value = serde_json::from_str(&body)
134        .map_err(|e| VerifyError::Rpc(format!("parse {e}; body={body}")))?;
135    if let Some(err) = j.get("error") { return Err(VerifyError::Rpc(format!("rpc error: {err}"))); }
136    let hex_str = j.get("result").and_then(|v| v.as_str())
137        .ok_or_else(|| VerifyError::Rpc(format!("no result in response: {body}")))?;
138    hex::decode(hex_str.trim_start_matches("0x"))
139        .map_err(|e| VerifyError::Rpc(format!("decode hex: {e}")))
140}
141
142fn decode_bool(bytes: &[u8]) -> bool {
143    bytes.len() >= 32 && !U256::from_be_slice(&bytes[..32]).is_zero()
144}