Skip to main content

bal_source/
jsonrpc.rs

1//! JSON-RPC source. Day-0 findings on Platåberget (reth 2.5.0, 2026-08-29):
2//!
3//! - the block object carries `blockAccessListHash` but **not** the BAL body;
4//! - the body comes from `eth_getBlockAccessList(blockNumberOrTagOrHash)`,
5//!   decoded to JSON (execution-apis), available back to block 1;
6//! - `debug_getRawBlockAccessList` exists in reth but public gateways block
7//!   `debug_*`.
8//!
9//! Also hosts the day-0 probe.
10
11use crate::{
12    AccountProof, BalSource, Header, Result, SourceError, SourcedBlock, StateSource, StorageProof,
13};
14use alloy_primitives::{Address, Bytes, B256, U256};
15use async_trait::async_trait;
16use bal_codec::BlockAccessList;
17use serde::Deserialize;
18use serde_json::{json, Value};
19
20/// Header field carrying `keccak(rlp(bal))` on the block object.
21pub const BAL_HASH_FIELD: &str = "blockAccessListHash";
22/// JSON-RPC method serving the BAL body (execution-apis).
23pub const BAL_METHOD: &str = "eth_getBlockAccessList";
24/// Largest JSON-RPC response body accepted, in bytes.
25pub const MAX_BODY_BYTES: u64 = 64 * 1024 * 1024;
26
27/// [`BalSource`] + [`StateSource`] over plain JSON-RPC. One request per call;
28/// no batching, no retries — callers own that policy.
29pub struct JsonRpcSource {
30    url: String,
31    client: reqwest::Client,
32}
33
34#[derive(Deserialize)]
35struct RpcResponse {
36    result: Option<Value>,
37    error: Option<RpcError>,
38}
39
40#[derive(Deserialize)]
41struct RpcError {
42    code: i64,
43    message: String,
44}
45
46impl JsonRpcSource {
47    /// Talk to the JSON-RPC endpoint at `url`. Requests time out after 30 s
48    /// so a stalled gateway cannot hang a sync forever; redirects are not
49    /// followed, so a request never silently goes to a host you did not name.
50    pub fn new(url: impl Into<String>) -> Self {
51        let client = reqwest::Client::builder()
52            .timeout(std::time::Duration::from_secs(30))
53            .redirect(reqwest::redirect::Policy::none())
54            .build()
55            .unwrap_or_default();
56        Self {
57            url: url.into(),
58            client,
59        }
60    }
61
62    /// Raw JSON-RPC call; returns the `result` member or the error object as [`SourceError::Rpc`].
63    pub async fn call(&self, method: &str, params: Value) -> Result<Value> {
64        let body = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params});
65        let http = self
66            .client
67            .post(&self.url)
68            .json(&body)
69            .send()
70            .await
71            .map_err(|e| SourceError::Transport(e.to_string()))?;
72        // Gateways answer 5xx with HTML; say so instead of "invalid JSON".
73        let status = http.status();
74        if !status.is_success() {
75            return Err(SourceError::Transport(format!(
76                "HTTP {} from {} for {method}",
77                status.as_u16(),
78                self.url
79            )));
80        }
81        // Read the body with a hard cap: a node must not be able to make us
82        // allocate without bound. The EIP caps a BAL at 8 MiB of RLP; its
83        // JSON form is a few times larger. 64 MiB is generous.
84        if let Some(len) = http.content_length() {
85            if len > MAX_BODY_BYTES {
86                return Err(SourceError::Transport(format!(
87                    "{method}: response of {len} bytes exceeds the {MAX_BODY_BYTES}-byte limit"
88                )));
89            }
90        }
91        let mut http = http;
92        let mut body: Vec<u8> = Vec::new();
93        while let Some(chunk) = http
94            .chunk()
95            .await
96            .map_err(|e| SourceError::Transport(format!("{method}: {e}")))?
97        {
98            if body.len() + chunk.len() > MAX_BODY_BYTES as usize {
99                return Err(SourceError::Transport(format!(
100                    "{method}: response exceeds the {MAX_BODY_BYTES}-byte limit"
101                )));
102            }
103            body.extend_from_slice(&chunk);
104        }
105        let resp: RpcResponse = serde_json::from_slice(&body)
106            .map_err(|e| SourceError::Malformed(format!("{method}: {e}")))?;
107        if let Some(e) = resp.error {
108            return Err(SourceError::Rpc {
109                code: e.code,
110                message: e.message,
111            });
112        }
113        Ok(resp.result.unwrap_or(Value::Null))
114    }
115
116    async fn raw_block(&self, tag: Value) -> Result<Value> {
117        let v = self
118            .call("eth_getBlockByNumber", json!([tag, false]))
119            .await?;
120        if v.is_null() {
121            return Err(SourceError::BlockNotFound(match &tag {
122                Value::String(s) => parse_hex_u64(s).unwrap_or(u64::MAX),
123                _ => u64::MAX,
124            }));
125        }
126        Ok(v)
127    }
128
129    /// Fetch and decode the BAL of `number` via `eth_getBlockAccessList`.
130    pub async fn bal(&self, number: u64) -> Result<BlockAccessList> {
131        let v = self
132            .call(BAL_METHOD, json!([format!("{number:#x}")]))
133            .await?;
134        if v.is_null() {
135            return Err(SourceError::NoBal(number));
136        }
137        Ok(BlockAccessList::from_rpc_json(&v)?)
138    }
139
140    /// Fetch header + BAL and check the BAL against the header. Used by the
141    /// probe; `sync` does the same check itself.
142    async fn probe_block(&self, tag: Value) -> BalProbe {
143        let v = match self.raw_block(tag).await {
144            Ok(v) => v,
145            Err(e) => return BalProbe::Error(e.to_string()),
146        };
147        let header = match parse_header(&v) {
148            Ok(h) => h,
149            Err(e) => return BalProbe::Error(e.to_string()),
150        };
151        let bal = match self.bal(header.number).await {
152            Ok(b) => b,
153            Err(SourceError::NoBal(n)) => return BalProbe::Missing(n),
154            Err(e) => return BalProbe::Error(e.to_string()),
155        };
156        let computed = bal.hash();
157        match header.block_access_list_hash {
158            None => BalProbe::NoHashInHeader {
159                block: header.number,
160                accounts: bal.accounts.len(),
161            },
162            Some(expected) if expected == computed => BalProbe::Verified {
163                block: header.number,
164                accounts: bal.accounts.len(),
165                hash: computed,
166            },
167            Some(expected) => BalProbe::Mismatch {
168                block: header.number,
169                computed,
170                expected,
171            },
172        }
173    }
174
175    /// Day-0 probe: Q1 (is the BAL served?), Q2 (for old blocks too?), and
176    /// does our codec reproduce the header hash on real data.
177    pub async fn probe(&self, old_block_age: u64) -> Result<ProbeReport> {
178        let head_v = self.raw_block(json!("latest")).await?;
179        let head = parse_header(&head_v)?;
180        let old_number = head.number.saturating_sub(old_block_age).max(1);
181        let chain_id = self
182            .call("eth_chainId", json!([]))
183            .await
184            .ok()
185            .and_then(|v| v.as_str().and_then(|s| parse_hex_u64(s).ok()));
186        let client_version = self
187            .call("web3_clientVersion", json!([]))
188            .await
189            .ok()
190            .and_then(|v| v.as_str().map(String::from));
191        let head_fields = head_v
192            .as_object()
193            .map(|o| o.keys().cloned().collect())
194            .unwrap_or_default();
195        let head_probe = self.probe_block(json!(format!("{:#x}", head.number))).await;
196        let old_probe = self.probe_block(json!(format!("{old_number:#x}"))).await;
197        let earliest_probe = self.probe_block(json!("0x1")).await;
198        let proof_window = self.measure_proof_window(head.number).await;
199
200        Ok(ProbeReport {
201            client_version,
202            chain_id,
203            head: head.number,
204            head_fields,
205            head_probe,
206            old_probe,
207            earliest_probe,
208            proof_window,
209        })
210    }
211
212    /// Largest distance behind head at which `eth_getProof` still answers.
213    /// reth: `--rpc.eth-proof-window` (default 0 = head only). `Err` if even
214    /// the head is refused (no bootstrap possible at all).
215    pub async fn measure_proof_window(&self, head: u64) -> std::result::Result<u64, String> {
216        let mut ok: Option<u64> = None;
217        for k in [0u64, 1, 2, 4, 8, 16, 32, 64, 128, 256, 1024, 4096] {
218            if k > head {
219                break;
220            }
221            let r = self
222                .call(
223                    "eth_getProof",
224                    json!([
225                        Address::ZERO,
226                        Vec::<B256>::new(),
227                        format!("{:#x}", head - k)
228                    ]),
229                )
230                .await;
231            match r {
232                Ok(_) => ok = Some(k),
233                Err(e) => {
234                    if ok.is_none() {
235                        return Err(e.to_string());
236                    }
237                    break;
238                }
239            }
240        }
241        Ok(ok.unwrap_or(0))
242    }
243}
244
245/// Outcome of fetching one block's BAL and checking it against its header.
246#[derive(Debug, Clone)]
247pub enum BalProbe {
248    /// BAL served and `keccak(rlp(bal)) == header.blockAccessListHash`.
249    Verified {
250        /// Block number.
251        block: u64,
252        /// Accounts in the BAL.
253        accounts: usize,
254        /// The matching hash.
255        hash: B256,
256    },
257    /// BAL served but the header has no hash field (pre-fork block or client gap).
258    NoHashInHeader {
259        /// Block number.
260        block: u64,
261        /// Accounts in the BAL.
262        accounts: usize,
263    },
264    /// BAL served, hash differs: codec/spec drift. Nothing should be built on this.
265    Mismatch {
266        /// Block number.
267        block: u64,
268        /// What this codec computed.
269        computed: B256,
270        /// What the header says.
271        expected: B256,
272    },
273    /// `eth_getBlockAccessList` returned null.
274    Missing(u64),
275    /// Transport or decoding failure.
276    Error(String),
277}
278
279/// Day-0 findings for one endpoint. Printed by `balq probe`.
280#[derive(Debug, Clone)]
281pub struct ProbeReport {
282    /// `web3_clientVersion`, if served.
283    pub client_version: Option<String>,
284    /// `eth_chainId`, if served.
285    pub chain_id: Option<u64>,
286    /// Head block number at probe time.
287    pub head: u64,
288    /// Field names on the head block object (to spot renamed BAL fields).
289    pub head_fields: Vec<String>,
290    /// Q1: the head block.
291    pub head_probe: BalProbe,
292    /// Q2: a block `age` blocks back.
293    pub old_probe: BalProbe,
294    /// Q2: block 1.
295    pub earliest_probe: BalProbe,
296    /// Measured `eth_getProof` window (blocks behind head still served), or
297    /// the error if proofs are not served at all.
298    pub proof_window: std::result::Result<u64, String>,
299}
300
301fn parse_hex_u64(s: &str) -> Result<u64> {
302    let s = s.strip_prefix("0x").unwrap_or(s);
303    u64::from_str_radix(s, 16).map_err(|e| SourceError::Malformed(format!("u64 {s}: {e}")))
304}
305
306fn parse_b256(v: &Value, name: &str) -> Result<B256> {
307    let s = v
308        .as_str()
309        .ok_or_else(|| SourceError::Malformed(format!("{name}: not a string")))?;
310    s.parse::<B256>()
311        .map_err(|e| SourceError::Malformed(format!("{name}: {e}")))
312}
313
314fn field<'a>(v: &'a Value, name: &str) -> Result<&'a Value> {
315    v.get(name)
316        .ok_or_else(|| SourceError::Malformed(format!("missing field {name}")))
317}
318
319fn parse_header(v: &Value) -> Result<Header> {
320    let num = field(v, "number")?
321        .as_str()
322        .ok_or_else(|| SourceError::Malformed("number".into()))?;
323    let ts = field(v, "timestamp")?
324        .as_str()
325        .ok_or_else(|| SourceError::Malformed("timestamp".into()))?;
326    let bal_hash = v
327        .get(BAL_HASH_FIELD)
328        .filter(|x| !x.is_null())
329        .map(|x| parse_b256(x, BAL_HASH_FIELD))
330        .transpose()?;
331    Ok(Header {
332        number: parse_hex_u64(num)?,
333        hash: parse_b256(field(v, "hash")?, "hash")?,
334        parent_hash: parse_b256(field(v, "parentHash")?, "parentHash")?,
335        state_root: parse_b256(field(v, "stateRoot")?, "stateRoot")?,
336        timestamp: parse_hex_u64(ts)?,
337        block_access_list_hash: bal_hash,
338    })
339}
340
341#[async_trait]
342impl BalSource for JsonRpcSource {
343    async fn header(&self, number: u64) -> Result<Header> {
344        let v = self.raw_block(json!(format!("{number:#x}"))).await?;
345        parse_header(&v)
346    }
347
348    async fn head(&self) -> Result<u64> {
349        let v = self.call("eth_blockNumber", json!([])).await?;
350        parse_hex_u64(
351            v.as_str()
352                .ok_or_else(|| SourceError::Malformed("blockNumber".into()))?,
353        )
354    }
355
356    async fn finalized(&self) -> Result<u64> {
357        let v = self.raw_block(json!("finalized")).await?;
358        Ok(parse_header(&v)?.number)
359    }
360
361    async fn block(&self, number: u64) -> Result<SourcedBlock> {
362        let v = self.raw_block(json!(format!("{number:#x}"))).await?;
363        let header = parse_header(&v)?;
364        let bal = JsonRpcSource::bal(self, number).await?;
365        Ok(SourcedBlock { header, bal })
366    }
367
368    async fn bal(&self, number: u64) -> Result<BlockAccessList> {
369        JsonRpcSource::bal(self, number).await
370    }
371}
372
373#[derive(Deserialize)]
374#[serde(rename_all = "camelCase")]
375struct ProofResp {
376    balance: U256,
377    nonce: U256,
378    code_hash: B256,
379    storage_hash: B256,
380    account_proof: Vec<Bytes>,
381    storage_proof: Vec<StorageProofResp>,
382}
383
384#[derive(Deserialize)]
385struct StorageProofResp {
386    key: U256,
387    value: U256,
388    proof: Vec<Bytes>,
389}
390
391#[async_trait]
392impl StateSource for JsonRpcSource {
393    async fn proof(&self, addr: Address, slots: &[B256], block: u64) -> Result<AccountProof> {
394        let v = self
395            .call("eth_getProof", json!([addr, slots, format!("{block:#x}")]))
396            .await?;
397        let p: ProofResp =
398            serde_json::from_value(v).map_err(|e| SourceError::Malformed(format!("proof: {e}")))?;
399        let nonce: u64 = p
400            .nonce
401            .try_into()
402            .map_err(|_| SourceError::Malformed(format!("proof nonce {} exceeds u64", p.nonce)))?;
403        Ok(AccountProof {
404            address: addr,
405            balance: p.balance,
406            nonce,
407            code_hash: p.code_hash,
408            storage_hash: p.storage_hash,
409            account_proof: p.account_proof,
410            storage_proofs: p
411                .storage_proof
412                .into_iter()
413                .map(|s| StorageProof {
414                    key: B256::from(s.key.to_be_bytes::<32>()),
415                    value: s.value,
416                    proof: s.proof,
417                })
418                .collect(),
419        })
420    }
421}