Skip to content

Commit 3ca739f

Browse files
authored
Fix block cache deserialization for old ethereum blocks (#6330)
* chain/ethereum: add json_patch module for unified type field patching Add a dedicated module for JSON patching utilities that handle missing `type` fields in Ethereum transactions and receipts. This consolidates patching logic that will be used by both the HTTP transport layer (for RPC responses) and cache deserialization (for stored blocks). The module provides: - patch_type_field: Adds "type": "0x0" to JSON objects missing the field - patch_block_transactions: Patches all transactions in a block - patch_receipts: Patches single receipts or arrays of receipts * chain/ethereum: refactor PatchingHttp to use json_patch module Refactor the HTTP transport's receipt patching to use the shared json_patch module instead of duplicating the patching logic. This removes the patch_receipt and patch_result methods from PatchingHttp and replaces them with calls to json_patch::patch_receipts. The patch_rpc_response and patch_response methods remain as they handle RPC-specific JSON-RPC response structure. * chain/ethereum: patch missing type field in cached blocks Add patching for cached blocks before deserialization to handle blocks that were cached before March 2022 when graph-node's rust-web3 fork didn't capture the transaction type field. This patches transactions and receipts in cached blocks at two locations: - ancestor_block(): For full block deserialization (EthereumBlock), patches both transactions and transaction_receipts - parent_ptr(): For light block deserialization (LightEthereumBlock), patches only transactions (light blocks don't include receipts) The patching adds type: 0x0 (legacy) to transactions/receipts missing the field, allowing alloy to deserialize blocks that would otherwise fail due to the missing required field. * chain/ethereum: add EthereumJsonBlock newtype for cached block handling Introduce EthereumJsonBlock newtype with helper methods for format detection (is_shallow, is_legacy_format) and deserialization (into_full_block, into_light_block). Cleans up repeated logic and avoids unnecessary clones by taking ownership of the JSON data. * chain/ethereum: use concrete types in EthereumJsonBlock methods Remove generic type parameters from into_full_block() and into_light_block(), returning EthereumBlock and LightEthereumBlock directly instead. * chain/ethereum: add doc comments to EthereumJsonBlock methods * chain/ethereum: remove unused From<Value> impl for EthereumJsonBlock
1 parent 6012d77 commit 3ca739f

6 files changed

Lines changed: 234 additions & 78 deletions

File tree

