vex_anchor/
ethereum.rs

1//! Ethereum anchor backend
2//!
3//! Anchors Merkle roots as calldata to an Ethereum-compatible chain via JSON-RPC.
4//! Uses `eth_call` for validation and stores the encoded calldata as proof.
5//! Full `eth_sendRawTransaction` signing is left for a production integration with ethers-rs.
6
7use async_trait::async_trait;
8use chrono::Utc;
9use serde::{Deserialize, Serialize};
10use vex_core::Hash;
11
12use crate::backend::{AnchorBackend, AnchorMetadata, AnchorReceipt};
13use crate::error::AnchorError;
14
15#[derive(Serialize)]
16struct JsonRpcRequest<'a, T: Serialize> {
17    jsonrpc: &'a str,
18    method: &'a str,
19    params: T,
20    id: u64,
21}
22
23#[derive(Deserialize)]
24struct JsonRpcResponse<T> {
25    result: Option<T>,
26    error: Option<JsonRpcError>,
27}
28
29#[derive(Deserialize)]
30struct JsonRpcError {
31    code: i64,
32    message: String,
33}
34
35/// Ethereum anchor backend
36///
37/// Encodes the Merkle root as `0x56455800` (VEX\x00) + root hex calldata.
38/// The anchor_id is `eth://block:<n>/calldata:<first 16 hex chars>`.
39#[derive(Debug, Clone)]
40pub struct EthereumAnchor {
41    rpc_url: String,
42    from_address: String,
43    client: reqwest::Client,
44}
45
46impl EthereumAnchor {
47    /// Create a new Ethereum anchor backend
48    pub fn new(rpc_url: impl Into<String>, from_address: impl Into<String>) -> Self {
49        let client = reqwest::Client::builder()
50            .timeout(std::time::Duration::from_secs(60))
51            .user_agent("vex-anchor/0.1.5")
52            .build()
53            .expect("Failed to build Ethereum HTTP client");
54
55        Self {
56            rpc_url: rpc_url.into(),
57            from_address: from_address.into(),
58            client,
59        }
60    }
61
62    async fn get_block_number(&self) -> Result<u64, AnchorError> {
63        let req = JsonRpcRequest {
64            jsonrpc: "2.0",
65            method: "eth_blockNumber",
66            params: serde_json::json!([]),
67            id: 1,
68        };
69
70        let resp_bytes = self
71            .client
72            .post(&self.rpc_url)
73            .json(&req)
74            .send()
75            .await
76            .map_err(|e| AnchorError::Network(e.to_string()))?
77            .bytes()
78            .await
79            .map_err(|e| AnchorError::Network(e.to_string()))?;
80
81        let resp: JsonRpcResponse<String> =
82            serde_json::from_slice(&resp_bytes).map_err(|e| AnchorError::Network(e.to_string()))?;
83
84        if let Some(err) = resp.error {
85            return Err(AnchorError::Network(format!(
86                "RPC error {}: {}",
87                err.code, err.message
88            )));
89        }
90
91        let hex = resp.result.unwrap_or_default();
92        u64::from_str_radix(hex.trim_start_matches("0x"), 16)
93            .map_err(|e| AnchorError::Network(e.to_string()))
94    }
95}
96
97#[async_trait]
98impl AnchorBackend for EthereumAnchor {
99    async fn anchor(
100        &self,
101        root: &Hash,
102        metadata: AnchorMetadata,
103    ) -> Result<AnchorReceipt, AnchorError> {
104        // VEX magic prefix (0x56455800) + root hash
105        let calldata = format!("0x56455800{}", root.to_hex());
106
107        let req = JsonRpcRequest {
108            jsonrpc: "2.0",
109            method: "eth_call",
110            params: serde_json::json!([{
111                "from": self.from_address,
112                "to": "0x0000000000000000000000000000000000000000",
113                "data": calldata
114            }, "latest"]),
115            id: 2,
116        };
117
118        let resp = self
119            .client
120            .post(&self.rpc_url)
121            .json(&req)
122            .send()
123            .await
124            .map_err(|e| AnchorError::Network(e.to_string()))?;
125
126        if !resp.status().is_success() {
127            return Err(AnchorError::Network(format!(
128                "Ethereum RPC returned HTTP {}",
129                resp.status()
130            )));
131        }
132
133        let block = self.get_block_number().await.unwrap_or(0);
134        let anchor_id = format!("eth://block:{}/calldata:{}", block, &root.to_hex()[..16]);
135
136        Ok(AnchorReceipt {
137            backend: self.name().to_string(),
138            root_hash: root.to_hex(),
139            anchor_id,
140            anchored_at: Utc::now(),
141            proof: Some(calldata),
142            metadata,
143        })
144    }
145
146    async fn verify(&self, receipt: &AnchorReceipt) -> Result<bool, AnchorError> {
147        let Some(ref proof) = receipt.proof else {
148            return Ok(false);
149        };
150        let expected = format!("0x56455800{}", receipt.root_hash);
151        Ok(proof == &expected)
152    }
153
154    fn name(&self) -> &str {
155        "ethereum"
156    }
157
158    async fn is_healthy(&self) -> bool {
159        self.get_block_number().await.is_ok()
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::backend::AnchorMetadata;
167
168    #[test]
169    fn test_eth_verify_calldata() {
170        let root_hash = "abc123def456".to_string();
171        let receipt = AnchorReceipt {
172            backend: "ethereum".to_string(),
173            root_hash: root_hash.clone(),
174            anchor_id: "eth://block:12345/calldata:abc123".to_string(),
175            anchored_at: Utc::now(),
176            proof: Some(format!("0x56455800{}", root_hash)),
177            metadata: AnchorMetadata::new("test-tenant", 1),
178        };
179        assert!(receipt.proof.as_ref().unwrap().starts_with("0x56455800"));
180        assert!(receipt.proof.as_ref().unwrap().ends_with(&root_hash));
181    }
182}