Skip to content

Commit ab13083

Browse files
committed
ethereum, node: Add no_eip2718 feature for pre-EIP-2718 chains
Adds a provider feature to patch transaction receipts that are missing the type field. This is a bandaid fix for chains that do not return EIP-2718 typed transaction fields, which causes alloy deserialization to fail.
1 parent 308d5e2 commit ab13083

File tree

5 files changed

+211
-15
lines changed

5 files changed

+211
-15
lines changed

chain/ethereum/src/network.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ mod tests {
394394
HeaderMap::new(),
395395
metrics.clone(),
396396
"",
397+
false,
397398
);
398399
let provider_metrics = Arc::new(ProviderEthRpcMetrics::new(mock_registry.clone()));
399400

@@ -497,6 +498,7 @@ mod tests {
497498
HeaderMap::new(),
498499
metrics.clone(),
499500
"",
501+
false,
500502
);
501503
let provider_metrics = Arc::new(ProviderEthRpcMetrics::new(mock_registry.clone()));
502504

@@ -568,6 +570,7 @@ mod tests {
568570
HeaderMap::new(),
569571
metrics.clone(),
570572
"",
573+
false,
571574
);
572575
let provider_metrics = Arc::new(ProviderEthRpcMetrics::new(mock_registry.clone()));
573576

@@ -632,6 +635,7 @@ mod tests {
632635
HeaderMap::new(),
633636
metrics.clone(),
634637
"",
638+
false,
635639
);
636640
let provider_metrics = Arc::new(ProviderEthRpcMetrics::new(mock_registry.clone()));
637641

@@ -919,6 +923,7 @@ mod tests {
919923
HeaderMap::new(),
920924
endpoint_metrics.clone(),
921925
"",
926+
false,
922927
);
923928

