Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to arc-node are documented in this file.

## [Unreleased]

### Fixes

- [EL] Reject pending-block state queries for `eth_getBalance`, `eth_getTransactionCount`, `eth_getCode`, `eth_getStorageAt`, `eth_call`, `eth_estimateGas`, `eth_getLogs`, and `eth_feeHistory`

## [v0.8.0]

**Changes:** [v0.7.3...v0.8.0](https://github.com/circlefin/arc-node/compare/v0.7.3...v0.8.0) -- [release notes](https://github.com/circlefin/arc-node/releases/tag/v0.8.0)
Expand Down
271 changes: 266 additions & 5 deletions crates/evm-node/src/rpc_middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ const ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD: &str =
"eth_getRawTransactionByBlockNumberAndIndex";
const ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD: &str = "eth_getUncleCountByBlockNumber";
const ETH_GET_HEADER_BY_NUMBER_METHOD: &str = "eth_getHeaderByNumber";
// Value/state-query methods whose block-tag param is NOT first positionally.
const ETH_GET_BALANCE_METHOD: &str = "eth_getBalance";
const ETH_GET_TRANSACTION_COUNT_METHOD: &str = "eth_getTransactionCount";
const ETH_GET_CODE_METHOD: &str = "eth_getCode";
const ETH_GET_STORAGE_AT_METHOD: &str = "eth_getStorageAt";
const ETH_CALL_METHOD: &str = "eth_call";
const ETH_ESTIMATE_GAS_METHOD: &str = "eth_estimateGas";
const ETH_GET_LOGS_METHOD: &str = "eth_getLogs";
// jsonrpsee proc-macro field name for eth_getLogs' single Filter param.
const FILTER_OBJECT_KEY: &str = "filter";
// Field names inside the Filter object itself — these come from the JSON-RPC
// wire format (alloy_rpc_types_eth::Filter's serde rename), not jsonrpsee's
// proc-macro, so they stay camelCase regardless of the outer param style.
const FILTER_FROM_BLOCK_KEY: &str = "fromBlock";
const FILTER_TO_BLOCK_KEY: &str = "toBlock";
const ETH_FEE_HISTORY_METHOD: &str = "eth_feeHistory";
// jsonrpsee proc-macro field name for eth_feeHistory's second param (newest_block).
const FEE_HISTORY_NEWEST_BLOCK_OBJECT_KEY: &str = "newest_block";
// jsonrpsee proc-macro field name for eth_getHeaderByNumber's BlockNumberOrTag param.
// Reth's trait declares `hash: BlockNumberOrTag` (copy-paste from getHeaderByHash) —
// see reth-rpc-eth-api/src/core.rs. If that arg is ever renamed, update this key and
Expand All @@ -64,6 +82,8 @@ const SUBSCRIPTION_KIND_OBJECT_KEY: &str = "kind";
const ETH_SEND_RAW_TRANSACTION_METHOD: &str = "eth_sendRawTransaction";
const ETH_SEND_RAW_TRANSACTION_SYNC_METHOD: &str = "eth_sendRawTransactionSync";
const PENDING_TX_SUBSCRIPTION_ERROR_CODE: i32 = -32001;
const PENDING_STATE_QUERY_ERROR_CODE: i32 = -32002;
const PENDING_STATE_QUERY_ERROR_MSG: &str = "queries against pending block state are not allowed";
const BATCH_TOO_LARGE_ERROR_CODE: i32 = -32600;
const UNPROTECTED_TX_ERROR_CODE: i32 = -32000;
const UNPROTECTED_TX_ERROR_MSG: &str =
Expand All @@ -86,7 +106,12 @@ pub struct ArcRpcLayer {
/// `eth_getBlockByNumber`, `eth_getBlockReceipts`,
/// `eth_getBlockTransactionCountByNumber`, `eth_getTransactionByBlockNumberAndIndex`,
/// `eth_getRawTransactionByBlockNumberAndIndex`, `eth_getUncleCountByBlockNumber`,
/// and `eth_getHeaderByNumber` when called with the `"pending"` tag.
/// and `eth_getHeaderByNumber` (answered with `null`) when called with the
/// `"pending"` tag. Also rejects, with an explicit JSON-RPC error (their
/// return types can't legally answer `null`), `eth_getBalance`,
/// `eth_getTransactionCount`, `eth_getCode`, `eth_getStorageAt`, `eth_call`,
/// `eth_estimateGas`, `eth_getLogs`, and `eth_feeHistory` when called against
/// pending state.
/// When false, the filter is bypassed and these are allowed.
/// CLI users opt out of the default via `--arc.expose-pending-txs`.
pub filter_pending_txs: bool,
Expand Down Expand Up @@ -323,6 +348,9 @@ where
if let Err(err) = error_if_pending_tx_rpc(&req) {
return MethodResponse::error(req.id(), err);
}
if let Err(err) = error_if_pending_state_query(&req) {
return MethodResponse::error(req.id(), err);
}
if is_pool_pending_tx_lookup(&req) || is_pending_block_query(&req) {
return null_response(&req);
}
Expand Down Expand Up @@ -786,27 +814,134 @@ fn is_pending_block_method(method: &str) -> bool {
|| method == ETH_GET_HEADER_BY_NUMBER_METHOD
}

/// Returns the zero-based positional index of the block-tag/BlockId argument for
/// value/state-query methods where it is NOT the first parameter, plus the object-form
/// key jsonrpsee's proc-macro binds it to. `None` for methods not in this group.
///
/// These methods accept `BlockId` (string tag or EIP-1898 object form), same as
/// `eth_getBlockReceipts` above. Unlike that branch, only the snake_case key is
/// tried here: jsonrpsee's proc-macro binds named params to the trait's own
/// Rust argument name (`block_number`), not a wire-format alias, so a camelCase
/// `blockNumber` key can't occur for named dispatch — see the six trait
/// signatures below. Their Reth return types (`U256`, `Bytes`, `B256`)
/// are non-optional, so unlike the methods `is_pending_block_query` covers, a
/// pending-tag match here must produce an explicit error, not `null` — see
/// `error_if_pending_state_query`.
fn state_query_block_param_position(method: &str) -> Option<(usize, &'static str)> {
match method {
ETH_GET_BALANCE_METHOD => Some((1, "block_number")),
ETH_GET_TRANSACTION_COUNT_METHOD => Some((1, "block_number")),
ETH_GET_CODE_METHOD => Some((1, "block_number")),
ETH_GET_STORAGE_AT_METHOD => Some((2, "block_number")),
ETH_CALL_METHOD => Some((1, "block_number")),
ETH_ESTIMATE_GAS_METHOD => Some((1, "block_number")),
_ => None,
}
}

/// Extracts a param at an arbitrary positional index (array-style) or by key
/// (object-style). Unlike `extract_param`, this does not assume the target is
/// the first element.
///
/// For array-style params, params preceding `index` are skipped as borrowed raw
/// slices rather than parsed into an owned `serde_json::Value` tree — `eth_call`
/// and `eth_estimateGas` (the two heaviest-traffic methods using this path) carry
/// a full transaction object, calldata included, as the skipped argument.
fn extract_param_at<T: DeserializeOwned>(
params: Params<'_>,
index: usize,
keys: &[&str],
) -> Option<T> {
if params.is_object() {
let obj = params.parse::<serde_json::Value>().ok()?;
let val = keys.iter().find_map(|k| obj.get(*k))?.clone();
serde_json::from_value(val).ok()
} else {
let mut seq = params.sequence();
for _ in 0..index {
let _: Option<&serde_json::value::RawValue> = seq.optional_next().ok().flatten();
}
seq.optional_next::<T>().ok().flatten()
}
}

/// Returns true if `eth_getLogs`' filter object requests pending state via
/// `fromBlock` and/or `toBlock`.
fn is_pending_get_logs_query(params: Params<'_>) -> bool {
let Some(filter) = extract_param_at::<serde_json::Value>(params, 0, &[FILTER_OBJECT_KEY])
else {
return false;
};
[FILTER_FROM_BLOCK_KEY, FILTER_TO_BLOCK_KEY]
.iter()
.any(|key| {
filter
.get(*key)
.and_then(|v| serde_json::from_value::<BlockNumberOrTag>(v.clone()).ok())
.is_some_and(|t| t.is_pending())
})
}

/// Returns an error if the request queries pending-block state through the six
/// value/state-query methods (`eth_getBalance`, `eth_getTransactionCount`,
/// `eth_getCode`, `eth_getStorageAt`, `eth_call`, `eth_estimateGas`),
/// `eth_getLogs`, or `eth_feeHistory`.
///
/// Unlike the block-content methods `is_pending_block_query` covers below (which
/// return `Option<T>` and so can legally answer `null`), these methods' Reth
/// return types are non-optional (`U256`, `Bytes`, `B256`, `Vec<Log>`,
/// `FeeHistory`) — `null` is not a valid response for them and would break
/// clients expecting the documented type. Reject explicitly instead of
/// silently returning an unrepresentable value.
fn error_if_pending_state_query<'a>(req: &Request<'a>) -> Result<(), ErrorObject<'a>> {
let method = req.method_name();
let params = req.params();

let is_pending = if let Some((index, key)) = state_query_block_param_position(method) {
extract_param_at::<BlockId>(params, index, &[key]).is_some_and(|id| id.is_pending())
} else if method == ETH_GET_LOGS_METHOD {
is_pending_get_logs_query(params)
} else if method == ETH_FEE_HISTORY_METHOD {
extract_param_at::<BlockNumberOrTag>(params, 1, &[FEE_HISTORY_NEWEST_BLOCK_OBJECT_KEY])
.is_some_and(|t| t.is_pending())
} else {
false
};

if is_pending {
let error = ErrorObjectOwned::owned::<()>(
PENDING_STATE_QUERY_ERROR_CODE,
PENDING_STATE_QUERY_ERROR_MSG,
None,
);
return Err(error);
}
Ok(())
}

