From b0129fb6da433d12fdfbb02b7cb55e5ebd6e0f6d Mon Sep 17 00:00:00 2001 From: Hussein Adeiza Yunusa Date: Mon, 19 Oct 2026 11:33:19 +0200 Subject: [PATCH] fix(ethereum): default missing sha3Uncles for pre-Gingerbread Celo blocks Since the alloy migration (#6063), blocks from chains whose RPC omits `sha3Uncles` fail deserialization with `missing field sha3Uncles` and retry endlessly. Pre-Gingerbread Celo (mainnet < 21616000) is exactly this case: those headers have no uncles hash, so nodes omit the field. The rust-web3 fork this repo used before v0.42.0 defaulted the field; the default was not ported. Patch the response in `PatchingHttp` the same way missing `type` fields are already handled: when a block-method result lacks `sha3Uncles`, insert the canonical empty-uncles hash (keccak256(rlp([]))), 0x1dcc4de8...d49347. The patch only fires when the field is absent, so standard chains (whose RPC always includes it) are unaffected. Tests cover: field added on block methods, existing value preserved (`patch_response` returns None), and non-block methods left alone. Fixes #6738 Signed-off-by: Hussein Adeiza Yunusa --- chain/ethereum/src/transport.rs | 95 +++++++++++++++++---- graph/src/components/ethereum/json_patch.rs | 28 ++++++ 2 files changed, 107 insertions(+), 16 deletions(-) diff --git a/chain/ethereum/src/transport.rs b/chain/ethereum/src/transport.rs index 5b5414c4a5d..08f28f7e1cb 100644 --- a/chain/ethereum/src/transport.rs +++ b/chain/ethereum/src/transport.rs @@ -191,22 +191,36 @@ impl PatchingHttp { method == "eth_getTransactionReceipt" || method == "eth_getBlockReceipts" } - fn patch_rpc_response(response: &mut Value) -> bool { - response + fn patch_rpc_response(method: Option<&str>, response: &mut Value) -> bool { + let mut patched = response + .get_mut("result") + .map(|result| { + // Celo pre-Gingerbread blocks omit `sha3Uncles`; alloy needs it. + if let Some(m) = method + && json_patch::is_block_method(m) + && let Some(result_obj) = result.as_object_mut() + { + return json_patch::patch_uncles_hash(result_obj); + } + false + }) + .unwrap_or(false); + patched |= response .get_mut("result") .map(json_patch::patch_receipts) - .unwrap_or(false) + .unwrap_or(false); + patched } - fn patch_response(body: &[u8]) -> Option> { + fn patch_response(method: Option<&str>, body: &[u8]) -> Option> { let mut json: Value = serde_json::from_slice(body).ok()?; let patched = match &mut json { - Value::Object(_) => Self::patch_rpc_response(&mut json), + Value::Object(_) => Self::patch_rpc_response(method, &mut json), Value::Array(batch) => { let mut patched = false; for r in batch { - patched |= Self::patch_rpc_response(r); + patched |= Self::patch_rpc_response(method, r); } patched } @@ -235,15 +249,32 @@ impl Service for PatchingHttp { let url = self.url.clone(); let no_eip2718 = self.no_eip2718; - let should_patch = if no_eip2718 { + // Receipt patching is gated on `no_eip2718` (only chains without typed + // transactions need it). Block patching (missing `sha3Uncles` on + // pre-Gingerbread Celo) runs unconditionally: it only fires when the + // field is absent, which standard chains never hit. + let (patch_receipts, is_block_call) = match &request { + RequestPacket::Single(req) => ( + no_eip2718 && Self::is_receipt_method(req.method()), + json_patch::is_block_method(req.method()), + ), + RequestPacket::Batch(reqs) => ( + no_eip2718 && reqs.iter().any(|r| Self::is_receipt_method(r.method())), + reqs.iter().any(|r| json_patch::is_block_method(r.method())), + ), + }; + // `patch_method` borrows `request`; convert to owned so it can move into + // the async block. + let patch_method: Option = if is_block_call { match &request { - RequestPacket::Single(req) => Self::is_receipt_method(req.method()), - RequestPacket::Batch(reqs) => { - reqs.iter().any(|r| Self::is_receipt_method(r.method())) - } + RequestPacket::Single(req) => Some(req.method().to_string()), + RequestPacket::Batch(reqs) => reqs + .iter() + .find(|r| json_patch::is_block_method(r.method())) + .map(|r| r.method().to_string()), } } else { - false + None }; Box::pin(async move { @@ -265,7 +296,10 @@ impl Service for PatchingHttp { )); } - if should_patch && let Some(patched) = Self::patch_response(&body) { + let should_patch = patch_receipts || patch_method.is_some(); + if should_patch + && let Some(patched) = Self::patch_response(patch_method.as_deref(), &body) + { return serde_json::from_slice(&patched).map_err(|err| { TransportError::deser_err(err, String::from_utf8_lossy(&patched)) }); @@ -283,7 +317,7 @@ mod tests { #[test] fn patch_response_single() { let body = br#"{"jsonrpc":"2.0","id":1,"result":{"status":"0x1"}}"#; - let patched = PatchingHttp::patch_response(body).unwrap(); + let patched = PatchingHttp::patch_response(None, body).unwrap(); let json: Value = serde_json::from_slice(&patched).unwrap(); assert_eq!(json["result"]["type"], "0x0"); } @@ -291,15 +325,44 @@ mod tests { #[test] fn patch_response_returns_none_when_type_exists() { let body = br#"{"jsonrpc":"2.0","id":1,"result":{"status":"0x1","type":"0x2"}}"#; - assert!(PatchingHttp::patch_response(body).is_none()); + assert!(PatchingHttp::patch_response(None, body).is_none()); } #[test] fn patch_response_batch() { let body = br#"[{"jsonrpc":"2.0","id":1,"result":{"status":"0x1"}},{"jsonrpc":"2.0","id":2,"result":{"status":"0x1"}}]"#; - let patched = PatchingHttp::patch_response(body).unwrap(); + let patched = PatchingHttp::patch_response(None, body).unwrap(); let json: Value = serde_json::from_slice(&patched).unwrap(); assert_eq!(json[0]["result"]["type"], "0x0"); assert_eq!(json[1]["result"]["type"], "0x0"); } + + #[test] + fn patch_response_block_adds_missing_sha3_uncles() { + // Pre-Gingerbread Celo block: no `sha3Uncles` in the header. + let body = br#"{"jsonrpc":"2.0","id":1,"result":{"number":"0x1","parentHash":"0xaa..bb","timestamp":"0x0"}}"#; + let patched = PatchingHttp::patch_response(Some("eth_getBlockByNumber"), body).unwrap(); + let json: Value = serde_json::from_slice(&patched).unwrap(); + assert_eq!( + json["result"]["sha3Uncles"], + "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + ); + } + + #[test] + fn patch_response_block_keeps_existing_sha3_uncles() { + // `type` present so the receipt patch is a no-op; only the uncles patch + // could fire, and the field already exists. + let body = + br#"{"jsonrpc":"2.0","id":1,"result":{"number":"0x1","type":"0x2","sha3Uncles":"0x1234"}}"#; + assert!(PatchingHttp::patch_response(Some("eth_getBlockByNumber"), body).is_none()); + } + + #[test] + fn patch_response_non_block_method_leaves_block_alone() { + // A result without sha3Uncles from a non-block method is not patched by + // the uncles rule (the receipt patch may still add `type`). + let body = br#"{"jsonrpc":"2.0","id":1,"result":{"number":"0x1","type":"0x2"}}"#; + assert!(PatchingHttp::patch_response(Some("eth_getLogs"), body).is_none()); + } } diff --git a/graph/src/components/ethereum/json_patch.rs b/graph/src/components/ethereum/json_patch.rs index 13859c8f4a7..1a3657bc8da 100644 --- a/graph/src/components/ethereum/json_patch.rs +++ b/graph/src/components/ethereum/json_patch.rs @@ -6,9 +6,37 @@ //! transaction) where missing. //! //! Also used by `PatchingHttp` for chains that don't support EIP-2718 typed transactions. +//! +//! Celo blocks from before the Gingerbread hard fork have no uncles hash in +//! their header, so RPC nodes omit `sha3Uncles` entirely and alloy rejects the +//! block with a missing-field error. Where the field is absent, patch in the +//! canonical empty-uncles hash (keccak256 of the RLP of an empty list), which +//! is what those blocks' headers commit to. use serde_json::Value; +/// keccak256(rlp([])) — the uncles hash of a block with no uncles. +const EMPTY_UNCLES_HASH: &str = + "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347"; + +/// Methods whose result is a block, or a map carrying block-like fields. +pub fn is_block_method(method: &str) -> bool { + method == "eth_getBlockByHash" + || method == "eth_getBlockByNumber" + || method.starts_with("eth_getBlockByNumberAndRange") +} + +pub fn patch_uncles_hash(block: &mut serde_json::Map) -> bool { + if !block.contains_key("sha3Uncles") { + block.insert( + "sha3Uncles".to_string(), + Value::String(EMPTY_UNCLES_HASH.to_string()), + ); + return true; + } + false +} + pub fn patch_type_field(obj: &mut Value) -> bool { if let Value::Object(map) = obj && !map.contains_key("type")