924929
Arc::new(

chain/ethereum/src/transport.rs

Lines changed: 188 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
1+
use alloy::transports::{TransportError, TransportErrorKind, TransportFut};
12
use graph::components::network_provider::ProviderName;
23
use graph::endpoint::{ConnectionType, EndpointMetrics, RequestLabels};
34
use graph::prelude::alloy::rpc::json_rpc::{RequestPacket, ResponsePacket};
5+
use graph::prelude::alloy::transports::{ipc::IpcConnect, ws::WsConnect};
46
use graph::prelude::*;
57
use graph::url::Url;
8+
use serde_json::Value;
69
use std::sync::Arc;
710
use std::task::{Context, Poll};
811
use tower::Service;
912

10-
use alloy::transports::{TransportError, TransportFut};
11-
12-
use graph::prelude::alloy::transports::{http::Http, ipc::IpcConnect, ws::WsConnect};
13-
1413
/// Abstraction over different transport types for Alloy providers.
1514
#[derive(Clone, Debug)]
1615
pub enum Transport {
@@ -41,19 +40,24 @@ impl Transport {
4140
}
4241

4342
/// Creates a JSON-RPC over HTTP transport.
43+
///
44+
/// Set `no_eip2718` to true for chains that don't return the `type` field
45+
/// in transaction receipts (pre-EIP-2718 chains). Use provider feature `no_eip2718`.
4446
pub fn new_rpc(
4547
rpc: Url,
4648
headers: graph::http::HeaderMap,
4749
metrics: Arc<EndpointMetrics>,
4850
provider: impl AsRef<str>,
51+
no_eip2718: bool,
4952
) -> Self {
5053
let client = reqwest::Client::builder()
5154
.default_headers(headers)
5255
.build()
5356
.expect("Failed to build HTTP client");
5457

55-
let http_transport = Http::with_client(client, rpc);
56-
let metrics_transport = MetricsHttp::new(http_transport, metrics, provider.as_ref().into());
58+
let patching_transport = PatchingHttp::new(client, rpc, no_eip2718);
59+
let metrics_transport =
60+
MetricsHttp::new(patching_transport, metrics, provider.as_ref().into());
5761
let rpc_client = alloy::rpc::client::RpcClient::new(metrics_transport, false);
5862

5963
Transport::RPC(rpc_client)
@@ -63,17 +67,13 @@ impl Transport {
6367
/// Custom HTTP transport wrapper that collects metrics
6468
#[derive(Clone)]
6569
pub struct MetricsHttp {
66-
inner: Http<reqwest::Client>,
70+
inner: PatchingHttp,
6771
metrics: Arc<EndpointMetrics>,
6872
provider: ProviderName,
6973
}
7074

7175
impl MetricsHttp {
72-
pub fn new(
73-
inner: Http<reqwest::Client>,
74-
metrics: Arc<EndpointMetrics>,
75-
provider: ProviderName,
76-
) -> Self {
76+
pub fn new(inner: PatchingHttp, metrics: Arc<EndpointMetrics>, provider: ProviderName) -> Self {
7777
Self {
7878
inner,
7979
metrics,
@@ -125,3 +125,179 @@ impl Service<RequestPacket> for MetricsHttp {
125125
})
126126
}
127127
}
128+
129+
/// HTTP transport that patches receipts for chains that don't support EIP-2718 (typed transactions).
130+
/// When `no_eip2718` is set, adds missing `type` field to receipts.
131+
#[derive(Clone)]
132+
pub struct PatchingHttp {
133+
client: reqwest::Client,
134+
url: Url,
135+
no_eip2718: bool,
136+
}
137+
138+
impl PatchingHttp {
139+
pub fn new(client: reqwest::Client, url: Url, no_eip2718: bool) -> Self {
140+
Self {
141+
client,
142+
url,
143+
no_eip2718,
144+
}
145+
}
146+
147+
fn is_receipt_method(method: &str) -> bool {
148+
method == "eth_getTransactionReceipt" || method == "eth_getBlockReceipts"
149+
}
150+
151+
fn patch_receipt(receipt: &mut Value) -> bool {
152+
if let Value::Object(obj) = receipt {
153+
if !obj.contains_key("type") {
154+
obj.insert("type".to_string(), Value::String("0x0".to_string()));
155+
return true;
156+
}
157+
}
158+
false
159+
}
160+
161+
fn patch_result(result: &mut Value) -> bool {
162+
match result {
163+
Value::Object(_) => Self::patch_receipt(result),
164+
Value::Array(arr) => {
165+
let mut patched = false;
166+
for r in arr {
167+
patched |= Self::patch_receipt(r);
168+
}
169+
patched
170+
}
171+
_ => false,
172+
}
173+
}
174+
175+
fn patch_rpc_response(response: &mut Value) -> bool {
176+
response
177+
.get_mut("result")
178+
.map(Self::patch_result)
179+
.unwrap_or(false)
180+
}
181+
182+
fn patch_response(body: &[u8]) -> Option<Vec<u8>> {
183+
let mut json: Value = serde_json::from_slice(body).ok()?;
184+
185+
let patched = match &mut json {
186+
Value::Object(_) => Self::patch_rpc_response(&mut json),
187+
Value::Array(batch) => {
188+
let mut patched = false;
189+
for r in batch {
190+
patched |= Self::patch_rpc_response(r);
191+
}
192+
patched
193+
}
194+
_ => false,
195+
};
196+
197+
if patched {
198+
serde_json::to_vec(&json).ok()
199+
} else {
200+
None
201+
}
202+
}
203+
}
204+
205+
impl Service<RequestPacket> for PatchingHttp {
206+
type Response = ResponsePacket;
207+
type Error = TransportError;
208+
type Future = TransportFut<'static>;
209+
210+
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
211+
Poll::Ready(Ok(()))
212+
}
213+
214+
fn call(&mut self, request: RequestPacket) -> Self::Future {
215+
let client = self.client.clone();
216+
let url = self.url.clone();
217+
let no_eip2718 = self.no_eip2718;
218+
219+
let should_patch = if no_eip2718 {
220+
match &request {
221+
RequestPacket::Single(req) => Self::is_receipt_method(req.method()),
222+
RequestPacket::Batch(reqs) => {
223+
reqs.iter().any(|r| Self::is_receipt_method(r.method()))
224+
}
225+
}
226+
} else {
227+
false
228+
};
229+
230+
Box::pin(async move {
231+
let resp = client
232+
.post(url)
233+
.json(&request)
234+
.headers(request.headers())
235+
.send()
236+
.await
237+
.map_err(TransportErrorKind::custom)?;
238+
239+
let status = resp.status();
240+
let body = resp.bytes().await.map_err(TransportErrorKind::custom)?;
241+
242+
if !status.is_success() {
243+
return Err(TransportErrorKind::http_error(
244+
status.as_u16(),
245+
String::from_utf8_lossy(&body).into_owned(),
246+
));
247+
}
248+
249+
if should_patch {
250+
if let Some(patched) = Self::patch_response(&body) {
251+
return serde_json::from_slice(&patched).map_err(|err| {
252+
TransportError::deser_err(err, String::from_utf8_lossy(&patched))
253+
});
254+
}
255+
}
256+
serde_json::from_slice(&body)
257+
.map_err(|err| TransportError::deser_err(err, String::from_utf8_lossy(&body)))
258+
})
259+
}
260+
}
261+
262+
#[cfg(test)]
263+
mod tests {
264+
use super::*;
265+
use serde_json::json;
266+
267+
#[test]
268+
fn patch_receipt_adds_missing_type() {
269+
let mut receipt = json!({"status": "0x1", "gasUsed": "0x5208"});
270+
assert!(PatchingHttp::patch_receipt(&mut receipt));
271+
assert_eq!(receipt["type"], "0x0");
272+
}
273+
274+
#[test]
275+
fn patch_receipt_skips_existing_type() {
276+
let mut receipt = json!({"status": "0x1", "type": "0x2"});
277+
assert!(!PatchingHttp::patch_receipt(&mut receipt));
278+
assert_eq!(receipt["type"], "0x2");
279+
}
280+
281+
#[test]
282+
fn patch_response_single() {
283+
let body = br#"{"jsonrpc":"2.0","id":1,"result":{"status":"0x1"}}"#;
284+
let patched = PatchingHttp::patch_response(body).unwrap();
285+
let json: Value = serde_json::from_slice(&patched).unwrap();
286+
assert_eq!(json["result"]["type"], "0x0");
287+
}
288+
289+
#[test]
290+
fn patch_response_returns_none_when_type_exists() {
291+
let body = br#"{"jsonrpc":"2.0","id":1,"result":{"status":"0x1","type":"0x2"}}"#;
292+
assert!(PatchingHttp::patch_response(body).is_none());
293+
}
294+
295+
#[test]
296+
fn patch_response_batch() {
297+
let body = br#"[{"jsonrpc":"2.0","id":1,"result":{"status":"0x1"}},{"jsonrpc":"2.0","id":2,"result":{"status":"0x1"}}]"#;
298+
let patched = PatchingHttp::patch_response(body).unwrap();
299+
let json: Value = serde_json::from_slice(&patched).unwrap();
300+
assert_eq!(json[0]["result"]["type"], "0x0");
301+
assert_eq!(json[1]["result"]["type"], "0x0");
302+
}
303+
}

docs/config.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,15 @@ A `provider` is an object with the following characteristics:
127127
- `transport`: one of `rpc`, `ws`, and `ipc`. Defaults to `rpc`.
128128
- `url`: the URL for the provider
129129
- `features`: an array of features that the provider supports, either empty
130-
or any combination of `traces` and `archive` for Web3 providers, or
131-
`compression` and `filters` for Firehose providers
130+
or any combination of the following for Web3 providers:
131+
- `traces`: provider supports `debug_traceBlockByNumber` for call tracing
132+
- `archive`: provider is an archive node with full historical state
133+
- `no_eip1898`: provider doesn't support EIP-1898 (block parameter by hash/number object)
134+
- `no_eip2718`: provider doesn't return the `type` field in transaction receipts
135+
(pre-EIP-2718 chains). When set, receipts are patched to add
136+
`"type": "0x0"` for legacy transaction compatibility.
137+
138+
For Firehose providers: `compression` and `filters`
132139
- `headers`: HTTP headers to be added on every request. Defaults to none.
133140
- `limit`: the maximum number of subgraphs that can use this provider.
134141
Defaults to unlimited. At least one provider should be unlimited,

node/src/chain.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,12 +215,14 @@ pub async fn create_ethereum_networks_for_chain(
215215

216216
use crate::config::Transport::*;
217217

218+
let no_eip2718 = web3.features.contains("no_eip2718");
218219
let transport = match web3.transport {
219220
Rpc => Transport::new_rpc(
220221
Url::parse(&web3.url)?,
221222
web3.headers.clone(),
222223
endpoint_metrics.cheap_clone(),
223224
&provider.label,
225+
no_eip2718,
224226
),
225227
Ipc => Transport::new_ipc(&web3.url).await,
226228
Ws => Transport::new_ws(&web3.url).await,

node/src/config.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -709,7 +709,13 @@ impl Web3Provider {
709709
}
710710
}
711711

712-
const PROVIDER_FEATURES: [&str; 3] = ["traces", "archive", "no_eip1898"];
712+
/// Supported provider features:
713+
/// - `traces`: Provider supports debug_traceBlockByNumber for call tracing
714+
/// - `archive`: Provider is an archive node with full historical state
715+
/// - `no_eip1898`: Provider doesn't support EIP-1898 (block parameter by hash/number object)
716+
/// - `no_eip2718`: Provider doesn't return the `type` field in transaction receipts.
717+
/// When set, receipts are patched to add `"type": "0x0"` for legacy transaction compatibility.
718+
const PROVIDER_FEATURES: [&str; 4] = ["traces", "archive", "no_eip1898", "no_eip2718"];
713719
const DEFAULT_PROVIDER_FEATURES: [&str; 2] = ["traces", "archive"];
714720

715721
impl Provider {

0 commit comments

Comments
 (0)