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