isocline-build 0.1.0

Build-time manifest verification for Isocline strategies: parses isocline.toml, checks every declared risk bucket and secret key against the on-chain UniversalAdapter / RitualTickScheduler. Fails the build on drift.
Documentation
//! Build-time manifest verification. Parses `isocline.toml`, RPCs
//! `UniversalAdapter.buckets(keccak256(name))` and
//! `RitualTickScheduler.hasDeclaredSecret(keccak256(name))`, errors out on mismatch.
//! Set `ISOCLINE_VERIFY=skip` to bypass (prints a loud cargo warning).

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(&eth_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(&eth_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()
}