Skip to content

Commit 3a3a425

Browse files
authored
Merge pull request #267 from graphprotocol/pcv/feat-rpc-chain-validation
Add RPC chainId validation
2 parents f58019b + 7d3640a commit 3a3a425

3 files changed

Lines changed: 261 additions & 0 deletions

File tree

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
use crate::config::{IndexedChain, ProtocolChain};
2+
use crate::runner::jrpc_utils::JrpcExpBackoff;
3+
use crate::{Caip2ChainId, Config};
4+
use anyhow::anyhow;
5+
use std::str::FromStr;
6+
use tracing::{error, info};
7+
use web3::api::Web3;
8+
use web3::helpers::CallFuture;
9+
10+
/// Validates that all configured RPC endpoints return the expected chain ID
11+
pub async fn validate_chain_ids(config: &Config) -> anyhow::Result<()> {
12+
info!("Validating RPC chain IDs...");
13+
14+
// Validate protocol chain
15+
validate_protocol_chain(&config.protocol_chain).await?;
16+
17+
// Validate indexed chains
18+
for chain in &config.indexed_chains {
19+
validate_indexed_chain(chain).await?;
20+
}
21+
22+
info!("All RPC chain IDs validated successfully");
23+
Ok(())
24+
}
25+
26+
async fn validate_protocol_chain(chain: &ProtocolChain) -> anyhow::Result<()> {
27+
let transport = JrpcExpBackoff::http(
28+
chain.jrpc_url.clone(),
29+
chain.id.clone(),
30+
std::time::Duration::from_secs(30),
31+
);
32+
let web3 = Web3::new(transport);
33+
34+
validate_chain_id(&web3, &chain.id, chain.jrpc_url.as_ref()).await
35+
}
36+
37+
async fn validate_indexed_chain(chain: &IndexedChain) -> anyhow::Result<()> {
38+
let transport = JrpcExpBackoff::http(
39+
chain.jrpc_url.clone(),
40+
chain.id.clone(),
41+
std::time::Duration::from_secs(30),
42+
);
43+
let web3 = Web3::new(transport);
44+
45+
validate_chain_id(&web3, &chain.id, chain.jrpc_url.as_ref()).await
46+
}
47+
48+
async fn validate_chain_id<T>(
49+
web3: &Web3<T>,
50+
expected_chain: &Caip2ChainId,
51+
rpc_url: &str,
52+
) -> anyhow::Result<()>
53+
where
54+
T: web3::Transport,
55+
{
56+
// Only validate EVM chains (namespace "eip155")
57+
if expected_chain.namespace_part() != "eip155" {
58+
info!(
59+
"Skipping chain ID validation for non-EVM chain: {}",
60+
expected_chain
61+
);
62+
return Ok(());
63+
}
64+
65+
// Parse expected numeric chain ID from CAIP-2 reference
66+
let expected_numeric_id = u64::from_str(expected_chain.reference_part())
67+
.map_err(|e| anyhow!("Failed to parse chain ID from {}: {}", expected_chain, e))?;
68+
69+
// Call eth_chainId
70+
let fut = web3.transport().execute("eth_chainId", vec![]);
71+
let call_fut: CallFuture<String, T::Out> = CallFuture::new(fut);
72+
73+
let chain_id_hex = match call_fut.await {
74+
Ok(id) => id,
75+
Err(e) => {
76+
error!("Failed to get chain ID from RPC {}: {}", rpc_url, e);
77+
return Err(anyhow!(
78+
"Failed to get chain ID from RPC {}: {}",
79+
rpc_url,
80+
e
81+
));
82+
}
83+
};
84+
85+
// Parse hex chain ID (e.g., "0xa4b1" -> 42161)
86+
let actual_chain_id = u64::from_str_radix(chain_id_hex.trim_start_matches("0x"), 16)
87+
.map_err(|e| anyhow!("Failed to parse chain ID hex '{}': {}", chain_id_hex, e))?;
88+
89+
// Compare
90+
if actual_chain_id != expected_numeric_id {
91+
error!(
92+
"Chain ID mismatch for {}: RPC {} returned chain ID {} (0x{:x}), expected {} from CAIP-2 identifier {}",
93+
expected_chain, rpc_url, actual_chain_id, actual_chain_id, expected_numeric_id, expected_chain
94+
);
95+
return Err(anyhow!(
96+
"Chain ID mismatch for {}: RPC {} returned chain ID {} (0x{:x}), expected {} from CAIP-2 identifier {}",
97+
expected_chain, rpc_url, actual_chain_id, actual_chain_id, expected_numeric_id, expected_chain
98+
));
99+
}
100+
101+
info!(
102+
"✓ Chain ID validated for {}: RPC {} correctly returns chain ID {}",
103+
expected_chain, rpc_url, actual_chain_id
104+
);
105+
106+
Ok(())
107+
}
108+
109+
#[cfg(test)]
110+
mod tests {
111+
use super::*;
112+
use jsonrpc_core::{Call, Value};
113+
use std::future::Future;
114+
use std::pin::Pin;
115+
use web3::RequestId;
116+
117+
#[test]
118+
fn test_caip2_parsing() {
119+
let chain_id = Caip2ChainId::from_str("eip155:1").unwrap();
120+
assert_eq!(chain_id.namespace_part(), "eip155");
121+
assert_eq!(chain_id.reference_part(), "1");
122+
123+
let chain_id = Caip2ChainId::from_str("eip155:42161").unwrap();
124+
assert_eq!(chain_id.namespace_part(), "eip155");
125+
assert_eq!(chain_id.reference_part(), "42161");
126+
127+
// Non-EVM chain
128+
let chain_id = Caip2ChainId::from_str("bip122:000000000019d6689c085ae165831e93").unwrap();
129+
assert_eq!(chain_id.namespace_part(), "bip122");
130+
assert_eq!(
131+
chain_id.reference_part(),
132+
"000000000019d6689c085ae165831e93"
133+
);
134+
}
135+
136+
// Mock transport that returns a predefined chain ID
137+
#[derive(Debug, Clone)]
138+
struct MockTransport {
139+
chain_id_response: String,
140+
}
141+
142+
impl web3::Transport for MockTransport {
143+
type Out = Pin<Box<dyn Future<Output = Result<Value, web3::Error>>>>;
144+
145+
fn prepare(&self, method: &str, params: Vec<Value>) -> (RequestId, Call) {
146+
let call = Call::MethodCall(jsonrpc_core::MethodCall {
147+
jsonrpc: Some(jsonrpc_core::Version::V2),
148+
method: method.to_string(),
149+
params: jsonrpc_core::Params::Array(params),
150+
id: jsonrpc_core::Id::Num(1),
151+
});
152+
(1, call)
153+
}
154+
155+
fn send(&self, _id: RequestId, request: Call) -> Self::Out {
156+
let response = match request {
157+
Call::MethodCall(ref call) if call.method == "eth_chainId" => {
158+
Ok(Value::String(self.chain_id_response.clone()))
159+
}
160+
Call::MethodCall(ref call) => Err(web3::Error::Decoder(format!(
161+
"Unexpected method: {}",
162+
call.method
163+
))),
164+
_ => Err(web3::Error::Decoder("Invalid request type".to_string())),
165+
};
166+
167+
Box::pin(futures::future::ready(response))
168+
}
169+
}
170+
171+
#[tokio::test]
172+
async fn test_validate_chain_id_success() {
173+
let mock_transport = MockTransport {
174+
chain_id_response: "0x1".to_string(),
175+
};
176+
let web3 = Web3::new(mock_transport);
177+
let chain_id = Caip2ChainId::from_str("eip155:1").unwrap();
178+
179+
let result = validate_chain_id(&web3, &chain_id, "http://test.com").await;
180+
assert!(result.is_ok());
181+
}
182+
183+
#[tokio::test]
184+
async fn test_validate_chain_id_mismatch() {
185+
let mock_transport = MockTransport {
186+
chain_id_response: "0x1".to_string(), // Returns mainnet (1)
187+
};
188+
let web3 = Web3::new(mock_transport);
189+
let chain_id = Caip2ChainId::from_str("eip155:42161").unwrap(); // Expects Arbitrum (42161)
190+
191+
let result = validate_chain_id(&web3, &chain_id, "http://test.com").await;
192+
assert!(result.is_err());
193+
let err_msg = result.unwrap_err().to_string();
194+
assert!(err_msg.contains("Chain ID mismatch"));
195+
assert!(err_msg.contains("returned chain ID 1"));
196+
assert!(err_msg.contains("expected 42161"));
197+
}
198+
199+
#[tokio::test]
200+
async fn test_validate_chain_id_hex_variations() {
201+
// Test with different hex formats
202+
let test_cases = vec![
203+
("0x1", 1), // 0x1
204+
("0x01", 1), // 0x01
205+
("0xa4b1", 42161), // 0xa4b1 (Arbitrum)
206+
("0xaa36a7", 11155111), // Sepolia
207+
];
208+
209+
for (hex_response, expected_id) in test_cases {
210+
let mock_transport = MockTransport {
211+
chain_id_response: hex_response.to_string(),
212+
};
213+
let web3 = Web3::new(mock_transport);
214+
let chain_id = Caip2ChainId::from_str(&format!("eip155:{}", expected_id)).unwrap();
215+
216+
let result = validate_chain_id(&web3, &chain_id, "http://test.com").await;
217+
assert!(
218+
result.is_ok(),
219+
"Failed for hex {} expecting {}",
220+
hex_response,
221+
expected_id
222+
);
223+
}
224+
}
225+
226+
#[tokio::test]
227+
async fn test_validate_chain_id_skips_non_evm() {
228+
// Non-EVM chains should be skipped
229+
let mock_transport = MockTransport {
230+
chain_id_response: "should_not_be_called".to_string(),
231+
};
232+
let web3 = Web3::new(mock_transport);
233+
let chain_id = Caip2ChainId::from_str("bip122:000000000019d6689c085ae165831e93").unwrap();
234+
235+
let result = validate_chain_id(&web3, &chain_id, "http://test.com").await;
236+
assert!(result.is_ok());
237+
}
238+
239+
#[tokio::test]
240+
async fn test_validate_chain_id_invalid_hex() {
241+
let mock_transport = MockTransport {
242+
chain_id_response: "invalid_hex".to_string(),
243+
};
244+
let web3 = Web3::new(mock_transport);
245+
let chain_id = Caip2ChainId::from_str("eip155:1").unwrap();
246+
247+
let result = validate_chain_id(&web3, &chain_id, "http://test.com").await;
248+
assert!(result.is_err());
249+
let err_msg = result.unwrap_err().to_string();
250+
assert!(err_msg.contains("Failed to parse chain ID hex"));
251+
}
252+
}

crates/oracle/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod chain_validation;
12
pub mod commands;
23
pub mod config;
34
pub mod contracts;

crates/oracle/src/runner/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ pub async fn run(config_file: impl AsRef<Path>) -> Result<(), Error> {
7171
init_logging(config.log_level);
7272
info!(log_level = %config.log_level, "The block oracle is starting.");
7373

74+
// Validate RPC chain IDs before starting
75+
if let Err(err) = crate::chain_validation::validate_chain_ids(&config).await {
76+
error!("Chain ID validation failed: {}", err);
77+
return Err(Error::BadJrpcProtocolChain(web3::Error::Decoder(
78+
err.to_string(),
79+
)));
80+
}
81+
7482
// Spawn the metrics server
7583
tokio::spawn(metrics_server(&METRICS, config.metrics_port));
7684

0 commit comments

Comments
 (0)