Onchain

Ethereum library with RPC, ABI encoding/decoding and transaction signing. The former hieroglyph and cartouche libraries ship here; since 0.16.0 their ABI.* and Cartouche.* modules are Onchain.ABI.* and Onchain.* (see the CHANGELOG migration section). ABI, transaction and EIP-712 codecs use the core alloy Rust NIF. Cryptography uses the existing Keccak and secp256k1 NIF dependencies.

Package Family

Package Purpose Deps
onchain (this) Core Ethereum primitives, RPC, ABI, signing descripex, zen_websocket, crypto dependencies
onchain_aave Aave V3 protocol wrappers onchain
onchain_aerodrome Aerodrome bindings and analytics on Base onchain
onchain_solana Solana RPC, transactions, tokens and Ed25519 signing onchain
onchain_evm Rust NIFs: revm simulation, Solidity parsing, codegen onchain + rustler
onchain_js JS bridge: npm packages on the BEAM via QuickBEAM onchain + quickbeam
onchain_tempo Tempo chain primitives: 0x76 transactions, TIP-20 encoding onchain

EVM simulation and the JavaScript bridge remain separate optional packages.

Installation

def deps do
[
{:onchain, "~> 0.16"},
# Add if you need Aave:
{:onchain_aave, "~> 0.1"},
# Add if you need EVM simulation / Solidity parsing:
{:onchain_evm, "~> 0.1"},
# Add if you need JS bridge (solc-js, Uniswap SDK, etc.):
{:onchain_js, "~> 0.1"},
# Add if you need Tempo chain (0x76 transactions, TIP-20 tokens):
{:onchain_tempo, "~> 0.1"}
]
end

Requires an Ethereum JSON-RPC endpoint. Configure via:

# config/config.exs
config :cartouche, :ethereum_node, "https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"

Or pass rpc_url: url or ethereum_node: url per call to either RPC module (:rpc_url takes precedence). Without either option or the application default, requests return {:error, {:missing_option, :ethereum_node}}.

Single calls and JSON-RPC array batches now share Onchain.RPC's transport. Configure transport defaults with config :cartouche, Onchain.RPC, [...], then config :cartouche, :req_options, [...]; per-call req_options: [...] takes highest precedence. Migrate former batch settings under :onchain, Onchain.RPC and :onchain, :req_options to these :cartouche keys for RPC. The :onchain settings still apply to the CCIP-Read HTTP gateway.

Opt into transport retries with retry: [max_retries: 2, backoff_ms: 100] on raw, typed, or batch calls. JSON-RPC errors are final; decoding does not resend a request. Each call emits one [:onchain, :rpc, :request] telemetry span, including all retry attempts, with the existing method, status, and error metadata. All RPC calls go through Onchain.RPC and return the same tagged node refusals.

Node compatibility

Onchain is built to run against any mainstream Ethereum JSON-RPC endpoint — Alchemy, Infura, QuickNode, a self-hosted Geth/reth/Erigon, pruned or archive. That portability is a deliberate constraint, not an accident: the maintainers develop against a full archive node, and anything that only works there is treated as a bug.

Everything in Onchain.RPC, Onchain.ERC20, Onchain.ERC721, Onchain.ERC1155, Onchain.Contract, Onchain.Block, Onchain.Multicall and Onchain.ENS uses standard methods and needs no special endpoint. These surfaces depend on what your provider serves:

