Lesson 6 — Custom precompiles
Question
Precompiles = built-in Solidity functions at magic addresses (0x01-0x09). Add your own = extend the EVM for chain-specific operations.
Principle (minimum model)
- Precompile architecture. Address (e.g. 0x1A) + Rust function
fn(input: &[u8], gas_limit: u64) -> (Bytes, u64). Returns output + gas used. - Why precompiles. Common operations too slow in pure Solidity (e.g. ed25519 verify); shared utility (CLOB ops); chain-specific (HyperEVM order placement).
- Integration via revm.
EvmConfig::with_precompiles(custom_precompiles). Revm dispatches to your function at the address. - Gas cost. Determined by you. Underprice → DoS; overprice → unusable. Benchmark.
- Failure semantics. Return empty Bytes + gas charge OR revert. Pick deliberate semantics.
- Production examples. Hyperliquid CLOB precompiles (0xCL...AB read best bid; 0xCL...C1 place order). Tempo merchant attestation precompile.
- Tests. Deploy a Solidity contract that calls the precompile; assert correct output + gas charge.
Worked example + steps
Custom precompiles
You want SHA-256 inside the EVM. Two roads: add a new opcode (a new byte in the instruction stream — SHA256 next to ADD), or add a precompile (a native Rust function the EVM calls when a contract does CALL 0x00...02). Real Ethereum picked the second: precompiles at addresses 0x01 through 0x0a cover ecrecover, sha256, modexp, and the BN254 elliptic-curve ops. Foundry's cheatcodes (vm.deal, vm.warp) are the same trick at industrial scale.
Custom opcodes break consensus with every wallet, indexer, and Solidity compiler on the planet. Custom precompiles don't. This lesson is why the answer is different, and how to register one.
1. Opcode vs precompile
| Opcode | Precompile | |
|---|---|---|
| Invocation | bytecode instruction | CALL to a special address |
| Adding | modifies interpreter | adds an entry in the precompile registry |
| Tooling impact | breaks Solidity, ABIs | mostly transparent |
| Use case | tight inner loops | heavy operations like pairings, hashing |
Real Ethereum already has precompiles at addresses 0x01–0x0a (ecrecover, sha256, ripemd160, identity, modexp, BN254 ops, BLAKE2F, point eval).
2. A real precompile — the identity precompile (0x04)
This is the entire identity_run from crates/precompile/src/identity.rs:
use super::calc_linear_cost;
use crate::{
eth_precompile_fn, EthPrecompileOutput, EthPrecompileResult, Precompile, PrecompileHalt,
PrecompileId,
};
use primitives::Bytes;
eth_precompile_fn!(identity_precompile, identity_run);
/// Address of the identity precompile.
pub const FUN: Precompile = Precompile::new(
PrecompileId::Identity,
crate::u64_to_address(4),
identity_precompile,
);
/// The base cost of the operation
pub const IDENTITY_BASE: u64 = 15;
/// The cost per word
pub const IDENTITY_PER_WORD: u64 = 3;
/// Takes the input bytes, copies them, and returns it as the output.
pub fn identity_run(input: &[u8], gas_limit: u64) -> EthPrecompileResult {
let gas_used = calc_linear_cost(input.len(), IDENTITY_BASE, IDENTITY_PER_WORD);
if gas_used > gas_limit {
return Err(PrecompileHalt::OutOfGas);
}
Ok(EthPrecompileOutput::new(
gas_used,
Bytes::copy_from_slice(input),
))
}
That's a production precompile in Ethereum mainnet. Read it line by line:
- Address
u64_to_address(4)→0x0000…0004. The address is part of thePrecompile::new— you don't get to put it anywhere; it's compiled in. - Gas formula
base + per_word * ceil(len / 32). Linear in input.IDENTITY_BASE = 15,IDENTITY_PER_WORD = 3— the real values from the Yellow Paper. - Halt vs revert —
PrecompileHalt::OutOfGasmeans the entire frame halts with no refund, distinct from a regular revert. EthPrecompileOutputcarries(gas_used, output_bytes).
3. Registering custom precompiles
sequenceDiagram
participant C as Contract bytecode
participant I as Revm interpreter
participant Reg as Precompiles registry
participant Fn as Custom precompile fn
C->>I: CALL 0x00...ff
I->>Reg: lookup(addr)
Reg-->>I: Found — Precompile
I->>Fn: run(input, gas_limit)
Fn-->>I: Ok(gas_used, output)
I->>C: returndata + gas refund
The Precompiles registry in crates/precompile/src/lib.rs has an extend method designed exactly for this:
pub fn extend(&mut self, other: impl IntoIterator<Item = Precompile>) {
let iter = other.into_iter();
let (lower, _) = iter.size_hint();
self.addresses.reserve(lower);
self.inner.reserve(lower);
for item in iter {
let address = *item.address();
if let Some(short_idx) = short_address(&address) {
self.optimized_access[short_idx] = Some(item.clone());
}
self.addresses.insert(address);
self.inner.insert(address, item);
}
}
So adding your own is just:
let my_pre = Precompile::new(
PrecompileId::Custom("my_thing"),
address!("00000000000000000000000000000000000000ff"),
my_function,
);
precompiles.extend([my_pre]);
my_function follows the same shape as identity_run: fn(&[u8], u64) -> EthPrecompileResult. Wire it through your custom Evm builder where the precompile set is loaded.
The optimized_access array
Notice the optimized_access[short_idx] write. For addresses that fit in a small number, Revm uses a flat array instead of a hashmap — dispatch becomes a single index lookup. This is why standard precompiles (0x01–0x0a) are essentially free to dispatch.
4. Real-world: Foundry's cheatcodes ARE custom precompiles
The most widely-deployed custom precompile in the Rust EVM stack lives in Foundry. Every vm.deal, vm.warp, vm.prank you've ever written in a Solidity test is a CALL to a custom precompile.
From forge-std/src/Base.sol:
address internal constant VM_ADDRESS = 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D;
That address is computed as:
address(uint160(uint256(keccak256("hevm cheat code"))))
Foundry constructs Revm with a custom precompile registered at this address that decodes the calldata as a cheatcode invocation (e.g., the selector for deal(address,uint256)) and dispatches into Foundry's Rust code. State mutations like "give this account 10 ETH" happen by directly modifying the in-memory Revm DB before continuing execution.
This is the production case study for the registration pattern in Section 3:
- Foundry forks the standard precompile set
- Registers an additional entry at
0x7109...pointing to its cheatcode dispatcher - The dispatcher reads calldata, matches a selector, and runs Rust to mutate the EVM state
If you want to see a production custom-precompile design in detail, read foundry-rs/foundry/crates/cheatcodes — it's the same pattern as our example, just at industrial scale (hundreds of cheatcodes, snapshotting, revert support).
5. When to reach for a precompile
- A computation is too expensive in pure EVM bytecode (BLS pairings, FRI verification, large-radix arithmetic)
- The same operation is needed by many contracts on your chain
- You can write a provably-correct Rust implementation
Don't add a precompile to save a few opcodes — the design overhead and consensus risk only pay off for genuinely heavy work.
6. Pricing
The cardinal rule: gas cost should track CPU cost (and ideally a multiple of the worst case). Underprice and an attacker DoSes your chain with a single weird transaction. Real Ethereum has been here multiple times — see the EIP-2929 reset of cold/warm storage costs.
A reasonable workflow:
- Benchmark your precompile on the slowest realistic input
- Multiply CPU time by an "abuse factor" (commonly 2–5x)
- Convert to gas via your chain's gas/CPU ratio
- Re-benchmark on adversarial inputs
After this, your precompile is cheap to use in normal code and prohibitively expensive to abuse.
Final check: you ship a precompile at gas cost = 100. An attacker discovers an input shape that takes 10x normal CPU time at the same 100 gas. What's the economic attack? How much does it cost the attacker per second of node CPU? If you can't sketch the math, you can't safely price a precompile — re-read Section 6 and Ethereum's EIP-2929 for what real underpricing has cost mainnet.
Summary (3 lines)
- Custom precompile = address + Rust function (input, gas_limit) → (output, gas_used). Magic-address dispatch via revm.
- Why: too-slow in Solidity (crypto) + shared utility (CLOB ops) + chain-specific (HyperEVM ops).
- Gas cost benchmarked; failure semantics deliberate. Production: Hyperliquid CLOB + Tempo attestation precompiles.