evmify 0.2.0

EVM-ify any chain: a generic JSON-RPC client for EVM chains, plus a modular adapter -> normalizer -> transformer pipeline that serves non-EVM chains (Solana, Bitcoin, ...) as EVM blocks, transactions, receipts and logs.
Documentation

evmify

CI crates.io docs.rs downloads license MSRV

Docs · Examples · Project page · Roadmap · Changelog

EVM-ify any chain. One read API for EVM and non-EVM chains, always answered in EVM JSON-RPC shape: query Solana or Bitcoin and get back the same blocks, transactions, receipts and logs your Ethereum code already understands.

  • EvmClient — a typed eth_* client that works against any EVM chain (Ethereum, OP-stack, Arbitrum, Polygon, BNB, Avalanche, zkSync, …). Fields the crate does not model (l1BlockNumber, deposit fields, …) are kept in other, never dropped.
  • A modular pipeline — Adapter → Normalizer → Transformer — that serves non-EVM chains as EVM blocks, transactions, receipts and logs. Solana and Bitcoin ship as reference adapters.
  • ChainProvider — the trait both implement, so Vec<Box<dyn ChainProvider>> can hold Ethereum, Base, Solana and Bitcoin side by side.
use evmify::{BlockId, ChainProvider, EvmClient, adapters::solana};

let chains: Vec<Box<dyn ChainProvider>> = vec![
    Box::new(EvmClient::connect("ethereum").await?),
    Box::new(EvmClient::connect("ronin").await?),
    Box::new(solana::connect("solana")?),
];

for chain in &chains {
    let block = chain.block(BlockId::Latest, true).await?.unwrap();
    // Same struct, same JSON, whichever chain it came from.
    println!("{} #{} {}", chain.chain().name, block.number, serde_json::to_string(&block)?);
}

Run the full example: cargo run --example multi_chain, and see every built-in chain with cargo run --example list_chains. More copy-paste commands (inspect any address on Ronin, Ethereum, Base…, look up a Solana transaction) are in EXAMPLES.md.

Chain shorthands and RPC endpoints

Everywhere an RPC URL is accepted you can pass a shorthand key instead: ethereum, base, arbitrum, ronin, polygon, solana, … (cargo run --example list_chains prints them all). A key resolves in this order:

  1. A URL wins. Anything containing :// is used as is: EvmClient::connect("https://my-node.example:8545").
  2. EVMIFY_RPC_<KEY>, if set: EVMIFY_RPC_RONIN=https://my-node.example evmify-app points ronin at your own node without code changes (upper case, - becomes _, e.g. EVMIFY_RPC_BASE_SEPOLIA).
  3. The chain's public RPC, taken from the chain's own documentation.

For a key, EvmClient::connect also checks the node's eth_chainId, so an override that points at the wrong network fails with Error::ChainMismatch instead of returning the wrong chain's data. chains::resolve_rpc exposes the same resolution if you want the URL itself.

Public endpoints are free but rate limited and meant for trying things out. Use your own node or a provider (Alchemy, QuickNode, Chainstack, dRPC, …) in production. Bitcoin has no public JSON-RPC, so bitcoin needs EVMIFY_RPC_BITCOIN=http://user:pass@host:8332.

Scanning logs over long ranges

Providers cap how many blocks one eth_getLogs call may cover (Ronin: 200) and how many calls you may make per second. EvmClient::scan_logs handles both:

let transfers = Filter::new().event("Transfer(address,address,uint256)");
let latest = client
    .scan_logs(transfers.clone().topic(1, me))  // from me …
    .or_filter(transfers.topic(2, me))          // … or to me
    .newest_first()                              // walk backwards from the head
    .limit(20)                                   // stop once 20 are found
    .max_blocks(10_000)
    .rate_limit(5.0)                             // requests per second
    .on_progress(|p| eprint!("\r{}/{} blocks", p.scanned, p.total))
    .run()
    .await?;

Windows that the node rejects as too large are halved and retried, and scan.plan() estimates the number of calls up front (e.g. to ask the user). client.blocks_in(Duration::from_secs(86_400)) turns "the last 24 h" into a block count using the chain's measured block time, and client.block_at(unix_secs) finds the exact block for a date by binary search on block timestamps. evmify keeps no state between calls: caching and storing results is up to your application.

The pipeline

 native RPC ──► Adapter ──► RawBlock/RawTx ──► Normalizer ──► NormalizedBlock/Tx ──► Transformer ──► EVM Block/Tx/Receipt/Log
               (fetch)                         (understand)                        (present)
