1use 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(ð_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(ð_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}