diff --git a/chain/ethereum/src/ethereum_adapter.rs b/chain/ethereum/src/ethereum_adapter.rs index 9c85f0ac551..f138eaad2e6 100644 --- a/chain/ethereum/src/ethereum_adapter.rs +++ b/chain/ethereum/src/ethereum_adapter.rs @@ -38,21 +38,23 @@ use graph::prelude::{ TransactionInput, TransactionRequest, trace::{filter::TraceFilter as AlloyTraceFilter, parity::LocalizedTransactionTrace}, }, - transports::{RpcError, TransportErrorKind}, + transports::RpcError, }, tokio::try_join, }; +use graph::components::ethereum::json_patch; use graph::slog::o; use graph::{ blockchain::{BlockPtr, IngestorError, block_stream::BlockWithTriggers}, prelude::{ BlockNumber, ChainStore, CheapClone, DynTryFuture, Error, EthereumCallCache, Logger, - TimeoutError, + TimeoutError, serde_json, anyhow::{self, Context, anyhow, bail, ensure}, debug, error, hex, info, retry, trace, warn, }, }; use itertools::Itertools; +use serde_json::Value; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::convert::TryFrom; use std::iter::FromIterator; @@ -256,7 +258,23 @@ impl EthereumAdapter { let alloy_trace_filter = Self::build_trace_filter(from, to, &addresses); let start = Instant::now(); - let result = self.alloy.trace_filter(&alloy_trace_filter).await; + let result = match self.alloy.trace_filter(&alloy_trace_filter).await { + Ok(traces) => Ok(traces), + Err(RpcError::DeserError { text, err }) + if err + .to_string() + .contains(json_patch::MISSING_TRACE_OUTPUT_ERROR) => + { + warn!( + &logger, + "trace_filter returned traces with missing result.output; applying compatibility patch"; + "from" => from, + "to" => to, + ); + Self::deserialize_trace_filter_response_with_patch(text) + } + Err(error) => Err(Error::from(error)), + }; if let Ok(traces) = &result { self.log_trace_results(&logger, from, to, traces.len()); @@ -271,7 +289,7 @@ impl EthereumAdapter { &logger, ); - result.map_err(Error::from) + result } fn build_trace_filter( @@ -313,7 +331,7 @@ impl EthereumAdapter { &self, subgraph_metrics: &Arc, elapsed: f64, - result: &Result, RpcError>, + result: &Result, Error>, from: BlockNumber, to: BlockNumber, logger: &ProviderLogger, @@ -332,6 +350,15 @@ impl EthereumAdapter { } } + fn deserialize_trace_filter_response_with_patch( + response_text: String, + ) -> Result, Error> { + // TODO: remove after alloy-rs/alloy#3931 is released and adopted. + let mut raw_traces: Value = serde_json::from_str(&response_text).map_err(Error::from)?; + json_patch::patch_missing_trace_output(&mut raw_traces); + serde_json::from_value(raw_traces).map_err(Error::from) + } + // This is a lazy check for block receipt support. It is only called once and then the result is // cached. The result is not used for anything critical, so it is fine to be lazy. async fn check_block_receipt_support_and_update_cache( @@ -2697,8 +2724,9 @@ mod tests { block_trigger_types_from_intervals, check_block_receipt_support, parse_block_triggers, }; use graph::blockchain::BlockPtr; - use graph::components::ethereum::AnyNetworkBare; + use graph::components::ethereum::{AnyNetworkBare, json_patch}; use graph::prelude::alloy::primitives::{Address, B256, Bytes}; + use graph::prelude::alloy::providers::ext::TraceApi; use graph::prelude::alloy::providers::ProviderBuilder; use graph::prelude::alloy::providers::mock::Asserter; use graph::prelude::{EthereumCall, LightEthereumBlock, create_minimal_block_for_test}; @@ -2850,6 +2878,56 @@ mod tests { .unwrap(); } + #[graph::test] + async fn missing_output_trace_repro() { + let trace_filter_response = r#"[{ + "action": { + "from": "0xf7cf0d9398d06d5cb7e4d37dc1e18a829bfff934", + "value": "0x0", + "gas": "0x0", + "init": "0x", + "address": "0xf7cf0d9398d06d5cb7e4d37dc1e18a829bfff934", + "refundAddress": "0x4c3ccc98c01103be72bcfd29e1d2454c98d1a6e3", + "balance": "0x0" + }, + "blockHash": "0x6b747793a61c3ce4e5f3355cf80edcb6aa465913ed43f4b0136d93803cf330f3", + "blockNumber": 66762070, + "result": { + "gasUsed": "0x0" + }, + "subtraces": 0, + "traceAddress": [1, 1], + "transactionHash": "0x5b3dc50c4c7bd9b0e80469b21febbc5d1b54b364a01b22b1e9c426e4632e0b8f", + "transactionPosition": 0, + "type": "suicide" + }]"#; + + let json_value: Value = serde_json::from_str(trace_filter_response).unwrap(); + let asserter = Asserter::new(); + let provider = ProviderBuilder::<_, _, AnyNetworkBare>::default() + .network::() + .connect_mocked_client(asserter.clone()); + + asserter.push_success(&json_value); + + let err = provider + .trace_filter( + &graph::prelude::alloy::rpc::types::trace::filter::TraceFilter::default(), + ) + .await + .expect_err("trace_filter should fail to deserialize when result.output is missing"); + + assert!( + err.to_string() + .contains(json_patch::MISSING_TRACE_OUTPUT_ERROR), + "unexpected error: {err:#}" + ); + + let mut patched: Value = serde_json::from_str(trace_filter_response).unwrap(); + json_patch::patch_missing_trace_output(&mut patched); + assert_eq!(patched[0]["result"]["output"], Value::String("0x".to_string())); + } + #[test] fn parse_block_triggers_specific_call_not_found() { let block = create_minimal_block_for_test(2, hash(2)); diff --git a/graph/src/components/ethereum/json_patch.rs b/graph/src/components/ethereum/json_patch.rs index 13859c8f4a7..f6bf6d45273 100644 --- a/graph/src/components/ethereum/json_patch.rs +++ b/graph/src/components/ethereum/json_patch.rs @@ -9,6 +9,9 @@ use serde_json::Value; +pub const MISSING_TRACE_OUTPUT_ERROR: &str = + "data did not match any variant of untagged enum TraceOutput"; + pub fn patch_type_field(obj: &mut Value) -> bool { if let Value::Object(map) = obj && !map.contains_key("type") @@ -44,9 +47,33 @@ pub fn patch_receipts(result: &mut Value) -> bool { } } +pub fn patch_missing_trace_output(raw_traces: &mut Value) -> bool { + let Some(traces) = raw_traces.as_array_mut() else { + return false; + }; + + let mut patched = false; + for trace in traces { + let Some(result) = trace.get_mut("result") else { + continue; + }; + let Some(result_obj) = result.as_object_mut() else { + continue; + }; + + if result_obj.contains_key("gasUsed") && !result_obj.contains_key("output") { + result_obj.insert("output".to_owned(), Value::String("0x".to_owned())); + patched = true; + } + } + + patched +} + #[cfg(test)] mod tests { use super::*; + use alloy::rpc::types::trace::parity::LocalizedTransactionTrace; use serde_json::json; #[test] @@ -120,4 +147,77 @@ mod tests { let mut val = Value::Null; assert!(!patch_receipts(&mut val)); } + + #[test] + fn patch_missing_trace_output_adds_missing_output() { + let mut traces = json!([{ + "result": { + "gasUsed": "0x0" + } + }]); + assert!(patch_missing_trace_output(&mut traces)); + assert_eq!(traces[0]["result"]["output"], "0x"); + } + + #[test] + fn patch_missing_trace_output_preserves_existing_output() { + let mut traces = json!([{ + "result": { + "gasUsed": "0x1", + "output": "0xabc" + } + }]); + assert!(!patch_missing_trace_output(&mut traces)); + assert_eq!(traces[0]["result"]["output"], "0xabc"); + } + + #[test] + fn patch_missing_trace_output_create_without_result_output() { + let mut traces = json!([{ + "result": { + "code": "0x60016000" + } + }]); + assert!(!patch_missing_trace_output(&mut traces)); + assert_eq!(traces[0]["result"]["code"], "0x60016000"); + } + + #[test] + fn patch_missing_trace_output_handles_missing_result() { + let mut traces = json!([{ + "action": { + "to": "0x0000000000000000000000000000000000000000" + } + }]); + assert!(!patch_missing_trace_output(&mut traces)); + } + + #[test] + fn patch_missing_trace_output_deserializes_sonic_fixture() { + let mut traces = json!([{ + "action": { + "from": "0xf7cf0d9398d06d5cb7e4d37dc1e18a829bfff934", + "value": "0x0", + "gas": "0x0", + "init": "0x", + "address": "0xf7cf0d9398d06d5cb7e4d37dc1e18a829bfff934", + "refundAddress": "0x4c3ccc98c01103be72bcfd29e1d2454c98d1a6e3", + "balance": "0x0" + }, + "blockHash": "0x6b747793a61c3ce4e5f3355cf80edcb6aa465913ed43f4b0136d93803cf330f3", + "blockNumber": 66762070, + "result": { + "gasUsed": "0x0" + }, + "subtraces": 0, + "traceAddress": [1, 1], + "transactionHash": "0x5b3dc50c4c7bd9b0e80469b21febbc5d1b54b364a01b22b1e9c426e4632e0b8f", + "transactionPosition": 0, + "type": "suicide" + }]); + + assert!(patch_missing_trace_output(&mut traces)); + let decoded: Vec = serde_json::from_value(traces).unwrap(); + assert_eq!(decoded.len(), 1); + } }