/// Returns true if the request queries pending-block state via a block number/tag parameter.
///
/// The consensus engine may briefly expose a pending block via `provider().pending_block()`
/// even when `--rpc.pending-block=none` is set. Intercepting at the middleware layer
/// guarantees a consistent `null` response regardless of consensus-engine state.
fn is_pending_block_query(req: &Request<'_>) -> bool {
if !is_pending_block_method(req.method_name()) {
let method = req.method_name();
let params = req.params();

if !is_pending_block_method(method) {
return false;
}
let params = req.params();
// eth_getBlockReceipts accepts BlockId: handles string tags and EIP-1898 object form
// ({"blockNumber": "pending"}). All other methods accept BlockNumberOrTag.
if req.method_name() == ETH_GET_BLOCK_RECEIPTS_METHOD {
if method == ETH_GET_BLOCK_RECEIPTS_METHOD {
return extract_param::<BlockId>(
params,
&[BLOCK_ID_OBJECT_KEY_SNAKE, BLOCK_ID_OBJECT_KEY_CAMEL],
)
.is_some_and(|id| id.is_pending());
}
// Object key for named params — coupled to jsonrpsee proc-macro field names.
let key = if req.method_name() == ETH_GET_HEADER_BY_NUMBER_METHOD {
let key = if method == ETH_GET_HEADER_BY_NUMBER_METHOD {
BLOCK_HEADER_NUMBER_OBJECT_KEY
} else {
BLOCK_NUMBER_OBJECT_KEY
Expand Down Expand Up @@ -1152,6 +1287,132 @@ mod tests {
);
}

// -- state-query methods with non-first block-tag param: rejected with an
// explicit error (their Reth return types are non-optional, so unlike the
// block-content methods above, null is not a schema-valid response) --

#[tokio::test]
async fn test_enabled_blocks_pending_state_queries() {
let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService);
let cases: &[(&str, &str)] = &[
(
"eth_getBalance",
r#"["0x0000000000000000000000000000000000000000", "pending"]"#,
),
(
"eth_getTransactionCount",
r#"["0x0000000000000000000000000000000000000000", "pending"]"#,
),
(
"eth_getCode",
r#"["0x0000000000000000000000000000000000000000", "pending"]"#,
),
(
"eth_getStorageAt",
r#"["0x0000000000000000000000000000000000000000", "0x0", "pending"]"#,
),
(
"eth_call",
r#"[{"to":"0x0000000000000000000000000000000000000000"}, "pending"]"#,
),
(
"eth_estimateGas",
r#"[{"to":"0x0000000000000000000000000000000000000000"}, "pending"]"#,
),
(
"eth_getLogs",
r#"[{"fromBlock":"pending","toBlock":"pending"}]"#,
),
(
"eth_getLogs",
r#"[{"fromBlock":"0x1","toBlock":"pending"}]"#,
),
(
"eth_getLogs",
r#"[{"fromBlock":"pending","toBlock":"0x1"}]"#,
),
("eth_feeHistory", r#"["0x1", "pending", []]"#),
];
for (method, params_json) in cases {
let params = RawValue::from_string(params_json.to_string()).unwrap();
let request = create_request_with_params(method, params, 1);
let response = middleware.call(request).await;
assert_eq!(
response.as_error_code(),
Some(PENDING_STATE_QUERY_ERROR_CODE),
"{method} with {params_json} should be rejected with an explicit error"
);
}
}

// Named (object-style) params can't be exercised against the live public
// endpoint (it rejects by-name dispatch before routing), so this branch of
// extract_param_at relies entirely on unit coverage.
#[tokio::test]
async fn test_enabled_blocks_pending_state_queries_object_params() {
let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService);
let cases: &[(&str, &str)] = &[
(
"eth_getBalance",
r#"{"address":"0x0000000000000000000000000000000000000000","block_number":"pending"}"#,
),
(
"eth_getBalance",
r#"{"address":"0x0000000000000000000000000000000000000000","block_number":{"blockNumber":"pending"}}"#,
),
(
"eth_getLogs",
r#"{"filter":{"fromBlock":"pending","toBlock":"pending"}}"#,
),
(
"eth_feeHistory",
r#"{"block_count":"0x1","newest_block":"pending","reward_percentiles":[]}"#,
),
];
for (method, params_json) in cases {
let params = RawValue::from_string(params_json.to_string()).unwrap();
let request = create_request_with_params(method, params, 1);
let response = middleware.call(request).await;
assert_eq!(
response.as_error_code(),
Some(PENDING_STATE_QUERY_ERROR_CODE),
"{method} with object params {params_json} should be rejected"
);
}
}

#[tokio::test]
async fn test_enabled_allows_non_pending_state_queries() {
let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService);
let cases: &[(&str, &str)] = &[
(
"eth_getBalance",
r#"["0x0000000000000000000000000000000000000000", "latest"]"#,
),
(
"eth_getTransactionCount",
r#"["0x0000000000000000000000000000000000000000", "0x1"]"#,
),
("eth_getLogs", r#"[{"fromBlock":"0x1","toBlock":"latest"}]"#),
("eth_feeHistory", r#"["0x1", "latest", []]"#),
// Absent block-tag param: reth defaults to latest, filter must not
// treat a missing argument as pending.
(
"eth_getBalance",
r#"["0x0000000000000000000000000000000000000000"]"#,
),
];
for (method, params_json) in cases {
let params = RawValue::from_string(params_json.to_string()).unwrap();
let request = create_request_with_params(method, params, 1);
let response = middleware.call(request).await;
assert!(
response.as_error_code().is_none(),
"{method} with {params_json} (non-pending) should pass through"
);
}
}

// -- pool pending tx lookup: intercepted --
//
// eth_getTransactionBySenderAndNonce returns null (success, not error)
Expand Down