Coming from ethers.js / viem

Copy Markdown View Source

A translation table from the two JavaScript libraries most readers (human or model) already know to the Cartouche calls that do the same job — and, in the last column, where the semantics differ. Cartouche is not a port of either: it is an idiomatic Elixir substrate that happens to cover the same ground. The differences column is the part worth reading; the name mappings are the part worth searching.

Verified against cartouche 0.9.1 (2026-09-16). ethers refers to v6, viem to 2.x. ABI rows point into hieroglyph, Cartouche's ABI dependency, because that is where the ABI.* modules live.

Read this first — four rules that are not in the table

  1. Bytes, not hex strings. Every address, hash, signature, calldata and return value is a raw binary (<<_::160>> for an address, <<_::256>> for a word). Hex strings exist only at the JSON-RPC boundary and in your own logs. Cartouche.Hex.from_hex!/1 on the way in, Cartouche.Hex.to_hex/1 on the way out — or use Cartouche.Hex and write ~h[0x…]. Passing a "0x…" string where an address is expected does not error early; it produces a 42-byte binary and fails somewhere downstream.
  2. Cartouche.Signer.sign/3 is not signMessage. It keccaks the bytes it is given and signs that digest — no EIP-191 prefix, and v is EIP-155-encoded (chain_id * 2 + 35 + recid) by default, which can exceed one byte. Cartouche.Signer.sign_message/3 is the wallet-compatible form — it adds the prefix and fixes v to 27/28; use it wherever ethers or viem would call signMessage.
  3. A signer is a process, not a value. new Wallet(pk) becomes a supervised Cartouche.Signer GenServer (started from config or Cartouche.Signer.start_link/1), addressed by name or pid through the signer: option. Cartouche.Signer.Curvy is the raw-key backend if you need a function call without a process.
  4. Nothing validates an EIP-55 checksum. Cartouche.Hex.checksum_address/1 produces one; Cartouche.Hex.decode_address!/1 checks length only. ethers' getAddress throws on a wrongly cased input — Cartouche accepts it.

Results are {:ok, value} | {:error, reason} tuples; !-suffixed functions raise. Options are trailing keyword lists. Integers are native arbitrary precision — there is no BigNumber/bigint distinction anywhere.

Bytes and hex

ethers v6viemCartoucheDifference
getBytes("0x…")hexToBytesCartouche.Hex.from_hex!/1, or ~h[0x…]—
hexlify(bytes)bytesToHexCartouche.Hex.to_hex/1always lowercase, always 0x-prefixed
toQuantity(n)numberToHex(n)Cartouche.Hex.encode_quantity/1— (minimal hex, "0x0" for zero)
toBigInt("0x…")hexToBigIntCartouche.Hex.decode_hex_number!/1returns a plain integer
zeroPadValue(v, 32)pad(v, { size: 32 })Cartouche.Hex.pad/2binaries only; raises if the input is already longer
zeroPadBytes(v, 32)pad(v, { dir: "right" })Cartouche.Hex.pad_right/2—
dataSlice, concatslice, concatbinary pattern matching, <>no helpers — the language does it
encodeBase58 / decodeBase58—Cartouche.Base58.encode/1, decode/1, decode!/1decode/1 returns {:ok, bytes}

Units

ethers v6viemCartoucheDifference
parseEther("1.5")parseEther("1.5")Cartouche.Wei.to_wei({Decimal.new("1.5"), :eth})input is Decimal or integer, never a string or float; the atom is :eth (:ether is rejected); a fraction finer than 1 wei raises — viem rounds, ethers throws
parseUnits("5", "gwei")parseGwei("5")Cartouche.Wei.to_wei({5, :gwei})integer gwei only — there is no {Decimal, :gwei} clause
parseUnits(x, decimals)parseUnits(x, decimals)—no arbitrary-decimals helper; multiply integers yourself
formatEther, formatUnitsformatEther, formatUnits—not in Cartouche; nothing renders wei back to a decimal string
a bigint amounta bigint amountan integer, or {n, :wei | :gwei | :eth} anywhere a fee or value is takenthe tagged tuple is accepted by every constructor and by execute_trx/3 options

Hashing and addresses

ethers v6viemCartoucheDifference
keccak256(bytes)keccak256(bytes)Cartouche.Hash.keccak/1returns 32 raw bytes
id("Transfer(address,uint256)")toEventSelector / keccak256(toBytes(s))Cartouche.Hash.keccak/1 on the string, or ABI.Event.event_signature/1 from a selector—
getAddress("0x…")getAddressCartouche.Hex.checksum_address/1 (string → string) or Cartouche.Hex.to_address/1 (bytes → string)produces EIP-55 but never validates it — see rule 4
isAddressisAddressCartouche.Hex.decode_address!/1 (raises otherwise)length check only, no checksum check
computeAddress(pubkey)publicKeyToAddressCartouche.Address.from_public_key/1SEC1 uncompressed 0x04‖X‖Y (65 bytes) only; compressed keys are not accepted
getCreateAddress, getCreate2AddressgetContractAddress—not in Cartouche

