Unsafe Integer Casts in Anvil's JSON-RPC Handler

I wrote about a bug in Anvil's Optimism deposit transaction parser. A panicking U256 -> u128 conversion that turned a single eth_sendTransaction tx into a process abort.

A small rg command later and five more instances of the same bug, all in crates/anvil/src/eth/api.rs, all merged in #14658.


The common shape

Every Anvil RPC parameter that represents an EVM scalar (gas, fees, block counts, time intervals) arrives over JSON-RPC as a U256. That is what the JSON-RPC encoding permits. Internally, Anvil's backend stores these in narrower Rust integer types that match the EVM execution domain: u64 for gas limits and block counts, u128 for gas prices.

The bridge between the two used Uint::to::<T>(), the panicking conversion from ruint. Any value wider than the destination type aborts the process. saturating_to, try_to, wrapping_to, checked_to all spell out almost identically, and only one of them is unsafe.

For untrusted RPC input, saturation is the conservative default.


eth_feeHistory (#14653)

api.rs:1129:

const MAX_BLOCK_COUNT: u64 = 1024u64;
let block_count = block_count.to::<u64>().min(MAX_BLOCK_COUNT);

The clamp to 1024 is good, but the panicking .to::<u64>() runs first. Any blockCount of 2642^{64} or larger panics before the cap is ever applied. The clamp does nothing for the inputs it was supposed to protect against.

curl -X POST -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_feeHistory",
  "params":["0x010000000000000000","latest",[]]}' \
  http://127.0.0.1:8545

eth_feeHistory is a standard JSON-RPC method. Wallets call it. Block explorers call it. Every fee estimation library on Ethereum calls it routinely.


anvil_mine (#14654)

Two panic sites in one function, at api.rs:2828 and :2836:

let interval = interval.map(|i| i.to::<u64>());     // panic #1
let blocks = num_blocks.unwrap_or(U256::from(1));
if blocks.is_zero() { return Ok(()); }
 
self.on_blocking_task(|this| async move {
    for _ in 0..blocks.to::<u64>() {                // panic #2

The second one is gnarlier because it lives inside a spawned blocking task.

curl -X POST -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"anvil_mine",
  "params":["0x010000000000000000",null]}' \
  http://127.0.0.1:8545

evm_setBlockGasLimit (#14655)

api.rs:506:

pub fn evm_set_block_gas_limit(&self, gas_limit: U256) -> Result<bool> {
    node_info!("evm_setBlockGasLimit");
    self.backend.set_gas_limit(gas_limit.to());
    Ok(true)
}

No bounds check of any kind. The RPC parameter goes straight into .to(), which then has to fit into the u64 that set_gas_limit requires.

curl -X POST -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"evm_setBlockGasLimit",
  "params":["0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"]}' \
  http://127.0.0.1:8545

Block gas limits in the EVM are 64-bit by design. After the fix, an unrealistically large limit clamps to the maximum realistic one instead of killing the node.


anvil_setNextBlockBaseFeePerGas (#14656)

api.rs:375:

self.backend.set_base_fee(basefee.to());

EIP-1559 base fees are stored as u64 inside Anvil's backend. Anything wider from the RPC aborts the process.

curl -X POST -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"anvil_setNextBlockBaseFeePerGas",
  "params":["0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"]}' \
  http://127.0.0.1:8545

This RPC is what you use to simulate fee spikes in tests, mimicking gas auctions and testing how contracts behave under congestion. The people most likely to mutate it aggressively are the people doing the most useful testing. A fuzzer ranging basefee over the full U256 space kills the node on the first iteration outside u64.


anvil_setMinGasPrice (#14657)

api.rs:360. This one casts to u128, not u64, because gas prices have wider headroom in Anvil's model.

self.backend.set_gas_price(gas.to());
anvil --hardfork berlin &
curl -X POST -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"anvil_setMinGasPrice",
  "params":["0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"]}' \
  http://127.0.0.1:8545

anvil_setMinGasPrice is only reachable when the chain is configured before EIP-1559 (--hardfork berlin for example).


What the five have in common

Uint::to::<T>() is less a bug than a vulnerability primitive. Any time a U256 from RPC input gets cast into a narrower type controlled by an untrusted caller, you have a "one-token" DoS, the missing token being saturating.


Thanks to mablr and stevencartavia for the quick review.