Stage Trait Job Swap it when…
Adapter Adapter Talk to the native node, return raw serde structs you use a different provider/API (Helius, Esplora, …)
Normalizer Normalizer Raw payload → chain-agnostic NormalizedBlock / NormalizedTx / NormalizedEvent you want to decode programs/instructions differently
Transformer Transformer Normalized → output format (EvmTransformer by default) you want a different output (your own schema, Parquet rows, …)

Normalizers are pure and synchronous, so they are tested against JSON fixtures with no network. Every stage is generic, so swapping one never touches the others:

let btc = bitcoin::pipeline(bitcoin::BitcoinAdapter::mainnet(transport))
    .with_transformer(EvmTransformer::new(DefaultMapper::new(true)).with_scale(ValueScale::Native));

How non-EVM data maps onto EVM

EVM field Solana Bitcoin
block.number slot (skipped slots → None) height
block.hash keccak256(blockhash) block hash (unchanged)
block.miner leader (fee reward recipient) first coinbase output address
tx.hash keccak256(signature) txid (unchanged, reversible)
tx.from fee payer first input's address (0x0 for coinbase)
tx.to / value recipient + amount for plain SOL transfers, otherwise first invoked program + 0 first non-change output + total paid excluding change
gasUsed × effectiveGasPrice fee fee
status meta.err == null 1 once in a block
logs BalanceChange(address,int256) per SOL/SPL balance change, plus ERC-20 Transfer when a mint has one sender and one receiver ERC-20 Transfer(from, output, sats) per output
_native signature, raw accounts, instructions, compute units, program logs inputs, outputs, coinbase flag

Details worth knowing:

  • Addresses. Non-hex native addresses become keccak256(utf8(native))[12..]. The original is always in _native. Plug in your own IdMapper for a different scheme.
  • Amounts. By default native amounts are rescaled to 18 decimals (ValueScale::Decimals(18)) so EVM tooling formats 1 SOL / 1 BTC like 1 ETH. Token amounts are never rescaled. Use ValueScale::Native for lamports/satoshis.
  • Native coin logs are emitted from 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, the usual native-token placeholder.
  • Hash lookups. Bitcoin hashes are reversible. Solana signatures hash one-way, so transaction(hash) works for anything the pipeline has seen in a block (IndexedMapper, last 100k); otherwise use transaction_by_native_id(signature). For an indexer, back IdMapper with your database.
  • logs() on non-EVM chains scans blocks one by one and is capped by max_log_range (default 50).
  • Bitcoin needs Core 25+ (prevouts in getblock verbosity 3) and -txindex=1 for tx lookups. Auth: HttpTransport::new(url).basic_auth(user, Some(pass)).

Transports

Transport is one async method, so anything can sit behind it.

  • HttpTransport (feature http, on by default) — reqwest + rustls, bearer or basic auth, a shared request budget (.max_rps(5.0)), and automatic retries with backoff when a provider answers HTTP 429 / 503 (.max_retries(n)).
  • FallbackTransport — several endpoints, moves on only on network failures (an RPC error like execution reverted is returned straight away).
  • MockTransport — canned responses + call log, for your own tests.

Build with default-features = false to bring your own HTTP stack.

Request a chain

Missing a chain? Open a chain request, or 👍 an existing one. The most-wanted chains are built first. Questions and ideas go in Discussions.

Adding a chain

EVM chains already work with EvmClient and a URL, with no code needed. To give one a shorthand key, a proper name and (optionally) its public RPC, add one line with the chain's EIP-155 chain ID to KNOWN_CHAINS in src/chains.rs; see CONTRIBUTING.md.

Non-EVM chains need an adapter; see CONTRIBUTING.md. In short: raw serde structs, an Adapter that fetches them, a Normalizer that maps them, a pipeline() helper, and fixture tests. The EVM output comes for free.

Scope

Read-side only for now. EvmClient::send_raw_transaction broadcasts pre-signed EVM transactions; signing and non-EVM writes are out of scope.

Roadmap ideas

Full address history via indexers, a JSON-RPC server that lets viem/ethers read Solana and Bitcoin unchanged, NEO and more chains, and smaller improvements: see FUTURE.md, including why block explorers are instant while eth_getLogs scans are not.

Author

Built and maintained by Mads Paaskesen, Hardcore Websmith at MP-IT. Need a chain adapter, an indexer, payment monitoring or a Rust / Node.js backend around evmify? I take on freelance work: get in touch.

License

Dual-licensed under MIT or Apache-2.0, at your option.