Skip to content

ZenHive/cartouche

Cartouche

Hex.pm

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/3 API.
  • Transaction builders for legacy (V1) and EIP-1559 (V2) Ethereum transactions, plus Solana legacy transactions.
  • Contract codegenmix cartouche.gen turns Foundry / Hardhat artifacts into typed Elixir modules with encode_* / call_* / execute_* helpers backed by the RPC client.

Status

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.

Installation

def deps do
  [
    {:cartouche, "~> 0.1"}
  ]
end

Configuration

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.

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_kms signer spec over :priv_key in production — Cartouche.Signer.Curvy keeps the key in BEAM memory, while Cloud KMS keeps it in GCP HSM and gives you per-call audit logs. Consumers reaching cartouche through the onchain wrapper 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"}

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_fee and :priority_fee — V2 (EIP-1559) path. If both are omitted, V2 is the default and eth_gasPrice is consulted for the base fee.
  • :gas_limit — defaults to eth_estimateGas × :gas_buffer (1.5).
  • :nonce — defaults to eth_getTransactionCount.
  • :value — wei (default 0). Accepts {n, :gwei} or a bare integer.
  • :verify — runs eth_call first to surface revert reasons before broadcasting (default true).
  • :trx_type:v1, :v2, or nil (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 binary

To 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.

Operator keys vs. end-user wallets

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.Typed for domain / type encoding and the digest a wallet would have produced.
  • personal_sign / raw signature recoveryCartouche.Recover.recover_eth/2 (with prefix_eth/1 for the \x19Ethereum Signed Message:\n envelope) and Cartouche.Recover.find_recid/3 when only (r, s) arrived.
  • Recovery-bit normalisationCartouche.RecoveryBit between :base / :ethereum / :eip155 representations.

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.

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/contracts produces MyApp.Contracts.SomeContract in lib/my_app/contracts/some_contract.ex.
  • --out — output root (default lib/).

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 data
  • selector_<fn>/0 — returns the %ABI.FunctionSelector{}
  • call_<fn>(contract, args, opts) — wraps Cartouche.RPC.call_trx/2
  • execute_<fn>(contract, args, opts) — wraps Cartouche.RPC.execute_trx/3
  • prepare_<fn>(contract, args, opts) — wraps Cartouche.RPC.prepare_trx/3
  • estimate_gas_<fn>(contract, args, opts)
  • decode_call/1, decode_event/2, decode_error/1 dispatchers
  • For pure functions with bytecode: exec_vm_<fn> (local EVM execution via Cartouche.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
end

Module-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

Modules 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.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

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.

About

Ethereum key manager + RPC client for Elixir

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors