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.
0.1.3 — current release (2026-05-02). Refreshes google_api_cloud_kms to 0.43 (internalises the 0.40 arity change in the Ethereum + Solana KMS signers — public API of Cartouche.{Signer,Solana.Signer}.CloudKMS preserved). Earlier 0.1.x releases ported the signet codebase under the Cartouche module tree, added the Solana surface, and shipped a published-on-hex ABI dependency (hieroglyph). See CHANGELOG.md for what has shipped.
def deps do
[
{:cartouche, "~> 0.1"}
]
endCartouche 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.
Production tip — direct cartouche use: if you're embedding cartouche directly to operate a server-side hot wallet (relayer, fee payer, treasury, oracle), prefer the
:cloud_kmssigner spec over:priv_keyin production —Cartouche.Signer.Curvykeeps the key in BEAM memory, while Cloud KMS keeps it in GCP HSM and gives you per-call audit logs. Consumers reaching cartouche through theonchainwrapper inherit whatever signer that layer configures.
| 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"}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.
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.
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.
The Cartouche.Signer GenServer is for keys you operate — relayers, fee payers, treasury wallets, attestation oracles. It is not a place to plug in end-user wallets; users on-chain sign in their own wallet (MetaMask, Phantom, Ledger, WalletConnect) and your backend's job is to verify what they sent. The relevant primitives:
- EIP-712 typed data (
eth_signTypedData_v4) —Cartouche.Typedfor domain / type encoding and the digest a wallet would have produced. personal_sign/ raw signature recovery —Cartouche.Recover.recover_eth/2(withprefix_eth/1for the\x19Ethereum Signed Message:\nenvelope) andCartouche.Recover.find_recid/3when only(r, s)arrived.- Recovery-bit normalisation —
Cartouche.RecoveryBitbetween:base/:ethereum/:eip155representations.
Solana mirrors this with Cartouche.Solana.Keys for Phantom-signed payload verification on the user side and Cartouche.Solana.Signer (Ed25519 / Cloud KMS) for the operator side.
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.
mix cartouche.gen turns Solidity build artifacts into Elixir modules:
mix cartouche.gen "out/**/*.json" --prefix my_app/contractsFlags:
--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 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.
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_000| 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.Typed |
EIP-712 typed-data domain / type encoder, digest builder |
Cartouche.Recover |
EIP-191 personal_sign recovery — recover_eth/2, recover_public_key/2, find_recid/3 |
Cartouche.RecoveryBit |
Convert v between :base (0/1), :ethereum (27/28), :eip155 |
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 |
Full API reference: hexdocs.pm/cartouche. Release history: CHANGELOG.md.
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.
MIT. See LICENSE.