Keys and signers

ethers v6viemCartoucheDifference
Wallet.createRandom()generatePrivateKey() + privateKeyToAccountCartouche.Keys.generate_keypair/0 → {address, private_key}32 random bytes, no mnemonic (BIP-39/32 is roadmap task 9015)
new Wallet(pk)privateKeyToAccount(pk)config signer: [default: {:priv_key, "0x…"}], or Cartouche.Signer.start_link(mfa: {Cartouche.Signer.Curvy, :sign, [pk]}, name: MySigner)a process — see rule 3; the :default entry registers as Cartouche.Signer.Default and is what every signer:-less call uses
wallet.addressaccount.addressCartouche.Signer.address/120 raw bytes, fetched from the process
— (address from a key, no object)privateKeyToAddressCartouche.Signer.Curvy.get_address/1—
wallet.signingKey.publicKeyprivateKeyToAccount(pk).publicKeyCartouche.Signer.Curvy.public_key/1—
signingKey.sign(digest)sign({ hash, privateKey })Cartouche.Signer.Curvy.sign_digest/2returns %Curvy.Signature{} without a recovery id; Cartouche.Recover.find_recid_from_digest/3 recovers it against the expected address
a KMS signer (third-party plugins)toAccount with a custom sign{:cloud_kms, credentials, key_path, version} signer specGCP Cloud KMS is a first-class backend; KMS emits no recovery bit, so Cartouche tries all four

Messages and typed data

ethers v6viemCartoucheDifference
signer.signMessage(msg)signMessageCartouche.Signer.sign_message/3— (sign/3 is the unprefixed, EIP-155 primitive — see rule 2)
hashMessage(msg)hashMessageCartouche.Hash.keccak(Cartouche.Recover.prefix_eth(msg))—
verifyMessage(msg, sig)recoverMessageAddress / verifyMessageCartouche.Recover.recover_personal_sign/2returns the 20-byte address — compare it yourself; accepts packed r‖s‖v with v in {0, 1, 27, 28, EIP-155} or a %Curvy.Signature{}
recoverAddress(digest, sig)recoverAddressCartouche.Recover.recover_eth_from_digest/2—
——Cartouche.Recover.recover_eth/2keccaks the message first and applies no prefix — the inverse of Cartouche.Signer.sign/3, with no ethers equivalent
TypedDataEncoder.encode(d, t, v)—Cartouche.Typed.encode/1same 0x1901 ‖ domainSeparator ‖ hashStruct bytes
TypedDataEncoder.hash(d, t, v)hashTypedDataCartouche.Hash.keccak(Cartouche.Typed.encode(typed))—
signer.signTypedData(d, t, v)signTypedDataCartouche.Signer.sign_typed_data/3— (sign/3 on Typed.encode/1 gives the same digest with an EIP-155 v)
verifyTypedDatarecoverTypedDataAddressCartouche.Recover.recover_eth(Cartouche.Typed.encode(typed), sig)—
TypedDataEncoder.hashStructhashStructCartouche.Typed.hash_struct/3—
TypedDataEncoder.hashDomainhashDomainCartouche.Typed.domain_seperator/1note the spelling
eth_signTypedData_v4 JSON payloadsameCartouche.Typed.deserialize/1expects domain / types / value keys — a wallet payload's message must be renamed; primaryType is not read (inferred from value's field names)

Transactions

Cartouche has one struct per envelope type and no field-based type inference: Cartouche.Transaction.V1 (legacy), V_2930 (type 1), V2 (EIP-1559), V3 (EIP-4844), V4 (EIP-7702).