Surface Requirement Symptom without it
Historical reads — any block parameter older than ~128 blocks (eth_call, eth_getBalance, eth_feeHistory at an old block) an archive node, or a hosted plan that retains history {:error, {:unavailable, map}} (-32001 Unable to complete request on Alchemy), or a "missing trie node" error, depending on client
Onchain.RPC.eth_get_storage_at/3 and eth_get_proof/3 historical state/proof retention is endpoint-specific: Alchemy served DAI at block 18,000,000 on 2026-10-01; the archive node refused that proof raw %{code: -32602, message: "distance to target block exceeds maximum proof window"} for the observed archive proof refusal; see verbatim probes
Onchain.Subscription (eth_subscribe) a WebSocket endpoint (wss://), which not every plan includes connection refused, or {:error, {:method_not_found, map}} over HTTP
trace_* / debug_* on a free hosted plan a plan that serves that namespace {:error, {:namespace_unavailable, map}} (Alchemy: -32600 "...not available on the Free tier")
Methods the node does not implement (eth_getBlockAccessList, eth_baseFee, …) a node that serves them. The next-block base fee does not need eth_baseFee: Onchain.RPC.base_fee/1 reads eth_feeHistory(1, "latest", []) {:error, {:method_not_found, map}}

Each of those error terms is classified on the shared Onchain.RPC transport path so a codegen'd wrapper, a hand-written wrapper, call/3 and batch/2 apply the same rules to the same wire response. Note that a provider may not send the same wire response in both modes — Alchemy reports pruned history as -32001 to a single call but as a generic -32000 "Internal error" inside an array batch, so a batched capability probe can come back unclassified. Unrecognized JSON-RPC codes still arrive as {:error, {:rpc_error, map}}. See Onchain.RPC's moduledoc § "Node-capability refusals" for the pinned message shapes and for the finding that -32001 is not uniquely pruned history (Alchemy answers it for some unimplemented methods too).

Onchain.RPC.base_fee/1 returns the next block's fee from eth_feeHistory, which Alchemy and Infura mainnet serve; it does not call eth_baseFee or read the pending header. Onchain.RPC.blob_base_fee/1 still wraps eth_blobBaseFee. The probe, the hosted refusals, and why fee history won are in docs/base-fee-portability.md.

If you hit a method that works on your node but not on a common hosted provider, that's a portability bug worth reporting.

Quick Start

# Read an ERC-20 token balance (USDC on mainnet)
usdc = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
{:ok, balance} = Onchain.ERC20.balance_of(usdc, "0xYourAddress")
# Resolve an ENS name (UTS-46/ENSIP-15 normalized before namehash)
{:ok, address} = Onchain.ENS.resolve("vitalik.eth")
# Multi-coin / wildcard / CCIP-Read resolution: walks parent labels for a
# wildcard resolver (ENSIP-10) and follows EIP-3668 OffchainLookup reverts.
{:ok, eth_bytes} = Onchain.ENS.address("vitalik.eth", 60)
{:ok, op_bytes} = Onchain.ENS.address("name.eth", Onchain.ENS.evm_coin_type(10))
# Generic contract call (encode -> eth_call -> decode)
{:ok, [name]} = Onchain.Contract.call(usdc, "name()", [], "(string)")
# All functions have bang variants that raise on error
balance = Onchain.ERC20.balance_of!(usdc, "0xYourAddress")
# EIP-1559 fee suggestion: fetch history, compute base/priority/max in one go.
# `:reward_percentiles` is required — non-empty, monotonically non-decreasing list of integers in 0..100.
{:ok, history} = Onchain.RPC.fee_history(20, reward_percentiles: [50])
{:ok, {base_fee, max_priority, max_fee}} = Onchain.Fees.suggest_fees(history)
# Gas-limit estimation (eth_estimateGas) over a tx-params map
{:ok, gas} = Onchain.RPC.eth_estimate_gas(%{from: from, to: token, data: calldata})
# send_transaction auto-estimates the gas limit (with 1.25x headroom) when :gas_limit
# is omitted — so stale hardcoded limits can't OOG-revert. Pass :gas_limit to opt out.
{:ok, tx_hash} =
Onchain.ERC20.transfer(token, recipient, amount,
private_key: key, nonce: nonce, chain_id: 1, rpc_url: url
)
# Decode a Solidity 0.8.4+ custom-error revert against a list of candidate signatures
{:ok, %{error: "OwnableUnauthorizedAccount", args: [_addr]}} =
Onchain.ABI.decode_hex_error(
"0x118cdaa7000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045",
["OwnableUnauthorizedAccount(address)"]
)
# After an eth_call that reverts with a custom error, use :data from the rpc_error map:
# {:error, {:rpc_error, %{data: revert_hex}}} -> Onchain.ABI.decode_hex_error(revert_hex, [...])

Modules

Core

Module Purpose
Onchain.Hex Hex encoding/decoding (hex<->binary, hex<->integer, 0x prefix), plus the convenience names decode/1, encode/1, to_integer/1, from_integer/1, valid?/1
Onchain.ABI ABI encoding/decoding. Binary codecs stay encode/2, decode/3, encode_call/3, decode_call/3, decode_error/3. Hex conveniences are encode_hex_call/2, decode_hex_call/3, decode_hex_error/3, decode_response/3, and decode_types/3. strict: true rejects non-canonical payloads as {:error, {:decode_error, {:strict_violation, detail}}}. Events are event_signature/1 and decode_event/4
Onchain.Address Address validation, EIP-55 checksum, normalization
Onchain.Decimal Decimal precision helpers (to_decimal, div_pow10, to_basis_points)
Onchain.Fees EIP-1559 fee recommendation (suggest_fees/2) over Onchain.FeeHistory.t() — pure function, returns {base_fee, max_priority, max_fee}
Onchain.RPC Ethereum JSON-RPC wrapper (eth_call, eth_estimate_gas, receipts, nonces, balances, block_number, chain_id, decoded get_block_by_number, EIP-7928 block access lists, eth_get_code, eth_send_raw_transaction, fee_history, base_fee, blob_base_fee; call/3 for any other method; batch/2 for JSON-RPC array batching). Opt-in retry: [max_retries: n, backoff_ms: ms] on single and batch paths retries transport failures only (default: no retry). Block access lists preserve the node's raw camelCase response. get_block_by_number/2 returns atom-keyed maps (quantities as integers). eth_getStorageAt and EIP-1186 proofs are Onchain.RPC.eth_get_storage_at/3 (32-byte binary) and eth_get_proof/3 (Onchain.RPC.Proof). Stateless eth_getLogs is Onchain.RPC.eth_get_logs/2, returning [%Onchain.Filter.Log{}]. It accepts atom keys or canonical camelCase string aliases ("fromBlock", "toBlock", "blockHash", "address", "topics"); :block_hash is mutually exclusive with :from_block/:to_block. Receipt logs are %Onchain.Filter.Log{} (removed is nil when the node omits it). eth_syncing, block transaction counts, net_listening, net_peerCount, and web3_clientVersion are Onchain.RPC.eth_syncing/1, eth_get_block_transaction_count_by_hash/2, eth_get_block_transaction_count_by_number/2, net_listening/1, net_peer_count/1, and web3_client_version/1. Transaction objects are Onchain.RPC.eth_get_transaction_by_hash/2, eth_get_transaction_by_block_hash_and_index/3, and eth_get_transaction_by_block_number_and_index/3 (%Onchain.Transaction.Info{}; a null result is {:error, :not_found}). Block receipts are Onchain.RPC.eth_get_block_receipts/2 ([%Onchain.Receipt{}])
Onchain.RPC.Helpers Shared RPC helpers (hex normalization, block tags, tx hash validation; parse_block_response/1, parse_transaction_map/1; execution-revert maps get :data hex for Onchain.ABI.decode_hex_error/2)
Onchain.Block Full decoded block plus get_by_number/2 and timestamp binary search. Hashes are 32-byte binaries. A null RPC block is {:error, :block_not_found}; a pending block is {:error, :pending_block}
Onchain.Contract Generic contract call (encode -> eth_call -> decode in one function)
Onchain.Contract.Generator Compile-time codegen from ABI JSON (use with :abi_json or :abi_file). .sol inputs need onchain_evm
Onchain.Multicall Batch multiple eth_call via Multicall3
Onchain.Sleuth.deploy_query/5 Deploy-as-call: ship creation bytecode in one eth_call, decode returned bytes
Onchain.Filter.Log Decoded log struct for eth_getLogs and receipt logs (removed is nil when the node omits it)
Onchain.Signer Key management and transaction signing
Onchain.ERC20 ERC-20 read (balanceOf, allowance, decimals, symbol, totalSupply) and write (transfer, approve)
Onchain.ERC721 ERC-721 NFT reads (owner_of, token_uri, balance_of, name, symbol, get_approved, approved_for_all?)
Onchain.ERC1155 ERC-1155 multi-token reads (balance_of, balance_of_batch, uri, approved_for_all?)
Onchain.ERC7730 ERC-7730 clear-signing: load a descriptor (load/1), bind it to calldata / EIP-712 / UserOp and render human-readable display fields (format/2, format!/2)

Chain Intelligence

Module Purpose
Onchain.Wallet Classify address (EOA/contract), native ETH balance
Onchain.Transfer Parse ERC-20/721/1155 Transfer events into normalized structs
Onchain.MEV MEV protection — submit signed txs/bundles to a Flashbots-style private relay (send_private_transaction/2, send_bundle/2); caller-supplied :endpoint (no public-node fallback) + :headers auth
Onchain.ENS ENS name resolution: forward (resolve/2), multi-coin + wildcard + CCIP-Read (address/3, ENSIP-9/10 + EIP-3668), reverse, text records, contenthash, ABI, pubkey; UTS-46/ENSIP-15 normalize/1, ENSIP-10 dns_encode/1, ENSIP-11 evm_coin_type/1
Onchain.ENS.Normalize UTS-46 / ENSIP-15 name normalization (deterministic subset: case-fold + NFC + ignored/disallowed code points; not the confusable/script-mixing security filters)
Onchain.ENS.CCIP EIP-3668 CCIP-Read pure helpers (OffchainLookup parse, gateway-request shaping, callback calldata) + bounded gateway round-trip loop
Onchain.Subscription Real-time streaming via eth_subscribe (newHeads, pendingTx, logs)
Onchain.Subscription.Parser Pure parsing for eth_subscribe notification payloads (newHeads, pendingTx, logs)

DeFi

Module Purpose
Onchain.DEX.Router Optimal swap-path routing across Uniswap v2/v3-style pools — pure-Elixir constant-product math for v2, on-chain QuoterV2 eth_call for v3 (route/5, quote_pool/4, amount_out_v2/4)

Account Abstraction (ERC-4337)

Module Purpose
Onchain.AA ERC-4337 UserOperation hashing (user_op_hash/4), signing (sign_user_operation/5 — :eip191/:raw), and bundler JSON-RPC (send_user_operation/3, estimate_user_operation_gas/3, get_user_operation_by_hash/2, get_user_operation_receipt/2, supported_entry_points/1). Handles both v0.6 and v0.7 EntryPoint wire formats; user_op_hash verified against viem reference vectors
Onchain.AA.UserOperation Version-agnostic UserOperation struct (numeric fields as integers, byte fields as 0x hex, optional v0.7 factory/paymaster fields). Build with Onchain.AA.new/1

Most read functions (Onchain.RPC, Onchain.ERC20/ERC721/ERC1155, Onchain.Block, Onchain.DEX.Router.amount_out_v2, …) expose a function!/1 bang variant that raises on error instead of returning {:error, reason}. Newer composite modules (Onchain.MEV, Onchain.AA, Onchain.ERC7730, Onchain.DEX.Router.route/quote_pool) return tagged tuples only — no bang variant.

Real-time Subscriptions

Stream new blocks, pending transactions, and event logs via WebSocket:

# Connect to a WebSocket endpoint
{:ok, sub} = Onchain.Subscription.connect("wss://eth-mainnet.g.alchemy.com/v2/KEY")
# Subscribe to new block headers
{:ok, sub_id} = Onchain.Subscription.subscribe(sub, :new_heads)
# Events arrive as messages to the calling process
receive do
{:subscription, {:new_heads, ^sub_id, head}} ->
IO.inspect(head.number, label: "new block")
end
# Or provide a custom handler
{:ok, sub} = Onchain.Subscription.connect("wss://...",
handler: fn {:new_heads, _id, head} -> Logger.info("Block #{head.number}") end
)
# Subscribe to filtered event logs
{:ok, _} = Onchain.Subscription.subscribe(sub, {:logs, %{
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
topics: [Onchain.Transfer.transfer_topics()]
}})
# Clean up
Onchain.Subscription.unsubscribe(sub, sub_id)
Onchain.Subscription.close(sub)

Requires a WebSocket-capable endpoint (wss:// or ws://), separate from the HTTP RPC URL.

Discovery

All modules use descripex for self-describing APIs:

Onchain.describe() # List of all annotated modules (one summary per module)
Onchain.describe(:hex) # Function summary list for a module
Onchain.describe(:hex, :decode) # Function detail map (params, errors, returns)

Testing

mix test.json --quiet # Unit tests (no RPC needed)
mix test.json --quiet --include integration # Integration tests (requires RPC)

Differential tests compare Onchain.RPC against independently decoded raw JSON-RPC on the same node (opt-in, requires mainnet RPC):

export ONCHAIN_DIFFERENTIAL_TESTS=1
export ETHEREUM_API_URL="https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"
mix test.json --quiet --include differential test/onchain/differential

Integration tests require an Ethereum RPC endpoint:

export ETHEREUM_API_URL="https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"

WebSocket subscription tests require a WebSocket endpoint:

export ETHEREUM_WS_URL="wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"

Sepolia write tests additionally require ETH_SEPOLIA_RPC_URL and SIGNER_PRIVATE_KEY.

License

MIT