diff --git a/chain/ethereum/src/network.rs b/chain/ethereum/src/network.rs index 536f7a8a54d..882720e55f1 100644 --- a/chain/ethereum/src/network.rs +++ b/chain/ethereum/src/network.rs @@ -394,6 +394,7 @@ mod tests { HeaderMap::new(), metrics.clone(), "", + false, ); let provider_metrics = Arc::new(ProviderEthRpcMetrics::new(mock_registry.clone())); @@ -497,6 +498,7 @@ mod tests { HeaderMap::new(), metrics.clone(), "", + false, ); let provider_metrics = Arc::new(ProviderEthRpcMetrics::new(mock_registry.clone())); @@ -568,6 +570,7 @@ mod tests { HeaderMap::new(), metrics.clone(), "", + false, ); let provider_metrics = Arc::new(ProviderEthRpcMetrics::new(mock_registry.clone())); @@ -632,6 +635,7 @@ mod tests { HeaderMap::new(), metrics.clone(), "", + false, ); let provider_metrics = Arc::new(ProviderEthRpcMetrics::new(mock_registry.clone())); @@ -919,6 +923,7 @@ mod tests { HeaderMap::new(), endpoint_metrics.clone(), "", + false, ); Arc::new( diff --git a/chain/ethereum/src/transport.rs b/chain/ethereum/src/transport.rs index d5fac8523bb..4923e9c7b82 100644 --- a/chain/ethereum/src/transport.rs +++ b/chain/ethereum/src/transport.rs @@ -1,16 +1,15 @@ +use alloy::transports::{TransportError, TransportErrorKind, TransportFut}; use graph::components::network_provider::ProviderName; use graph::endpoint::{ConnectionType, EndpointMetrics, RequestLabels}; use graph::prelude::alloy::rpc::json_rpc::{RequestPacket, ResponsePacket}; +use graph::prelude::alloy::transports::{ipc::IpcConnect, ws::WsConnect}; use graph::prelude::*; use graph::url::Url; +use serde_json::Value; use std::sync::Arc; use std::task::{Context, Poll}; use tower::Service; -use alloy::transports::{TransportError, TransportFut}; - -use graph::prelude::alloy::transports::{http::Http, ipc::IpcConnect, ws::WsConnect}; - /// Abstraction over different transport types for Alloy providers. #[derive(Clone, Debug)] pub enum Transport { @@ -41,19 +40,24 @@ impl Transport { } /// Creates a JSON-RPC over HTTP transport. + /// + /// Set `no_eip2718` to true for chains that don't return the `type` field + /// in transaction receipts (pre-EIP-2718 chains). Use provider feature `no_eip2718`. pub fn new_rpc( rpc: Url, headers: graph::http::HeaderMap, metrics: Arc, provider: impl AsRef, + no_eip2718: bool, ) -> Self { let client = reqwest::Client::builder() .default_headers(headers) .build() .expect("Failed to build HTTP client"); - let http_transport = Http::with_client(client, rpc); - let metrics_transport = MetricsHttp::new(http_transport, metrics, provider.as_ref().into()); + let patching_transport = PatchingHttp::new(client, rpc, no_eip2718); + let metrics_transport = + MetricsHttp::new(patching_transport, metrics, provider.as_ref().into()); let rpc_client = alloy::rpc::client::RpcClient::new(metrics_transport, false); Transport::RPC(rpc_client) @@ -63,17 +67,13 @@ impl Transport { /// Custom HTTP transport wrapper that collects metrics #[derive(Clone)] pub struct MetricsHttp { - inner: Http, + inner: PatchingHttp, metrics: Arc, provider: ProviderName, } impl MetricsHttp { - pub fn new( - inner: Http, - metrics: Arc, - provider: ProviderName, - ) -> Self { + pub fn new(inner: PatchingHttp, metrics: Arc, provider: ProviderName) -> Self { Self { inner, metrics, @@ -125,3 +125,179 @@ impl Service for MetricsHttp { }) } } + +/// HTTP transport that patches receipts for chains that don't support EIP-2718 (typed transactions). +/// When `no_eip2718` is set, adds missing `type` field to receipts. +#[derive(Clone)] +pub struct PatchingHttp { + client: reqwest::Client, + url: Url, + no_eip2718: bool, +} + +impl PatchingHttp { + pub fn new(client: reqwest::Client, url: Url, no_eip2718: bool) -> Self { + Self { + client, + url, + no_eip2718, + } + } + + fn is_receipt_method(method: &str) -> bool { + method == "eth_getTransactionReceipt" || method == "eth_getBlockReceipts" + } + + fn patch_receipt(receipt: &mut Value) -> bool { + if let Value::Object(obj) = receipt { + if !obj.contains_key("type") { + obj.insert("type".to_string(), Value::String("0x0".to_string())); + return true; + } + } + false + } + + fn patch_result(result: &mut Value) -> bool { + match result { + Value::Object(_) => Self::patch_receipt(result), + Value::Array(arr) => { + let mut patched = false; + for r in arr { + patched |= Self::patch_receipt(r); + } + patched + } + _ => false, + } + } + + fn patch_rpc_response(response: &mut Value) -> bool { + response + .get_mut("result") + .map(Self::patch_result) + .unwrap_or(false) + } + + fn patch_response(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::Array(batch) => { + let mut patched = false; + for r in batch { + patched |= Self::patch_rpc_response(r); + } + patched + } + _ => false, + }; + + if patched { + serde_json::to_vec(&json).ok() + } else { + None + } + } +} + +impl Service for PatchingHttp { + type Response = ResponsePacket; + type Error = TransportError; + type Future = TransportFut<'static>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, request: RequestPacket) -> Self::Future { + let client = self.client.clone(); + let url = self.url.clone(); + let no_eip2718 = self.no_eip2718; + + let should_patch = if no_eip2718 { + match &request { + RequestPacket::Single(req) => Self::is_receipt_method(req.method()), + RequestPacket::Batch(reqs) => { + reqs.iter().any(|r| Self::is_receipt_method(r.method())) + } + } + } else { + false + }; + + Box::pin(async move { + let resp = client + .post(url) + .json(&request) + .headers(request.headers()) + .send() + .await + .map_err(TransportErrorKind::custom)?; + + let status = resp.status(); + let body = resp.bytes().await.map_err(TransportErrorKind::custom)?; + + if !status.is_success() { + return Err(TransportErrorKind::http_error( + status.as_u16(), + String::from_utf8_lossy(&body).into_owned(), + )); + } + + if should_patch { + if let Some(patched) = Self::patch_response(&body) { + return serde_json::from_slice(&patched).map_err(|err| { + TransportError::deser_err(err, String::from_utf8_lossy(&patched)) + }); + } + } + serde_json::from_slice(&body) + .map_err(|err| TransportError::deser_err(err, String::from_utf8_lossy(&body))) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn patch_receipt_adds_missing_type() { + let mut receipt = json!({"status": "0x1", "gasUsed": "0x5208"}); + assert!(PatchingHttp::patch_receipt(&mut receipt)); + assert_eq!(receipt["type"], "0x0"); + } + + #[test] + fn patch_receipt_skips_existing_type() { + let mut receipt = json!({"status": "0x1", "type": "0x2"}); + assert!(!PatchingHttp::patch_receipt(&mut receipt)); + assert_eq!(receipt["type"], "0x2"); + } + + #[test] + fn patch_response_single() { + let body = br#"{"jsonrpc":"2.0","id":1,"result":{"status":"0x1"}}"#; + let patched = PatchingHttp::patch_response(body).unwrap(); + let json: Value = serde_json::from_slice(&patched).unwrap(); + assert_eq!(json["result"]["type"], "0x0"); + } + + #[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()); + } + + #[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 json: Value = serde_json::from_slice(&patched).unwrap(); + assert_eq!(json[0]["result"]["type"], "0x0"); + assert_eq!(json[1]["result"]["type"], "0x0"); + } +} diff --git a/docs/config.md b/docs/config.md index 8641398867c..bbf882cd41c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -127,8 +127,15 @@ A `provider` is an object with the following characteristics: - `transport`: one of `rpc`, `ws`, and `ipc`. Defaults to `rpc`. - `url`: the URL for the provider - `features`: an array of features that the provider supports, either empty - or any combination of `traces` and `archive` for Web3 providers, or - `compression` and `filters` for Firehose providers + or any combination of the following for Web3 providers: + - `traces`: provider supports `debug_traceBlockByNumber` for call tracing + - `archive`: provider is an archive node with full historical state + - `no_eip1898`: provider doesn't support EIP-1898 (block parameter by hash/number object) + - `no_eip2718`: provider doesn't return the `type` field in transaction receipts + (pre-EIP-2718 chains). When set, receipts are patched to add + `"type": "0x0"` for legacy transaction compatibility. + + For Firehose providers: `compression` and `filters` - `headers`: HTTP headers to be added on every request. Defaults to none. - `limit`: the maximum number of subgraphs that can use this provider. Defaults to unlimited. At least one provider should be unlimited, diff --git a/node/src/chain.rs b/node/src/chain.rs index b1f2b0709cb..e417ad48e6f 100644 --- a/node/src/chain.rs +++ b/node/src/chain.rs @@ -215,12 +215,14 @@ pub async fn create_ethereum_networks_for_chain( use crate::config::Transport::*; + let no_eip2718 = web3.features.contains("no_eip2718"); let transport = match web3.transport { Rpc => Transport::new_rpc( Url::parse(&web3.url)?, web3.headers.clone(), endpoint_metrics.cheap_clone(), &provider.label, + no_eip2718, ), Ipc => Transport::new_ipc(&web3.url).await, Ws => Transport::new_ws(&web3.url).await, diff --git a/node/src/config.rs b/node/src/config.rs index b118f34da57..c06b5298ac0 100644 --- a/node/src/config.rs +++ b/node/src/config.rs @@ -709,7 +709,13 @@ impl Web3Provider { } } -const PROVIDER_FEATURES: [&str; 3] = ["traces", "archive", "no_eip1898"]; +/// Supported provider features: +/// - `traces`: Provider supports debug_traceBlockByNumber for call tracing +/// - `archive`: Provider is an archive node with full historical state +/// - `no_eip1898`: Provider doesn't support EIP-1898 (block parameter by hash/number object) +/// - `no_eip2718`: Provider doesn't return the `type` field in transaction receipts. +/// When set, receipts are patched to add `"type": "0x0"` for legacy transaction compatibility. +const PROVIDER_FEATURES: [&str; 4] = ["traces", "archive", "no_eip1898", "no_eip2718"]; const DEFAULT_PROVIDER_FEATURES: [&str; 2] = ["traces", "archive"]; impl Provider {