Lightweight Ethereum and Solana RPC client for Elixir. Cartouche is an attributed fork of hayesgm/signet maintained by ZenHive.
It bundles four capabilities into one library:
- JSON-RPC clients for Ethereum and Solana (
Cartouche.RPC,Cartouche.Solana.RPC). - Signers as supervised GenServers — local secp256k1 / Ed25519 seeds, or GCP Cloud KMS — exposing a uniform
sign/3API. - Transaction builders for legacy (V1) and EIP-1559 (V2) Ethereum transactions, plus Solana legacy transactions.
- Contract codegen —
mix cartouche.genturns Foundry / Hardhat artifacts into typed Elixir modules withencode_*/call_*/execute_*helpers backed by the RPC client.
Status
0.1.1 — current release. Ports the signet codebase under the Cartouche module tree, adds the Solana surface, and ships a published-on-hex ABI dependency (hieroglyph). See CHANGELOG.md for what has shipped.
Installation
def deps do
[
{:cartouche, "~> 0.1"}
]
endConfiguration
Cartouche is an OTP application — its supervisor starts on boot and reads config :cartouche, .... A typical mainnet setup:
# config/runtime.exs
import Config
config :cartouche,
chain_id: 1,
ethereum_node: "https://mainnet.infura.io/v3/" <> System.fetch_env!("INFURA_KEY"),
signer: [
default: {:priv_key, System.fetch_env!("ETH_PRIVATE_KEY")}
]Each entry under :signer becomes a supervised Cartouche.Signer GenServer; the :default name is special — it's registered as Cartouche.Signer.Default and used when a caller doesn't pass :signer explicitly. Solana mirrors this with :solana_node and :solana_signer.
| Key | Default | Purpose |
|---|---|---|
:chain_id | 1 | Default Ethereum chain ID for signers and transactions |
:ethereum_node | "https://mainnet.infura.io" | Ethereum JSON-RPC endpoint |
:signer | [] | List of {name, signer_spec} for Ethereum signers |
:solana_node | nil | Solana JSON-RPC endpoint (required for any Solana RPC call) |
:solana_signer | [] | List of {name, signer_spec} for Solana signers |
:contracts | [] | Named contract address registry — see Cartouche.get_contract_address/1 |
:client | Finch | HTTP client module |
:finch_name | CartoucheFinch | Name of the supervised Finch pool |
:start_finch | true | Set false to manage your own Finch pool |
:timeout | 30_000 | Ethereum RPC request timeout (ms) — compile-time |
:solana_timeout | 30_000 | Solana RPC request timeout (ms) — compile-time |
:open_chain_client | Finch | HTTP client for OpenChain / 4byte signature lookups |
:open_chain_base_url | "https://api.4byte.sourcify.dev" | OpenChain base URL |
Signer specs:
# Local secp256k1 key (Ethereum)
{:priv_key, "0xdeadbeef..."}
# GCP Cloud KMS (Ethereum)
{:cloud_kms, kms_credentials, "projects/P/locations/L/keyRings/R/cryptoKeys/K", "1"}
# Local Ed25519 seed (Solana) — accepts raw 32-byte binary, hex, or Base58
{:ed25519, "0x..."}
# GCP Cloud KMS (Solana, Ed25519)
{:cloud_kms, kms_credentials, "projects/P/locations/L/keyRings/R/cryptoKeys/K", "1"}Quick start: send your first transaction
With the configuration above (a :default signer registered), Cartouche.RPC.execute_trx/3 looks up the nonce, signs, and sends in one call:
{:ok, tx_hash} =
Cartouche.RPC.execute_trx(
<<1::160>>, # contract address (20 bytes)
{"baz(uint,address)", [50, :binary.decode_unsigned(<<1::160>>)]},
base_fee: {1, :gwei},
priority_fee: {3, :gwei},
value: 0
)execute_trx/3 accepts:
:gas_price— V1 (legacy) path. Mutually exclusive with the V2 fee fields.:base_feeand:priority_fee— V2 (EIP-1559) path. If both are omitted, V2 is the default andeth_gasPriceis consulted for the base fee.:gas_limit— defaults toeth_estimateGas×:gas_buffer(1.5).:nonce— defaults toeth_getTransactionCount.:value— wei (default0). Accepts{n, :gwei}or a bare integer.:verify— runseth_callfirst to surface revert reasons before broadcasting (defaulttrue).:trx_type—:v1,:v2, ornil(auto-detect).:signer,:chain_id— override the configured defaults.
Cartouche.RPC.prepare_trx/3 has the same option surface but returns the signed %V1{} or %V2{} struct without broadcasting — useful for offline signing or batch submission.
Ethereum
RPC calls
Cartouche.RPC exposes the JSON-RPC surface and a small set of higher-level wrappers. The transport is send_rpc/3; everything else is convenience:
{:ok, balance_wei} = Cartouche.RPC.get_balance(<<1::160>>)
{:ok, nonce} = Cartouche.RPC.get_nonce(<<1::160>>)
{:ok, chain_id} = Cartouche.RPC.eth_chain_id()
{:ok, block_number} = Cartouche.RPC.eth_block_number()
{:ok, %Cartouche.Block{}} = Cartouche.RPC.get_block_by_number(block_number)
{:ok, %Cartouche.Block{}} = Cartouche.RPC.get_block_by_number("latest")
{:ok, %Cartouche.Receipt{}} = Cartouche.RPC.get_trx_receipt(tx_hash)
# Read-only contract call (eth_call)
{:ok, return_data} =
Cartouche.RPC.call_trx(%Cartouche.Transaction.V2{
destination: contract,
data: call_data
})Every RPC function takes a final opts keyword list — :ethereum_node, :block_number, :timeout, :headers — letting you target multiple nodes from one process tree.
Signing
A signer process knows its own address and signs on demand. With a :default entry in config, callers don't need to pass anything:
{:ok, signature} = Cartouche.Signer.sign("hello world")
address = Cartouche.Signer.address() # 20-byte binaryTo start a signer manually (e.g. in a test):
{:ok, pid} =
Cartouche.Signer.start_link(
mfa: {Cartouche.Signer.Curvy, :sign, [private_key_bytes]},
name: MySigner
)
{:ok, sig} = Cartouche.Signer.sign("hello", MySigner)Each signer process keeps its own public key, and signatures are verified against it before they're returned. Cloud KMS doesn't emit a recovery bit, so Cartouche tries all four and picks the one that recovers to the registered address.
Transactions
Build, sign, and encode a V1 (legacy) transaction:
{:ok, signed_trx} =
Cartouche.Transaction.build_signed_trx(
contract, # 20-byte address
nonce, # integer
{"baz(uint,address)", [50, :binary.decode_unsigned(<<1::160>>)]},
{50, :gwei}, # gas price
100_000, # gas limit
0, # value
chain_id: :goerli
)
raw = Cartouche.Transaction.V1.encode(signed_trx)
{:ok, tx_hash} = Cartouche.RPC.send_rpc("eth_sendRawTransaction", [Cartouche.Hex.to_hex(raw)])Build, sign, and encode a V2 (EIP-1559) transaction:
{:ok, signed_trx} =
Cartouche.Transaction.build_signed_trx_v2(
contract,
nonce,
{"baz(uint,address)", [50, :binary.decode_unsigned(<<1::160>>)]},
{50, :gwei}, # max priority fee per gas
{10, :gwei}, # max fee per gas
100_000, # gas limit
0, # value
[], # access list
chain_id: :goerli
)
raw = Cartouche.Transaction.V2.encode(signed_trx)Both builders accept a :callback option — a fn trx -> {:ok, trx} | {:error, reason} run after construction and before signing — useful for last-mile mutations (nonce reservation, gas overrides). V1.recover_signer/2 and V2.recover_signer/1 round-trip the encoded form back to the signing address.
Contract bindings
mix cartouche.gen turns Solidity build artifacts into Elixir modules:
mix cartouche.gen "out/**/*.json" --prefix my_app/contracts
Flags:
--prefix— module + path prefix.my_app/contractsproducesMyApp.Contracts.SomeContractinlib/my_app/contracts/some_contract.ex.--out— output root (defaultlib/).
The generator accepts both raw ABI JSON arrays and full Foundry / Hardhat artifacts (with "abi" and "bytecode"). For each ABI entry it emits:
encode_<fn>/N— ABI-encodes the call dataselector_<fn>/0— returns the%ABI.FunctionSelector{}call_<fn>(contract, args, opts)— wrapsCartouche.RPC.call_trx/2execute_<fn>(contract, args, opts)— wrapsCartouche.RPC.execute_trx/3prepare_<fn>(contract, args, opts)— wrapsCartouche.RPC.prepare_trx/3estimate_gas_<fn>(contract, args, opts)decode_call/1,decode_event/2,decode_error/1dispatchers- For pure functions with bytecode:
exec_vm_<fn>(local EVM execution viaCartouche.VM)
Once generated, callsites read like any other Elixir module:
{:ok, tx_hash} =
MyApp.Contracts.SomeContract.execute_some_function(addr, 55, priority_fee: {55, :gwei})Solana
Solana support mirrors the Ethereum surface. With :solana_node and a :solana_signer configured:
fee_payer = Cartouche.Solana.Signer.address() # 32-byte pubkey from configured signer
recipient = Cartouche.Base58.decode!("RecipientPublicKeyInBase58...")
{:ok, %{blockhash: blockhash}} = Cartouche.Solana.RPC.get_latest_blockhash()
instruction = Cartouche.Solana.SystemProgram.transfer(fee_payer, recipient, 1_000_000_000)
message = Cartouche.Solana.Transaction.build_message(fee_payer, [instruction], blockhash)
# Sign via the configured GenServer signer (no raw seed handling in app code)
msg_bytes = Cartouche.Solana.Transaction.serialize_message(message)
{:ok, sig} = Cartouche.Solana.Signer.sign(msg_bytes)
signed = %Cartouche.Solana.Transaction{signatures: [sig], message: message}
{:ok, signature} = Cartouche.Solana.RPC.send_and_confirm(signed, commitment: :confirmed)For offline signing (no GenServer), pass raw 32-byte Ed25519 seeds directly: Cartouche.Solana.Transaction.sign(message, [fee_payer_seed]). For sponsored transactions (one party pays fees for another), see Cartouche.Solana.Transaction.sign_partial/2 and add_signature/3.
Cartouche.Solana.RPC covers the standard JSON-RPC surface (get_balance/2, get_account_info/2, simulate_transaction/2, request_airdrop/3, plus the SPL token and fee queries). Cartouche.Solana.Keys handles keypair generation, seed loading, and Base58 conversion; Cartouche.Solana.Signer is the GenServer parallel to Cartouche.Signer for both Ed25519 and Cloud KMS backends.
Hex utilities
use Cartouche.Hex brings in the ~h sigil for compile-time hex literals plus the common encoders:
defmodule MyApp.Calls do
use Cartouche.Hex
@selector ~h[0xa9059cbb] # decoded at compile time
def is_transfer?(<<@selector::binary, _rest::binary>>), do: true
def is_transfer?(_), do: false
endModule-level helpers:
Cartouche.Hex.decode_hex!("0xaabb") # <<0xaa, 0xbb>>
Cartouche.Hex.to_hex(<<0xaa, 0xbb>>) # "0xaabb"
Cartouche.Hex.to_address(<<1::160>>) # "0x0000...0001" (EIP-55 checksummed)
Cartouche.Hash.keccak("hello") # 32-byte digest
Cartouche.Wei.to_wei({2, :gwei}) # 2_000_000_000Modules at a glance
| Module | What it does |
|---|---|
Cartouche.RPC | Ethereum JSON-RPC client; high-level execute_trx / prepare_trx / call_trx |
Cartouche.Signer | GenServer signer (secp256k1, Cloud KMS) — sign/3, address/1 |
Cartouche.Transaction | V1 (legacy) and V2 (EIP-1559) builders, encoders, signature recovery |
Cartouche.Hex / Cartouche.Hash | ~h sigil, encode/decode helpers, keccak digests |
Cartouche.Wei | to_wei/1 — accepts integers or {n, :gwei} |
Cartouche.Solana.RPC | Solana JSON-RPC client |
Cartouche.Solana.Transaction | Build / sign / serialize Solana legacy transactions |
Cartouche.Solana.Signer | GenServer Ed25519 signer (local seed, Cloud KMS) |
Mix.Tasks.Cartouche.Gen | Codegen from Solidity artifacts — mix cartouche.gen |
Documentation
Full API reference: hexdocs.pm/cartouche. Release history: CHANGELOG.md.
Relationship to upstream
Cartouche is a fork of hayesgm/signet. We upstream fixes where it makes sense. Attribution to the original maintainer (Geoffrey Hayes, Compound Labs) is preserved in LICENSE and CHANGELOG.md.
License
MIT. See LICENSE.