Pure Elixir Ethereum library. Provides read (eth_call) and write (transaction signing) capabilities using cartouche as the sole Ethereum dependency. No native deps, no Rustler.
| Package | Purpose | Deps |
|---|---|---|
| onchain (this) | Core Ethereum primitives, RPC, ABI, signing | cartouche |
| onchain_aave | Aave V3 protocol wrappers | 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 |
Pick what you need — consumers who only need eth_call never compile Rust or Zig.
def deps do
[
{:onchain, "~> 0.7"},
# 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"}
]
endRequires 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 the URL per-call to Onchain.RPC functions.
# 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)
# Decode a Solidity 0.8.4+ custom-error revert against a list of candidate signatures
{:ok, %{error: "OwnableUnauthorizedAccount", args: [_addr]}} =
Onchain.ABI.decode_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_error(revert_hex, [...])| Module | Purpose |
|---|---|
Onchain.Hex |
Hex encoding/decoding (hex<->binary, hex<->integer, 0x prefix) |
Onchain.ABI |
ABI encoding/decoding for contract calls (encode_call/2, decode_response/2, decode_types/2, decode_call/3 for selector-prefixed calldata, decode_error/2 for Solidity 0.8.4+ custom-error revert data) |
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 Cartouche.FeeHistory.t() — pure function, returns {base_fee, max_priority, max_fee} |
Onchain.RPC |
Ethereum JSON-RPC wrapper (eth_call, eth_getLogs, receipts, nonces, balances, block_number, chain_id, decoded get_block_by_number, get_transaction_by_hash, eth_get_code, eth_send_raw_transaction, syncing, fee_history, get_proof; call/3 for any other method; batch/2 for JSON-RPC array batching). Opt-in retry: [max_retries: n, backoff_ms: ms] on single-call paths retries transport failures only (default: no retry). get_block_by_number/2 returns atom-keyed maps (quantities as integers — aligned with get_transaction_by_hash/2). eth_get_logs/2 accepts atom keys or canonical camelCase string aliases ("fromBlock", "toBlock", "blockHash", "address", "topics"); :block_hash is mutually exclusive with :from_block/:to_block per EIP-1474 |
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 decode_error/2) |
Onchain.Block |
Block fetching with parsed fields, timestamp-based binary search |
Onchain.Contract |
Generic contract call (encode -> eth_call -> decode in one function) |
Onchain.Multicall |
Batch multiple eth_call via Multicall3 |
Onchain.Sleuth |
Deploy-as-call: ship creation bytecode in one eth_call, decode returned bytes |
Onchain.Log |
Event log parsing against ABI signatures |
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) |
| 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) |
| 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) |
| 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.
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.
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)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 Cartouche.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/differentialIntegration 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.