ethers v6viemCartoucheDifference
Transaction.from({ to, value, … })prepareTransactionRequestCartouche.Transaction.V2.new(nonce, max_priority_fee, max_fee, gas_limit, to, value, data, access_list, chain_id)positional constructor per type, not a request object; pick the struct yourself
Transaction.from(serialized)parseTransactionCartouche.Transaction.decode/1dispatches on the envelope byte; {:ok, struct}
tx.serializedserializeTransactionCartouche.Transaction.encode/1raw bytes — to_hex/1 before JSON
tx.fromrecoverTransactionAddressCartouche.Transaction.V2.recover_signer/1, V1.recover_signer/2V1 needs the chain id passed in
tx.hashkeccak256(serialized)V3.hash/1, V4.hash/1; for V1/V2 Cartouche.Hash.keccak(Cartouche.Transaction.encode(tx))no hash/1 on V1 and V2
wallet.signTransaction(tx)signTransactionCartouche.Transaction.build_signed_trx_v2/9 (builds and signs), build_signed_trx/7 for legacy; V3.sign/2, V4.sign/2 on an existing structV1/V2 have no sign/2 — the builder is the signing path; its :callback option runs between construction and signing
wallet.sendTransaction(tx)sendTransaction / writeContractCartouche.RPC.execute_trx(to, calldata, opts)fetches nonce, fees and gas, runs eth_call first (verify: true is the default), signs, broadcasts — simulateContract + writeContract in one call; Cartouche.RPC.prepare_trx/3 is the same without the broadcast
provider.broadcastTransaction(raw)sendRawTransactionCartouche.RPC.send_trx/2 on a signed V1/V2 structV3/V4 go through Cartouche.RPC.send_rpc("eth_sendRawTransaction", [to_hex(raw)])
authorization signing (7702)signAuthorizationCartouche.Transaction.V4.sign_authorization/2 on {chain_id, address, nonce}—
an RPC transaction objectsameCartouche.Transaction.V2.from_json/1 (and per type)—

RPC reads

There is no provider or client value: every function takes ethereum_node: url in its trailing opts (defaulting to config :cartouche, :ethereum_node). A client struct is roadmap task 9017 in onchain.

ethers v6viemCartoucheDifference
new JsonRpcProvider(url)createPublicClient({ transport: http(url) })—ethereum_node: option per call, or application config
provider.send(method, params)client.request({ method, params })Cartouche.RPC.send_rpc/3returns the raw JSON value unless decode: :hex | :hex_unsigned | fun
getBalance(addr)getBalanceCartouche.RPC.get_balance/2integer wei
getTransactionCount(addr)getTransactionCountCartouche.RPC.get_nonce/2block_number: "pending" for the pending nonce
getCode(addr)getCode / getBytecodeCartouche.RPC.get_code/2raw bytes
getStorage(addr, slot)getStorageAt—roadmap task 2130; send_rpc("eth_getStorageAt", …) until then
getBlockNumber()getBlockNumberCartouche.RPC.eth_block_number/1—
getNetwork().chainIdgetChainIdCartouche.RPC.eth_chain_id/1—
getBlock(tag, prefetchTxs)getBlockCartouche.RPC.get_block_by_number/2, get_block_by_hash/2 → %Cartouche.Block{}include_transaction_details: true for full transactions
getTransactionReceipt(hash)getTransactionReceiptCartouche.RPC.get_trx_receipt/2 → %Cartouche.Receipt{} | nil—
getTransaction(hash)getTransaction—roadmap task 2129; send_rpc("eth_getTransactionByHash", …) until then
waitForTransaction(hash)waitForTransactionReceipt—roadmap task 9017
getFeeData()estimateFeesPerGasCartouche.RPC.gas_price/1, max_priority_fee_per_gas/1, base_fee/1, fee_history/1 → %Cartouche.FeeHistory{}no combined fee struct; execute_trx/3 derives fees itself (base_fee_buffer, priority_fee options)
estimateGas(tx)estimateGasCartouche.RPC.estimate_gas/2takes a V1/V2/%Cartouche.Transaction.Call{} struct
call(tx)callCartouche.RPC.call_trx/2returns raw return bytes; a revert is {:error, %{code: 3, message: _, revert: bytes}}, decoded when errors: ["MyError(uint256)"] is passed
—createAccessListCartouche.RPC.create_access_list/2—
getLogs(filter)getLogs—roadmap task 2128; node-side filters only (below), or send_rpc, until then
FallbackProviderfallback([...])—roadmap task 9014
——Cartouche.RPC.debug_trace_call/2, trace_call/2, trace_call_many/2, trace_trx/2typed tracing wrappers, first-class

Contract calls (ABI lives in hieroglyph)

