Onchain

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 Family

PackagePurposeDeps
onchain (this)Core Ethereum primitives, RPC, ABI, signingcartouche
onchain_aaveAave V3 protocol wrappersonchain
onchain_evmRust NIFs: revm simulation, Solidity parsing, codegenonchain + rustler
onchain_jsJS bridge: npm packages on the BEAM via QuickBEAMonchain + quickbeam
onchain_tempoTempo chain primitives: 0x76 transactions, TIP-20 encodingonchain

Pick what you need — consumers who only need eth_call never compile Rust or Zig.

Installation

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"}
]
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 the URL per-call to Onchain.RPC functions.

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)
# 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, [...])

Modules

Core

ModulePurpose
Onchain.HexHex encoding/decoding (hex<->binary, hex<->integer, 0x prefix)
Onchain.ABIABI 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.AddressAddress validation, EIP-55 checksum, normalization
Onchain.DecimalDecimal precision helpers (to_decimal, div_pow10, to_basis_points)
Onchain.FeesEIP-1559 fee recommendation (suggest_fees/2) over Cartouche.FeeHistory.t() — pure function, returns {base_fee, max_priority, max_fee}
Onchain.RPCEthereum JSON-RPC wrapper (eth_call, eth_getLogs, receipts, nonces, balances, block_number, chain_id, decodedget_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.HelpersShared 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.BlockBlock fetching with parsed fields, timestamp-based binary search
Onchain.ContractGeneric contract call (encode -> eth_call -> decode in one function)
Onchain.MulticallBatch multiple eth_call via Multicall3
Onchain.SleuthDeploy-as-call: ship creation bytecode in one eth_call, decode returned bytes
Onchain.LogEvent log parsing against ABI signatures
Onchain.SignerKey management and transaction signing
Onchain.ERC20ERC-20 read (balanceOf, allowance, decimals, symbol, totalSupply) and write (transfer, approve)
Onchain.ERC721ERC-721 NFT reads (owner_of, token_uri, balance_of, name, symbol, get_approved, approved_for_all?)
Onchain.ERC1155ERC-1155 multi-token reads (balance_of, balance_of_batch, uri, approved_for_all?)
Onchain.ERC7730ERC-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

ModulePurpose
Onchain.WalletClassify address (EOA/contract), native ETH balance
Onchain.TransferParse ERC-20/721/1155 Transfer events into normalized structs
Onchain.MEVMEV 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.ENSENS 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.NormalizeUTS-46 / ENSIP-15 name normalization (deterministic subset: case-fold + NFC + ignored/disallowed code points; not the confusable/script-mixing security filters)
Onchain.ENS.CCIPEIP-3668 CCIP-Read pure helpers (OffchainLookup parse, gateway-request shaping, callback calldata) + bounded gateway round-trip loop
Onchain.SubscriptionReal-time streaming via eth_subscribe (newHeads, pendingTx, logs)
Onchain.Subscription.ParserPure parsing for eth_subscribe notification payloads (newHeads, pendingTx, logs)

DeFi

ModulePurpose
Onchain.DEX.RouterOptimal 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)

ModulePurpose
Onchain.AAERC-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.UserOperationVersion-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 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/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