chain/ethereum/src/chain.rs

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ use crate::codec::HeaderOnlyBlock;
4646
use crate::data_source::DataSourceTemplate;
4747
use crate::data_source::UnresolvedDataSourceTemplate;
4848
use crate::ingestor::PollingBlockIngestor;
49+
use crate::json_block::EthereumJsonBlock;
4950
use crate::network::EthereumNetworkAdapters;
5051
use crate::polling_block_stream::PollingBlockStream;
5152
use crate::runtime::runtime_adapter::eth_call_gas;
@@ -1076,22 +1077,32 @@ impl TriggersAdapterTrait<Chain> for TriggersAdapter {
10761077
.await?;
10771078

10781079
// First check if we have the ancestor in cache and can deserialize it.
1079-
// recent_blocks_cache can have full format {"block": {...}, "transaction_receipts": [...]}
1080-
// or light format (just block fields). We need full format with receipts for
1081-
// ancestor_block since it's used for trigger processing.
1080+
// The cached JSON can be in one of three formats:
1081+
// 1. Full RPC format: {"block": {...}, "transaction_receipts": [...]}
1082+
// 2. Shallow/header-only: {"timestamp": "...", "data": null} - only timestamp, no block data
1083+
// 3. Legacy direct: block fields at root level {hash, number, transactions, ...}
1084+
// We need full format with receipts for ancestor_block (used for trigger processing).
10821085
let block_ptr = match cached {
10831086
Some((json, ptr)) => {
1084-
if json.get("block").is_none() {
1085-
warn!(
1087+
let json_block = EthereumJsonBlock::new(json);
1088+
if json_block.is_shallow() {
1089+
trace!(
1090+
self.logger,
1091+
"Cached block #{} {} is shallow (header-only). Falling back to Firehose/RPC.",
1092+
ptr.number,
1093+
ptr.hash_hex(),
1094+
);
1095+
ptr
1096+
} else if json_block.is_legacy_format() {
1097+
trace!(
10861098
self.logger,
1087-
"Cached ancestor block #{} {} has light format without receipts. \
1088-
Falling back to Firehose/RPC.",
1099+
"Cached block #{} {} is legacy light format. Falling back to Firehose/RPC.",
10891100
ptr.number,
10901101
ptr.hash_hex(),
10911102
);
10921103
ptr
10931104
} else {
1094-
match json::from_value::<EthereumBlock>(json.clone()) {
1105+
match json_block.into_full_block() {
10951106
Ok(block) => {
10961107
return Ok(Some(BlockFinality::NonFinal(EthereumBlockWithCalls {
10971108
ethereum_block: block,
@@ -1169,24 +1180,32 @@ impl TriggersAdapterTrait<Chain> for TriggersAdapter {
11691180
ChainClient::Firehose(endpoints) => {
11701181
let chain_store = self.chain_store.cheap_clone();
11711182
// First try to get the block from the store
1183+
// See ancestor_block() for documentation of the 3 cached JSON formats.
11721184
if let Ok(blocks) = chain_store.blocks(vec![block.hash.clone()]).await {
1173-
if let Some(cached_json) = blocks.first() {
1174-
// recent_blocks_cache can contain full format {"block": {...}, "transaction_receipts": [...]}
1175-
// or light format (just block fields). Extract block data for deserialization.
1176-
let inner = cached_json.get("block").unwrap_or(cached_json);
1177-
match json::from_value::<LightEthereumBlock>(inner.clone()) {
1178-
Ok(light_block) => {
1179-
return Ok(light_block.parent_ptr());
1180-
}
1181-
Err(e) => {
1182-
warn!(
1183-
self.logger,
1184-
"Failed to deserialize cached block #{} {}: {}. \
1185-
Falling back to Firehose.",
1186-
block.number,
1187-
block.hash_hex(),
1188-
e
1189-
);
1185+
if let Some(cached_json) = blocks.into_iter().next() {
1186+
let json_block = EthereumJsonBlock::new(cached_json);
1187+
if json_block.is_shallow() {
1188+
trace!(
1189+
self.logger,
1190+
"Cached block #{} {} is shallow. Falling back to Firehose.",
1191+
block.number,
1192+
block.hash_hex(),
1193+
);
1194+
} else {
1195+
match json_block.into_light_block() {
1196+
Ok(light_block) => {
1197+
return Ok(light_block.parent_ptr());
1198+
}
1199+
Err(e) => {
1200+
warn!(
1201+
self.logger,
1202+
"Failed to deserialize cached block #{} {}: {}. \
1203+
Falling back to Firehose.",
1204+
block.number,
1205+
block.hash_hex(),
1206+
e
1207+
);
1208+
}
11901209
}
11911210
}
11921211
}

chain/ethereum/src/ethereum_adapter.rs

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ use graph::{
4747
blockchain::{block_stream::BlockWithTriggers, BlockPtr, IngestorError},
4848
prelude::{
4949
anyhow::{self, anyhow, bail, ensure, Context},
50-
debug, error, hex, info, retry, serde_json as json, trace, warn, BlockNumber, ChainStore,
51-
CheapClone, DynTryFuture, Error, EthereumCallCache, Logger, TimeoutError,
50+
debug, error, hex, info, retry, trace, warn, BlockNumber, ChainStore, CheapClone,
51+
DynTryFuture, Error, EthereumCallCache, Logger, TimeoutError,
5252
},
5353
};
5454
use itertools::Itertools;
@@ -66,6 +66,7 @@ use crate::adapter::EthereumRpcError;
6666
use crate::adapter::ProviderStatus;
6767
use crate::call_helper::interpret_eth_call_error;
6868
use crate::chain::BlockFinality;
69+
use crate::json_block::EthereumJsonBlock;
6970
use crate::trigger::{LogPosition, LogRef};
7071
use crate::Chain;
7172
use crate::NodeCapabilities;
@@ -1641,25 +1642,22 @@ impl EthereumAdapterTrait for EthereumAdapter {
16411642
.unwrap_or_default()
16421643
.into_iter()
16431644
.filter_map(|value| {
1644-
// recent_blocks_cache can contain full format {"block": {...}, "transaction_receipts": [...]}
1645-
// or light format (just block fields). Extract block data for deserialization.
1646-
let inner = value.get("block").unwrap_or(&value);
1647-
json::from_value(inner.clone())
1645+
let json_block = EthereumJsonBlock::new(value);
1646+
if json_block.is_shallow() {
1647+
return None;
1648+
}
1649+
json_block
1650+
.into_light_block()
16481651
.map_err(|e| {
1649-
let block_num = inner.get("number").and_then(|n| n.as_str());
1650-
let block_hash = inner.get("hash").and_then(|h| h.as_str());
16511652
warn!(
16521653
&logger,
1653-
"Failed to deserialize cached block #{:?} {:?}: {}. \
1654-
Block will be re-fetched from RPC.",
1655-
block_num,
1656-
block_hash,
1654+
"Failed to deserialize cached block: {}. Block will be re-fetched from RPC.",
16571655
e
16581656
);
16591657
})
16601658
.ok()
16611659
})
1662-
.map(|b| Arc::new(LightEthereumBlock::new(b)))
1660+
.map(Arc::new)
16631661
.collect();
16641662

16651663
let missing_blocks = Vec::from_iter(

chain/ethereum/src/json_block.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
use graph::prelude::serde_json::{self as json, Value};
2+
use graph::prelude::{EthereumBlock, LightEthereumBlock};
3+
4+
use crate::json_patch;
5+
6+
#[derive(Debug)]
7+
pub struct EthereumJsonBlock(Value);
8+
9+
impl EthereumJsonBlock {
10+
pub fn new(value: Value) -> Self {
11+
Self(value)
12+
}
13+
14+
/// Returns true if this is a shallow/header-only block (no full block data).
15+
pub fn is_shallow(&self) -> bool {
16+
self.0.get("data") == Some(&Value::Null)
17+
}
18+
19+
/// Returns true if this block is in the legacy format (direct block JSON
20+
/// rather than wrapped in a `block` field).
21+
pub fn is_legacy_format(&self) -> bool {
22+
self.0.get("block").is_none()
23+
}
24+
25+
/// Patches missing `type` fields in transactions and receipts.
26+
/// Required for alloy compatibility with cached blocks from older graph-node versions.
27+
pub fn patch(&mut self) {
28+
if let Some(block) = self.0.get_mut("block") {
29+
json_patch::patch_block_transactions(block);
30+
}
31+
if let Some(receipts) = self.0.get_mut("transaction_receipts") {
32+
json_patch::patch_receipts(receipts);
33+
}
34+
}
35+
36+
/// Patches and deserializes into a full `EthereumBlock` with receipts.
37+
pub fn into_full_block(mut self) -> Result<EthereumBlock, json::Error> {
38+
self.patch();
39+
json::from_value(self.0)
40+
}
41+
42+
/// Extracts and patches the inner block, deserializing into a `LightEthereumBlock`.
43+
pub fn into_light_block(mut self) -> Result<LightEthereumBlock, json::Error> {
44+
let mut inner = self
45+
.0
46+
.as_object_mut()
47+
.and_then(|obj| obj.remove("block"))
48+
.unwrap_or(self.0);
49+
json_patch::patch_block_transactions(&mut inner);
50+
json::from_value(inner)
51+
}
52+
}

chain/ethereum/src/json_patch.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
//! JSON patching utilities for Ethereum blocks and receipts.
2+
//!
3+
//! Some cached blocks are missing the transaction `type` field because
4+
//! graph-node's rust-web3 fork didn't capture it. Alloy requires this field for
5+
//! deserialization. These utilities patch the JSON to add `type: "0x0"` (legacy
6+
//! transaction) where missing.
7+
//!
8+
//! Also used by `PatchingHttp` for chains that don't support EIP-2718 typed transactions.
9+
10+
use graph::prelude::serde_json::Value;
11+
12+
pub(crate) fn patch_type_field(obj: &mut Value) -> bool {
13+
if let Value::Object(map) = obj {
14+
if !map.contains_key("type") {
15+
map.insert("type".to_string(), Value::String("0x0".to_string()));
16+
return true;
17+
}
18+
}
19+
false
20+
}
21+
22+
pub(crate) fn patch_block_transactions(block: &mut Value) -> bool {
23+
let Some(txs) = block.get_mut("transactions").and_then(|t| t.as_array_mut()) else {
24+
return false;
25+
};
26+
let mut patched = false;
27+
for tx in txs {
28+
patched |= patch_type_field(tx);
29+
}
30+
patched
31+
}
32+
33+
pub(crate) fn patch_receipts(result: &mut Value) -> bool {
34+
match result {
35+
Value::Object(_) => patch_type_field(result),
36+
Value::Array(arr) => {
37+
let mut patched = false;
38+
for r in arr {
39+
patched |= patch_type_field(r);
40+
}
41+
patched
42+
}
43+
_ => false,
44+
}
45+
}
46+
47+
#[cfg(test)]
48+
mod tests {
49+
use super::*;
50+
use graph::prelude::serde_json::json;
51+
52+
#[test]
53+
fn patch_type_field_adds_missing_type() {
54+
let mut obj = json!({"status": "0x1", "gasUsed": "0x5208"});
55+
assert!(patch_type_field(&mut obj));
56+
assert_eq!(obj["type"], "0x0");
57+
}
58+
59+
#[test]
60+
fn patch_type_field_preserves_existing_type() {
61+
let mut obj = json!({"status": "0x1", "type": "0x2"});
62+
assert!(!patch_type_field(&mut obj));
63+
assert_eq!(obj["type"], "0x2");
64+
}
65+
66+
#[test]
67+
fn patch_type_field_handles_non_object() {
68+
let mut val = json!("not an object");
69+
assert!(!patch_type_field(&mut val));
70+
}
71+
72+
#[test]
73+
fn patch_block_transactions_patches_all() {
74+
let mut block = json!({
75+
"hash": "0x123",
76+
"transactions": [
77+
{"hash": "0xabc", "nonce": "0x1"},
78+
{"hash": "0xdef", "nonce": "0x2", "type": "0x2"},
79+
{"hash": "0xghi", "nonce": "0x3"}
80+
]
81+
});
82+
assert!(patch_block_transactions(&mut block));
83+
assert_eq!(block["transactions"][0]["type"], "0x0");
84+
assert_eq!(block["transactions"][1]["type"], "0x2");
85+
assert_eq!(block["transactions"][2]["type"], "0x0");
86+
}
87+
88+
#[test]
89+
fn patch_block_transactions_handles_empty() {
90+
let mut block = json!({"hash": "0x123", "transactions": []});
91+
assert!(!patch_block_transactions(&mut block));
92+
}
93+
94+
#[test]
95+
fn patch_block_transactions_handles_missing_field() {
96+
let mut block = json!({"hash": "0x123"});
97+
assert!(!patch_block_transactions(&mut block));
98+
}
99+
100+
#[test]
101+
fn patch_receipts_single() {
102+
let mut receipt = json!({"status": "0x1"});
103+
assert!(patch_receipts(&mut receipt));
104+
assert_eq!(receipt["type"], "0x0");
105+
}
106+
107+
#[test]
108+
fn patch_receipts_array() {
109+
let mut receipts = json!([
110+
{"status": "0x1"},
111+
{"status": "0x1", "type": "0x2"}
112+
]);
113+
assert!(patch_receipts(&mut receipts));
114+
assert_eq!(receipts[0]["type"], "0x0");
115+
assert_eq!(receipts[1]["type"], "0x2");
116+
}
117+
118+
#[test]
119+
fn patch_receipts_handles_null() {
120+
let mut val = Value::Null;
121+
assert!(!patch_receipts(&mut val));
122+
}
123+
}

chain/ethereum/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ mod data_source;
77
mod env;
88
mod ethereum_adapter;
99
mod ingestor;
10+
mod json_block;
11+
mod json_patch;
1012
mod polling_block_stream;
1113
pub mod runtime;
1214
mod transport;

0 commit comments

Comments
 (0)