ethers v6viemCartouche / hieroglyphDifference
new Interface(abiJson)abi arrayABI.parse_specification/1—
Fragment.from("function transfer(address,uint256)")parseAbiItemABI.FunctionSelector.decode/1one item per call — no whole-ABI human-readable list
iface.encodeFunctionData(fn, args)encodeFunctionDataABI.encode_call/3, e.g. ABI.encode_call("transfer(address,uint256)", [to, amount])positional arg list; the address arg is 20 bytes
iface.decodeFunctionData(data)decodeFunctionDataABI.decode_call/3—
iface.decodeFunctionResult(fn, data)decodeFunctionResultABI.decode("(uint256)", return_data)pass the return tuple type, not the function signature
AbiCoder.encode(types, values)encodeAbiParametersABI.encode("(uint256,address)", [{n, addr}])tuple type string, values as one tuple; no selector
iface.getFunction(fn).selectortoFunctionSelectorABI.method_id/14 raw bytes
iface.parseError(data)decodeErrorResultABI.decode_error/3, or errors: on call_trx/2 / execute_trx/3—
solidityPacked(types, values)encodePackedABI.encode_packed/2—
new Contract(addr, abi, runner)getContractnone — inline {"sig(types)", args} calldata, or a module generated by mix cartouche.gen (call_<fn>, execute_<fn>, prepare_<fn>, decode_event/2)no contract object; the generated module is the closest thing and is compile-time, not runtime
contract.fn.staticCall(args)readContractCartouche.RPC.call_trx(%Cartouche.Transaction.Call{destination: addr, data: calldata}) then ABI.decode/3two steps unless you use generated bindings
contract.fn(args)writeContractCartouche.RPC.execute_trx(addr, {"fn(types)", args}, opts)see Transactions — simulates first by default
—simulateContractexecute_trx/3 with verify: true (default), or call_trx/2 with from:simulation is on by default — the opposite of ethers, which never simulates
—erc20AbiCartouche.Erc20.Call.balance_of/3 (read), Cartouche.Erc20.transfer/4 (send)—

Events and filters

ethers v6viemCartoucheDifference
contract.on(event, cb)watchContractEvent / watchEventCartouche.Filter.start_link(kind: :log, address: addr, topics: [...], events: [...]) + Cartouche.Filter.listen/1a supervised GenServer polling eth_getFilterChanges every check_delay ms (default 3000); delivers %Cartouche.Filter.Log{} messages to the listening pid — no callback
provider.on("block", cb)watchBlocksCartouche.Filter.start_link(kind: :block)delivers {:hashes, [hash]}
provider.on("pending", cb)watchPendingTransactionsCartouche.Filter.start_link(kind: :pending)same shape
iface.encodeFilterTopicsencodeEventTopicsABI.Event.encode_event_topics/2—
iface.parseLog(log)decodeEventLogABI.Event.decode_event/4—
eth_newBlockFilter & co.createBlockFilter & co.Cartouche.RPC.new_block_filter/1, new_pending_transaction_filter/1, get_filter_logs/2—

Node-managed accounts

The ethers JsonRpcSigner / viem "JSON-RPC account" path — the node holds the key and signs.

ethers v6viemCartoucheDifference
provider.getSigner().sendTransaction(tx)sendTransaction with a JSON-RPC accountCartouche.RPC.send_transaction/2eth_sendTransaction — the node signs
provider.listAccounts()getAddressesCartouche.RPC.accounts/1—
eth_sign via node—Cartouche.RPC.sign/3distinct from Cartouche.Signer.sign/3; this is the node's key
eth_signTransaction—Cartouche.RPC.sign_transaction/2, fill_transaction/2—

Chains

ethers v6viemCartoucheDifference
Network.from("sepolia")sepolia chain objectCartouche.Chain.parse_id(:sepolia) → 11155111an atom → id map only; no currency, RPC URLs, explorers or contract addresses (roadmap task 9013)

Cartouche extras with no ethers/viem counterpart

Cartouche.OpenChain.lookup/3 (selector → signature lookup), Cartouche.Sleuth (bytecode queries), Cartouche.VM (in-process EVM subset for pure functions), Cartouche.RPC.eth_config/1 and eth_capabilities/1, the Cloud KMS backends, and the whole Cartouche.Solana.* tree.

Not in Cartouche — and where it is instead

ethers / viemWhere
ENS (resolveName, lookupAddress, namehash)onchain — Onchain.ENS
Multicall (multicall)onchain — Onchain.Multicall
HDNodeWallet, Mnemonic, keystore JSONroadmap task 9015
waitForTransactionReceipt, a client structroadmap task 9017 (onchain)
FallbackProvider / fallback transportroadmap task 9014
chain objectsroadmap task 9013
test client (setBalance, impersonateAccount, mine, snapshot)roadmap task 9016
SIWE (siwe)roadmap task 9018 (onchain)
getLogs, getTransaction, getStorageroadmap tasks 2128, 2129, 2130 (bundle cartouche_rpc_read_surface, behind the 2137 transport move)
formatUnits, getCreate2Address, checksum validationno wrapper and no task today — a few lines of Elixir