All notable changes to this project will be documented in this file.

[Unreleased]

[0.1.3] — 2026-05-02

Bumped

  • google_api_cloud_kms 0.38.1 → 0.43.0 (loosened mix.exs pin ~> 0.38.1~> 0.43). Breaking change at 0.40.0cloudkms_..._get_public_key and ..._asymmetric_sign collapsed from arity 7/8 (split path components: (connection, project, location, keychain, key, version, ...)) to arity 4 ((connection, name, optional_params \\ [], opts \\ [])) where name is the full projects/{p}/locations/{l}/keyRings/{kc}/cryptoKeys/{k}/cryptoKeyVersions/{v} resource path. Both KMS signer modules (lib/cartouche/signer/cloud_kms.ex for Ethereum secp256k1 + lib/cartouche/solana/signer/cloud_kms.ex for Solana Ed25519) updated to construct the name string internally via a new private key_version_name/5 helper; public API of Cartouche.Signer.CloudKMS.{get_address,sign} and Cartouche.Solana.Signer.CloudKMS.{get_address,sign} is preserved — callers continue to pass (cred, project, location, keychain, key, version). Audit trail (hex tarball diff across 0.39.0 / 0.40.0 / 0.41.0 / 0.42.0 / 0.42.2 / 0.43.0): %PublicKey{algorithm:, pem:}, %AsymmetricSignResponse{signature:}, %Connection{}, and Goth/Tesla.Env.client() integration unchanged across all 5 minors; 0.43.0 adds :publicKey (ChecksummedData) + :publicKeyFormat to PublicKey additively. Pre-mutation coverage push (per critical-rules.md "RAISE COVERAGE BEFORE MUTATING") added focused ExUnit assertions for both modules: get_address/6 algorithm-mismatch error contracts on both, plus malformed-DER (wrong SubjectPublicKeyInfo OID) on Solana — Cartouche.Signer.CloudKMS 69.23% → 88.24%, Cartouche.Solana.Signer.CloudKMS 68.75% → 90.00%. The remaining uncovered lines on each module are exclusively the defp client(cred) do; %{token: token, type: "Bearer"} = Goth.fetch!(cred); Connection.new(token); end config-glue path, which (a) is not affected by this mutation, (b) requires :meck/Mox test infrastructure not currently in cartouche to exercise, and (c) sits below the 95% critical-tier gate strictly speaking — but the actually-mutated lines (resource-path construction + new arity-4 call sites) are 100% covered by the existing Tesla.Mock URL-pinning setup, which is the operative safety net for the API rewrite. Adding test mocking infrastructure for the Goth path is filed as a follow-up rather than blocking this dep refresh. Closes ROADMAP Tasks 24 + 25; Task 26 closed as superseded (EC_SIGN_ED25519 already shipped via Cartouche.Solana.Signer.CloudKMS in earlier work — the Phase 7.1 prose was stale on that point; no HMAC / attestation / new auth surfaces in 0.39 → 0.43 worth surfacing as Cartouche-level helpers).
  • junit_formatter 3.3.1 → 3.4.0 (loosened mix.exs pin ~> 3.3.1~> 3.3). Passive ExUnit formatter, zero callsites in cartouche source — no API risk. Root cause for the prior "Update not possible" was commit 860ac52 ("Tighten dep pins to match refreshed lockfile") rewriting ~> 3.3~> 3.3.1; loosening back to ~> 3.3 lets future patches land without re-triggering the pin gate. Closes ROADMAP Task 71.
  • bandit 1.10.4 → 1.11.0 (lock-only refresh — mix.exs pin ~> 1.10 already permitted 1.11.0). Single dev-only callsite (mix.exs tidewave alias, Bandit.start_link(plug: Tidewave, port: 4013)); the running Tidewave session keeps the old beam loaded until the user restarts, so the actual 1.11.0 boot is deferred to the next session — verified here only that the lock pins 1.11.0 and the test suite + dialyzer + credo + sobelow are clean against the new transitive set (thousand_island 1.4.3, plug 1.19.1, websock 0.5.3). Closes ROADMAP Task 72.

Documentation

  • Cartouche.Recover promoted from internal (@moduledoc false) to a documented public module. The three primitives — recover_eth/2, recover_public_key/2, find_recid/3 — were already @doc'd with full doctests and referenced by name in the README's operator-keys-vs-end-user-wallets prose, so the existing state was incoherent (module attribute said "internal", per-function docs said "use me, here's how"). Drops @moduledoc false and adds a moduledoc covering the EIP-191 personal_sign framing, the two accepted signature shapes (%Curvy.Signature{} struct vs raw 65-byte <<r::256, s::256, v::8>>), the three recid encodings the v byte may carry (raw 0/1, personal_sign 27/28, EIP-155 35 + 2 * chain_id + recid), and the two consumer audiences (internal Cartouche.Signer recover-and-verify path; external user-supplied signature verification e.g. MetaMask / WalletConnect). Pre-1.0 is the cheap window to formalise this surface; doing it post-1.0 would awkwardly relabel a module downstream consumers had already informally pinned to. The accompanying prefix_eth/1 docstring is rewritten to spell out the four-part byte structure (0x19 version byte + "Ethereum Signed Message:\n" prefix + decimal-ASCII length + message) and to flag that the \x19 and \n in the doctest output are the literal bytes — newcomers reading the rendered HexDocs page were misreading them as escape syntax. README's "Modules at a glance" table gains a Cartouche.Recover row between Cartouche.Typed (EIP-712) and Cartouche.RecoveryBit (v-byte normalisation), grouping the three signature-side primitives together. Also fixes the long-standing "Etheruem" typo in the prefix_eth/1 docstring inherited from upstream signet. Doc-only — no library code changed; the public function set is unchanged. Future agent-economy work (descripex api() annotations) deliberately deferred — see internal discussion 2026-05-02 — pending a Cartouche.Verify facade design that exposes hex-string-shaped agent verbs over the byte-binary primitives Recover speaks.

Roadmap

  • ROADMAP Task 68 ("Cartouche.DebugTrace.StructLog — add EIP-7702 opcodes (AUTH, AUTHCALL)") closed obsolete 2026-05-01. The task's two technical premises were both wrong, verified against the EIP-7702 spec, ethereum.org/roadmap/pectra, and go-ethereum master core/vm/opcodes.go (Codex consultation independently corroborated, pulling geth master + v1.14.12 + the EIP-3074 spec page which now displays a 🛑 Withdrawn badge). (1) AUTH (0xf6) and AUTHCALL (0xf7) were EIP-3074 opcodes — that spec is withdrawn and never reached any client. EIP-7702 is the replacement for 3074, swapping the new-opcode design for delegation indicators (0xef0100 || address) on existing CALL-family opcodes (geth's enable7702 in core/vm/eips.go:571–575 adjusts gas only, no opcode slots). (2) Pectra activated mainnet 2025-05-07 at epoch 364032 — bundling 10 EIPs that ship zero new opcode mnemonics. The current closed whitelist (lib/cartouche/debug_trace.ex:56–67) covers all live mainnet opcodes; 7702 delegation txs decode cleanly because the on-the-wire op strings remain standard CALL-family. The "Avoid anchors involving post-EIP-7702 traces until Task 68 lands" reservation in Task 62's notes (downstream of the same misunderstanding) was relaxed in the same edit. No production-code changes — the closure is roadmap hygiene against a corrected technical premise.
  • ROADMAP Task 70 filed (Phase 0.5, 🔶 blocked on Fusaka/Osaka mainnet activation): add CLZ (0x1e, EIP-7939) to the DebugTrace opcode whitelist. CLZ is in geth master's newOsakaInstructionSet() per EIP-7607 Osaka composition; once Osaka activates and the mnemonic emitted in struct-logs is confirmed against an Osaka testnet trace, add the atom + a round-trip regression test. Replaces the abandoned Task 68 with the correctly-scoped forward-compat task surfaced by Codex's read of geth master.

[0.1.2] — 2026-05-01

Changed

  • Refreshed deps to current hex versions and tightened mix.exs pins to match what the lockfile (and test suite) is now built against:
    • hieroglyph 1.0.0 → 1.4.0; pin ~> 1.0~> 1.4 (consumer-visible — raises floor to >= 1.4.0). Picks up the decode_structs: true String.to_existing_atom hardening (atom-table DOS guard, see ROADMAP Phase 11 advisory) plus the silent bug-fix windfall from 1.0.0–1.2.0 (indexed reference-type event params returning {:indexed_hash, _}, :string decode no longer truncating at NUL, encode_int/2 overflow guard, dynamic?/1 no longer crashes on T[0]).
    • ex_dna 1.4.1 → 1.4.3 (dev/test only, pin ~> 1.3~> 1.4).
    • ex_ast 0.5.0 → 0.8.1 (dev/test only, pin ~> 0.5~> 0.8).
  • Hieroglyph 1.4.0 brings two new transitive deps into the lockfile (descripex 0.6.0, json_spec 1.1.1) — surface-only for hieroglyph's self-describing api() macro; cartouche doesn't import either directly.
  • Phase 11 advisory audit (the two decode_structs: true callsites at lib/mix/cartouche.gen.ex:611-614 and lib/cartouche/sleuth.ex:91-128) verified safe under the offline test suite — atoms are pre-interned at module-compile time in both paths. Integration coverage for the Sleuth runtime path remains unverified this run.

[0.1.1] — 2026-05-01

Added

  • ROADMAP Block decoder bundle (Tasks 63 + 64 + 65) — Cartouche.Block extended with seven Ethereum hard-fork fields that the integration suite (Task 61) had pinned with refute Map.has_key?/2 decoder-gap assertions: base_fee_per_gas (London, EIP-1559), withdrawals_root + withdrawals (Shanghai, EIP-4895), parent_beacon_block_root (Cancun, EIP-4788), blob_gas_used + excess_blob_gas (Cancun, EIP-4844), and mix_hash (pre-Merge PoW mix hash; post-Merge PREVRANDAO per EIP-4399). All seven fields are ... | nil in @type t so pre-fork blocks deserialize cleanly through the existing map(x, f) nil-tolerant helper. withdrawals decodes to [Cartouche.Block.Withdrawal.t()] | nil via a new nested Cartouche.Block.Withdrawal submodule (mirrors the Cartouche.Receipt.Log precedent — own defstruct, @type t, deserialize/1, doctest) carrying index, validator_index, address, amount per EIP-4895. The empty-list boundary ("withdrawals": []withdrawals: [], distinct from absent → nil) is explicitly tested so consumers can detect Shanghai+ blocks with no withdrawals in this slot. Block doctest split into pre-London (existing fixture) and post-Cancun (new) shapes for documentation; load-bearing assertions live in test/block_test.exs's new describe "deserialize/1 — fork-tier optional fields (Tasks 63 + 64 + 65)" and describe "Cartouche.Block.Withdrawal.deserialize/1 (Task 64)" blocks per feedback_doctests_not_substitute_for_tests.md — covering pre-London nil defaults, the post-London base_fee_per_gas boundary, the post-Shanghai withdrawals-list-of-structs shape with non-empty + empty boundaries, the post-Cancun all-fields-populated shape, the cross-fork mix_hash decode, the zero-amount Withdrawal boundary, and the uint64-max amount round-trip. Integration tests at test/rpc_integration_test.exs strengthen the three prior refute blocks to positive assertions: post-London 15M anchor (assert b.base_fee_per_gas > 0), post-Shanghai 18M anchor (assert [%Cartouche.Block.Withdrawal{} | _] = b.withdrawals, byte_size(b.withdrawals_root) == 32), post-Cancun 20M anchor (32-byte parent_beacon_block_root, integer blob_gas_used + excess_blob_gas, 32-byte mix_hash). RPC doctests at lib/cartouche/rpc.ex:463/522 (get_block_by_number/2 and get_block_by_hash/2) updated to reflect the new struct shape — the mock test client at test/support/client.ex:893 already returns mixHash in its fixture, so the doctests show mix_hash populated and the six other new fields as nil. Pre-mutation gate cleared (Cartouche.Block already at 100% coverage); post-mutation Cartouche.Block and Cartouche.Block.Withdrawal both at 100%; dialyzer clean on block.ex; total invalid_contract count holds at 8 (no regressions). The :include_transaction_details opt remains hardcoded transactions: [] per ROADMAP Task 66 (filed standalone, separate concern). A new TODO(Task 66): marker at lib/cartouche/block.ex:294 makes that deferral visible to credo. Closes ROADMAP Tasks 63, 64, 65.

  • Mainnet archive integration test suite (test/rpc_integration_test.exs) with Cartouche.Test.Live helper module (test/support/live.ex). Opt-in via mix integration or mix test --include integration; excluded from mix test.json by default. The suite ground out two real Task-60-class wire-format bugs the mock client had been masking — both fixed in this release; see Fixed entries below.
    • Methods covered (13 read-only): eth_chainId, eth_blockNumber, eth_gasPrice, eth_maxPriorityFeePerGas, eth_getBlockByNumber, eth_getBlockByHash, eth_getTransactionReceipt, eth_getCode, eth_getBalance, eth_getTransactionCount, eth_call, eth_estimateGas, eth_feeHistory. Trace methods (trace_transaction, trace_call, trace_callMany, debug_traceCall) deferred to v2 — see ROADMAP Task 62.
    • Anchor strategy: historical mainnet data at four fork-tier blocks (pre-London 10M, post-London 15M, post-Shanghai 18M, post-Cancun 20M), plus type-0 + type-2 receipt anchors and WETH9 code/balance/nonce anchors at block 18M. The chain is immutable, so assertions are deterministic forever.
    • Architectural pattern: per-call client: Finch + ethereum_node: <url> opts (CCXT-style local-object pattern modeled on ../ccxt_client/test/support/integration_helper.ex). No Application.put_env mutation, no on_exit cleanup, tests stay async: true. Required a private :client opt on Cartouche.RPC.send_rpc/3 — every wrapper already forwards opts, so this propagates transparently to all 16 method wrappers without further surface changes.
    • Setup / failure mode: default node URL http://127.0.0.1:8545 (the blockwatch-one SSH tunnel: ssh -L 8545:127.0.0.1:8545 -L 8546:127.0.0.1:8546 blockwatch-one); override via CARTOUCHE_LIVE_NODE_URL. Cartouche.Test.Live.assert_node_available!/0 flunks loudly with multi-line tunnel-setup instructions when the node is unreachable, and with a chain-id-mismatch message when the node responds but isn't mainnet — never silent skips.
    • Decoder gaps surfaced: pinned with # TODO(integration-gap, ROADMAP Task NN): comments adjacent to refute Map.has_key? assertions — Tasks 63 (Block base_fee_per_gas), 64 (Block withdrawals), 65 (Block Cancun fields), 67 (Receipt EIP-4844 blob fields). Tasks 62 (trace methods), 66 (Block.transactions full-detail), and 68 (DebugTrace EIP-7702 opcodes) are filed for follow-up — no integration anchor yet.
    • Mix wiring: integration: ["test --only integration"] alias, integration: :test in cli/0 preferred_envs, test_helper.exs now ExUnit.start(exclude: [:integration]).

Fixed

  • Cartouche.RPC.get_block_by_hash/2 was sending only [block_hash] on the wire — JSON-RPC eth_getBlockByHash requires two params: [blockHash, fullTransactionObjects]. Real mainnet nodes (verified 2026-04-30 against the local archive tunnel) responded {:error, %{code: -32602, message: "Invalid params"}}; the mock client at test/support/client.ex:881 accepted the single-param shape and returned a fixture, masking the bug for the entire history of the upstream signet codebase. Same Task-60 class as the integer-block-param bug fixed in 0.1.0. Fix: get_block_by_hash/2 now reads :include_transaction_details from opts (default false, matching get_block_by_number/2's contract) and forwards both params on the wire. Mock client's eth_getBlockByHash/1 widened to eth_getBlockByHash/2 with a default-false second arg to match the new wire shape. Discovered by the new mainnet integration suite at first run — exactly what the suite is designed to catch.
  • The V1 call-params builder (private to_call_params/2 in Cartouche.RPC) for Cartouche.Transaction.V1 encoded the data field via Cartouche.Hex.encode_short_hex/1, which strips leading zeros and produces "0x0" for empty calldata. Real mainnet nodes reject data: "0x0" with -32602 Invalid params — the JSON-RPC DATA type requires bytes-preserving hex ("0x" for empty, full-width otherwise). The mock client accepted any value, masking the bug. Fix: V1's data now uses Cartouche.Hex.encode_big_hex/1, matching V2's encoding (which was already correct). The other V1 fields (gasPrice, gas, value) stay on encode_short_hex — they're QUANTITY type, where "0x0" is the spec-mandated form. Discovered by the integration suite's eth_estimateGas test for a simple ETH transfer with empty calldata.

[0.1.0] — 2026-04-30

First active release under the cartouche namespace. Ports the signet codebase under the Cartouche module tree with Elixir 1.20 compatibility, a published-on-hex ABI dep (hieroglyph), and a cleaned-up dialyzer baseline.

Fixed

  • ROADMAP Phase 1 closeout — Cartouche.Wei.to_wei/1 spec narrowed integer() | {integer(), :wei | :gwei}) :: integer()non_neg_integer() | {non_neg_integer(), :wei | :gwei}) :: non_neg_integer(), with matching amount >= 0 guards on all three clauses. Wei is a discrete count by domain — every internal caller (Cartouche.Transaction constructors at :67/:70/:384/:386/:389, Cartouche.RPC fee-suggestion fallbacks at :1569/:1584/:1591) already passes non-negative values; the spec was simply loose. Negative inputs now raise FunctionClauseError at the contract boundary instead of silently propagating a nonsense wei count downstream. New describe "spec boundaries (Phase 1.2)" block in test/wei_test.exs pins the zero boundary in all three clauses, the large-value identity round-trip, the :gwei multiplier, and the negative-input rejection — grounded as ExUnit assertions per feedback_doctests_not_substitute_for_tests.md. Cartouche.Wei coverage stays at 100%; full suite green; dialyzer clean on wei.ex. Closes ROADMAP Tasks 7+8+9, completing Phase 1 (1.1 RecoveryBit, 1.3 Signer mfa(), 1.4 Hex returns already shipped). The downstream onchain @dialyzer {:no_match} strip across Onchain.Hex / ABI / ERC / ENS / Multicall callers becomes load-bearing once cartouche 0.1.x ships (Task 6).

  • Cartouche.Trace.deserialize/1 no longer raises Protocol.UndefinedError on RPC payloads with missing or nil traceAddress. The Enum.map(params["traceAddress"], &decode_address_or_number/1) line at trace.ex:423 (tracked under TODO(Task 55) since Tasks 51 + 52) now routes through a decode_trace_address/1 helper that raises ArgumentError, "missing traceAddress in trace_transaction result element" when the key is absent or nil, and maps the list otherwise. The audit attached to ROADMAP Task 55 (Codex consultation 2026-04-26 reviewing OpenEthereum + Infura trace_transaction schemas) confirmed traceAddress is mandatory at the wire — the root call shows [], not omission — so the right contract is "loud reject", not soft || [] (which would silently coerce corrupt nodes' output). New describe "deserialize/1 — traceAddress absent/nil (Task 55)" block in test/trace_test.exs pins both the missing-key and explicit-nil shapes via assert_raise ArgumentError, ~r/missing traceAddress/. Cartouche.Trace coverage stays at 100%; full suite green; dialyzer clean on trace.ex. Closes ROADMAP Task 55.
  • Cartouche.RPC.get_block_by_number/2 and 9 companion JSON-RPC callsites no longer send raw integers as block parameters. The @spec declared non_neg_integer() | String.t() and the doctest at lib/cartouche/rpc.ex:450 exercised get_block_by_number(55) — but send_rpc/3 Jason.encode!s params verbatim, so the integer hit the wire as [55, false] and any real Ethereum node responded {:error, %{code: -32602, message: "Invalid params"}} (verified 2026-04-28 against a live mainnet reth tunnel). The mock test client at test/support/client.ex accepted any value, masking the bug for the entire history of the upstream codebase. Fix shape: a new public Cartouche.Hex.encode_quantity/1 produces JSON-RPC-spec-compliant lowercase quantity strings (0"0x0", no leading zeros, lowercase hex digits) — distinct from encode_short_hex/1, which is uppercase by design for transaction-field encoding. A private normalize_block_param/1 helper inside Cartouche.RPC (integer → Cartouche.Hex.encode_quantity/1, binary passthrough) is applied at all 10 callsites that forward a block tag: get_block_by_number/2, get_nonce/2, call_trx/2, estimate_gas/2, get_code/2, get_balance/2, get_transaction_count/2, trace_call/3, trace_call_many/2, and debug_trace_call/2. The existing integer doctest at :450 continues to pass — the mock returns the same fixture for any block param — and now accurately documents real-node behavior. New ExUnit coverage: a describe "encode_quantity/1" block in test/hex_test.exs (zero, small int, single-digit, large multi-byte block number, all-letter hex, negative-rejection) per critical-rules.md "Doctests Are Documentation, Not Tests"; and a describe "block-param wire encoding" block in test/rpc_test.exs using a CaptureClient test double that delegates to Cartouche.Test.Client while sending the decoded JSON-RPC body back to the test pid — pins the integer-in → "0x37"-on-wire wiring through the public get_block_by_number/2 path and proves companion normalization via get_balance/2 + get_nonce/2 opt-reader assertions. Pre-mutation coverage gate: Cartouche.Hex already 100% (Phase 1.4 closeout); Cartouche.RPC 91.55% module-level, but the touched functions are 100% covered — the 18 uncovered RPC lines are entirely in untouched error-path code (Solidity Panic decoding, prepare_trx, execute_trx, fee history, trace_revert) and tracked separately. README's RPC example block restored to chain eth_block_number/0 → get_block_by_number(int) (the original honest shape that motivated the bug discovery) alongside the "latest" form. Closes ROADMAP Task 60.

  • ROADMAP Phase 1.4 — Cartouche.Hex return-shape spec audit closeout. The five spec corrections (decode_hex/1, from_hex/1, from_hex!/1, decode_hex_number/1, plus the private decode_hex_/1) had previously shipped as a drive-by under commit 8d4bc18 ("doctor, credo fixes", 2026-04-26): {:ok, t()} | :error{:ok, t()} | :invalid_hex on the four soft-return functions and t() -> String.t()t() -> t() on from_hex!/1, matching the actual runtime return shape of the private helper. This commit grounds the corrections so a future onchain audit doesn't have to reverse-engineer them: (a) failure-path doctest added to from_hex/1 (parity with decode_hex/1 — both now show :invalid_hex on bad input); (b) raise-path doctest added to from_hex!/1 (parity with decode_hex!/1 — both now show the Cartouche.Hex.InvalidHex exception); (c) new describe "spec boundaries (Phase 1.4)" block in test/hex_test.exs pinning all four corrected return shapes as ExUnit assertions per the project rule that doctests are documentation, not load-bearing tests for spec contracts. Bundled with the closeout: a describe "deep_encode_binaries/1" block covering the four previously-uncovered lines of the @doc false recursive helper, lifting Cartouche.Hex coverage 94.29% → 100% and clearing the ≥95% critical-tier gate prophylactically for any future Hex mutation. No runtime behavior change. Dialyzer outcome: 0 hex.ex warnings (unchanged — the spec corrections were already absorbed); total invalid_contract count remains 8 (the 6 in sleuth.ex are tracked under Task 54, the 2 in typed.ex under Tasks 19+20). Onchain's @dialyzer {:no_match} strip on Onchain.Hex and the cascading ABI / ERC / ENS / Multicall callers becomes load-bearing once cartouche 0.1.x ships (Task 6). Closes ROADMAP Tasks 10+11+12+13.

  • Cartouche.Transaction.V1 r/s/v storage unified to integers throughout, settling a Schrödinger spec that produced three different runtime shapes for the same field. V1.t() declared r: integer(), s: integer(), v: integer(), but add_signature/2 (lib/cartouche/transaction.ex:170) stored r/s as 32-byte binaries (matched out of <<r::binary-size(32), s::binary-size(32), v::binary>>), while V1.new/7 and V1.decode/1 stored them as integers. The mismatch was latent until a public-API path exercised it: V1.decode → recover_signer raised ArgumentError on any signed legacy RLP transaction, because get_signature/1's second clause built the signature with <<r::binary-size(32), …>> and decode/1 had stored r/s via :binary.decode_unsigned/1. add_signature/2 now :binary.decode_unsigned/1s the incoming r/s segments so they match the spec and the other constructors; get_signature/1's second clause now rebuilds the signature with <<r::big-256, s::big-256, v_enc::binary>> — byte-equivalent to the prior 32-byte binary form, so add_signature → get_signature round-trips bit-for-bit and the chain-side recovery doctest still passes unchanged. Side benefit: add_signature(...) |> encode() now produces canonical RLP for r/s (leading zeros stripped) — the prior binary form was technically non-canonical on the wire. Bundled hardening from the staged-review pass: V1.decode/1 now guards byte_size(r) <= 32 and byte_size(s) <= 32 on the 9-element RLP shape, returning {:error, "invalid legacy transaction"} on adversarial input with >32-byte r/s — without the guard, the new <<r::big-256>> reconstruction in get_signature/1 would raise ArgumentError on r ≥ 2^256 reachable through decode → recover_signer. The add_signature doctest in transaction.ex was updated to show r: 1, s: 2 post-call (the only doctest whose expected output changed). New ExUnit describe "V1 (Task 53)" block in test/transaction_test.exs covers the malformed-RLP fallback in decode/1 (closing the coverage gate), the full build_signed_trx → encode → decode → recover_signer round-trip (the previously-broken path; failed pre-fix exactly as the type system predicted), the empty-sig boundary (r:0, s:0recover_signer reports missing signature), and the adversarial 33-byte r/s fixture asserting {:error, "invalid legacy transaction"} — four cases grounded as ExUnit assertions per critical-rules.md "Doctests Are Documentation, Not Tests". Coverage on Cartouche.Transaction.V1 93.33% → 100%; dialyzer drops transaction.ex:169 invalid_contract (count 9 → 8); 748/748 tests green. Closes ROADMAP Task 53; Phase 0.4 is fully cleared and Task 6 (mix hex.publish) is the only remaining 0.1.0 item.
  • Cartouche.Solana.Transaction.deserialize/1 and sign_partial/2 were specced as well-formed but raised on malformed input. deserialize/1 (specced {:ok, t()} | {:error, term()}) raised FunctionClauseError on empty / truncated compact-u16 prefixes (both decode_compact_u16_acc/3 clauses required <<byte, rest::binary>>), FunctionClauseError on sub-3-byte message headers (the only deserialize_message/1 head-clause required three header bytes), and MatchError on truncated instruction bodies (three raw <<...>> = rest matches in read_instructions/3 body). Hardened with a private safe_decode_compact_u16/1 returning {:ok, val, rest} | {:error, :truncated_compact_u16} (public decode_compact_u16/1 tuple contract preserved); a read_instructions/3 rewrite using with + a read_size_prefixed/2 guard helper that mirrors the existing read_signatures/3 / read_pubkeys/3 shape, plus a fallback (_, _, _) -> {:error, :insufficient_instruction_data} clause; and a deserialize_message(_) -> {:error, :invalid_message_header} fallback. sign_partial/2 evaluated Enum.map(0..-1, …) on a 0-signer message and produced two zero-filled placeholder signatures instead of the empty list its [<<_::512>>] field type implies; the range now carries an explicit //1 step (0..(n - 1)//1), giving the empty range when n == 0 (also silences the Elixir 1.20 deprecation on the unstepped form). Both behaviours grounded by new ExUnit describe blocks in test/solana/transaction_test.exs covering <<>>, truncation at every section boundary (compact-u16, signatures, header, pubkeys, blockhash, instruction header / accounts / data), and the synthetic zero-signer boundary — 10 new test cases, all assert {:error, _} / boundary-shape assertions per critical-rules.md "NEVER HIDE TEST FAILURES". Coverage on Cartouche.Solana.Transaction 95.56% → 98.97%; full suite green; dialyzer clean on the touched file. Closes ROADMAP Tasks 56 and 57.

  • Cartouche.Trace.t().trace_address and Cartouche.TraceCall.t().trace were typed as singular values but always built as lists at runtime — trace_address via Enum.map(params["traceAddress"], …) and trace via Cartouche.Trace.deserialize_many/1 (whose @spec returns [t()]). Consumer code pattern-matching on the documented singular shape would have raised MatchError on every real trace_transaction / trace_callMany response. Specs narrowed to [<<_::160>> | integer()] and [Cartouche.Trace.t()] respectively. Behavior-preserving — the runtime always returned lists; only the contract documentation was wrong. Dialyzer drops the trace.ex:412 and trace_call.ex:124 invalid_contract warnings (invalid_contract count 11 → 9). New focused ExUnit blocks in test/trace_test.exs and test/trace_call_test.exs ground the list shapes against edge cases (mixed-element union [42, <<_::160>>], empty list, deserialize_many/1 round-trip) — the existing doctests cover the happy path as documentation but read as prose, so the new assertions pin the spec shape against boundary conditions doctests don't exercise. Closes ROADMAP Tasks 51 + 52. A latent crash on missing/nil traceAddress (the Enum.map(nil, _) path at trace.ex:423) is tracked under TODO(Task 55) and filed as ROADMAP Task 55 for follow-up — the test suite intentionally does not pin the broken behavior with assert_raise per critical-rules.md "NEVER HIDE TEST FAILURES".

Documentation

  • README's Solana example block modernized to use the Cartouche.Solana.Signer GenServer (Signer.address/0, Signer.sign/1) instead of raw seed handling, paralleling the Ethereum example's reliance on Cartouche.Signer. Adds Cartouche.Solana.Transaction.serialize_message/1 to the example for explicit message-byte handoff to the signer. Old shape used an undefined fee_payer_seed variable inherited from upstream — the new example is runnable end-to-end against a configured signer. A trailing prose note links offline (raw-seed) signing via Transaction.sign/2 and sponsored-transaction signing via sign_partial/2 + add_signature/3. README-only; no library code change.
  • Doctor-driven typespec + docstring sweep across lib/cartouche/**. Adds .doctor.exs (min_*_coverage: 100, failed: false, ignores Mix.Tasks.Cartouche.Gen and lib/cartouche/contract/ — Doctor's source-level AST walker counts the generator's def unquote(name)(args) literals inside quote do blocks as defs of the Mix task itself, producing 23 false-positive missing-doc warnings; BEAM introspection confirms only run/1 is exported). Adds missing @spec and/or @doc/@doc false entries on 18 modules: Cartouche, Cartouche.{Application, Assembly, Block, Erc20, Filter, Hash, Hex, Keys, OpenChain, Recover, RPC, Signer, Sleuth, Transaction, Typed, VM}, and Cartouche.Solana.Programs. Behavior-preserving — pure documentation/typespec coverage.

Fixed

  • Cartouche.Signer.start_link/1 and Cartouche.Signer.sign_direct/4 specs: replace the Erlang mfa() BIF (which is {module(), atom(), arity :: non_neg_integer()}) with {module(), atom(), [any()]} to match what the impl actually receives — an args list, not an arity. Fixes the dialyzer signer.ex:141 invalid_contract warning that ROADMAP Phase 1.3 tracked, and forestalls the same regression that was about to ship via the new start_link spec. Bundled with the typespec sweep above per the touched-files credo rule.
  • Cartouche.get_contract_address/1 spec: widen the input type from Cartouche.contract() (which excludes hex strings — address() :: <<_::160>>) to binary() | atom() to match the impl, which routes any is_binary/1 value through Cartouche.Hex.decode_hex_input!/1 (handles both 20-byte raw binaries and "0x..." hex strings, per the function's own doctest).

  • Cartouche.Transaction.V1.add_signature/2 spec: return type tightened from the %__MODULE__{} literal to t() for consistency with V2.add_signature/2,4.
  • Cartouche.Transaction.V2.add_signature/2 (binary form): tighten the second-arg spec from loose binary() to <<_::512, _::_*8>> to match the pattern (r::32, s::32, v::binary — at least 64 bytes), aligning with V1's spec.

Security

  • Cartouche.DebugTrace.StructLog.deserialize/1 no longer mints atoms from RPC input. The previous implementation called String.to_atom(params["op"]) for every entry of an eth_debug_traceCall structLogs array — thousands of opcodes per trace — which let a buggy or compromised RPC node permanently grow the BEAM atom table (default cap ~1M; exhaustion crashes the VM). The hardened path defines a closed compile-time map of every Cancun-era EVM opcode (single-name + PUSH1..32 / DUP1..16 / SWAP1..16 / LOG0..4 ranged families) and resolves opcodes via Map.fetch/2. The whitelist also carries two alias pairs verified against go-ethereum's core/vm/opcodes.go master: KECCAK256 / SHA3 for opcode 0x20 (SHA3 covers pre-1.8 Geth and some non-Geth nodes), and DIFFICULTY / PREVRANDAO for opcode 0x44 (current Geth still emits "DIFFICULTY" — the rename TODO is still open in go-ethereum master, so without DIFFICULTY the whitelist would crash on every modern Geth trace; PREVRANDAO is forward-compat for clients that already emit the post-Merge name). Unknown strings raise ArgumentError carrying the offending value, surfacing future-EVM additions as visible failures instead of silent corruption. New regression coverage in test/debug_trace_test.exs (per-family boundary tests + nil/invalid rejections) and a non-async atom-table-stability test in test/debug_trace_atom_safety_test.exs (1000-iteration loop with novel-looking opcode strings; asserts the atom_count delta is ≪ iterations after a warmup pass that lets ExUnit/Logger machinery settle). Sobelow's DOS.StringToAtom finding at lib/cartouche/debug_trace.ex:71 (fingerprint 4F16CCA) is removed from .sobelow-skips. Audit of remaining String.to_atom callsites in lib/cartouche/sleuth.ex (the query_by/3 atom-deriving pair plus name_keyword/1) is tracked as ROADMAP Task 48 — those are bounded by compile-time atoms but warrant the same String.to_existing_atom treatment after raising Sleuth coverage to the 95% gate. The Mix-task callsite at lib/mix/cartouche.gen.ex:817 is dev-time only and stays in .sobelow-skips.

Changed

  • Breaking: rename Cartouche.Hex.HexErrorCartouche.Hex.InvalidHex and Cartouche.VM.VmErrorCartouche.VM.InvalidVm for consistency with the codebase's Invalid* exception strategy (InvalidAssembly, InvalidCode, InvalidOpcode, InvalidFileError). Public callers that rescue Cartouche.Hex.HexError / rescue Cartouche.VM.VmError must update. Settles cleanup.md B7 in favor of consistency over the no-breakage option, since cartouche is pre-1.x. Affects all decode_hex!/decode_address!/decode_word!/decode_sized!/decode_hex_number!/encode_address raise paths and Cartouche.VM.exec/3's error-rescue path.
  • Tooling: add .credo.exs (strict, with TagTODO: [exit_status: 2] to keep TODOs visible per project policy and Refactor.FunctionArity max_arity: 12 to permit Transaction.V2's EIP-1559-mirroring constructors), .sobelow-conf (exit: "Low"), and .sobelow-skips (fingerprints for two runtime String.to_atom callsites in Cartouche.Sleuth.query_by/3 bounded by compile-time-known atoms (full audit tracked as Task 48), one build-time String.to_atom callsite in the generator's contract-name binding (lib/mix/cartouche.gen.ex:817), and three File.* traversal flags in the generator's IO writers). The previously-suppressed runtime String.to_atom in Cartouche.DebugTrace.StructLog.deserialize/1 is now hardened (see Security above) rather than suppressed.
  • Cross-module helper-extraction refactor to bring mix credo --strict to zero issues on lib/cartouche/{assembly,hex,open_chain,rpc,sleuth,solana/token,solana/transaction,typed,vm}.ex. Extractions: Assembly.resolve_jump_ptr/2; OpenChain.decode_response/1 + pick_signature/3 clauses; RPC.error_matches?/2 + classify_decoded_error/2 (Panic dispatch table) + build_revert_data/2 + decode_revert_error/2 + decode_result/4 + log_decode_error/4 + resolve_gas_limit/4 + maybe_trace_revert/6 + do_estimate_and_verify/2 + apply_trace/6; Sleuth.{with_indexed_name,fallback_name,to_named_pair,name_keyword,obvious_results}/*; Solana.Token.{summarize_balance,accumulate_token_amount,maybe_include_token_2022}/*; Solana.Transaction.{merge_instruction_accounts,merge_account_meta}/2; Typed.type_fields_match?/2; VM.Operations.{do_sign_extend,extend_with_sign}/*, VM.{handle_static_call_result,pad_or_truncate_return}/*, ~25 do_* opcode-handler helpers, and pure safe_floor_div/safe_rem/safe_addmod/safe_mulmod/int_lt/int_gt/int_eq/int_is_zero callbacks for the unsigned_op*/signed_op* dispatch. Behavior-preserving — mix test.json --quiet green pre-commit. Two # credo:disable-for-next-line Refactor.CyclomaticComplexity retained on Assembly.show_opcode/1 and VM.run_single_op/3 (EVM opcode-dispatch tables — splitting would add indirection without reducing real complexity), one Readability.FunctionNames disable on Base58.sigil_B58/2 (Elixir requires sigil names start with uppercase), and two credo:disable-for-this-file Readability.{MaxLineLength,FunctionNames} on test-support files for generated bytestring fixtures and JSON-RPC method-name parity respectively.

Fixed

  • Cartouche.Sleuth.try_apply now uses reraise __STACKTRACE__ instead of raise inside the rescue, preserving the original exception's stacktrace alongside the descriptive RuntimeError message about the missing bytecode/0 (or other required) function. Settles cleanup.md B8.
  • Cartouche.OpenChain.lookup_* decode_response/1 now returns {:error, "unexpected response shape: ..."} for any successfully-decoded JSON envelope that doesn't match the OpenChain {ok: true, result: …} / {ok: false, error: …} contract, instead of raising CaseClauseError. Pre-existing gap surfaced when the inline case was extracted into the helper.
  • Cartouche.OpenChain.lookup/3 raise_on_multiple: true path: Enum.join(found_signatures, ",") was iterating a list of {sig, name} tuples and crashing Protocol.UndefinedError instead of returning {:error, "Multiple matching signatures: ..."}. Fixed at lib/cartouche/open_chain.ex:200 with Enum.map_join(found_signatures, ",", fn {_, name} -> name end) so the error message now lists the actual signature names. Surfaced during the Task 43 coverage push while writing the multi-result error-path test; per critical-rules.md "NEVER HIDE TEST FAILURES" the test asserts the corrected return shape rather than pinning the broken raise.

Tests

  • Pre-credo coverage push (ROADMAP Task 43) on the six modules slated for credo-strict cleanup, so the refactor session can rename / restructure / silence flags safely. New ExUnit blocks added to test/assembly_test.exs, test/receipt_test.exs, test/open_chain_test.exs, test/transaction_test.exs, test/sleuth_test.exs, test/solana/signer_test.exs. Highlights: data-driven show_opcode/1 table covering every named arm in Cartouche.Assembly (PUSH/DUP/SWAP/INVALID tuple coverage + show-only atoms :blobhash/:blobbasefee/:log) plus compile/1 3–7-operand cases, transform_jumps missing-jump-dest path, full PUSH1–32 / DUP1–16 / SWAP1–16 disassembly, per-clause opcode_size/1, and exception-struct defaults; contract-creation Cartouche.Receipt with to: nil/contractAddress populated and log shapes for empty / 2-topic / 4-topic data; Cartouche.OpenChainTest.TestClient extended with magic-byte signature dispatch (0xee00000105) routing to ok=false / non-JSON-body / transport-error / multi-result / empty-result paths in one async-safe inline client, plus lookup_error / lookup_error_and_values short-binary clauses and Signatures.deserialize/1 filter behaviour; V2 transaction roundtrip through Cartouche.Test.Signer.start_signer/0 + signer recovery, plus V2.new/9 chain-id-nil fallback, V2.new/12 nil-fee passthrough, ABI-tuple vs raw-binary build_trx_v2 call-data, callback-short-circuit on build_signed_trx_v2, and V2.decode/1 malformed-RLP rejection; Cartouche.Sleuth.try_apply rescue exercising the descriptive RuntimeError when the contract module is missing bytecode/0; explicit cache-hit test for Cartouche.Solana.Signer using :sys.get_state/1 to confirm the :address key is populated after the first address/1 call. No new test files; no new test dependencies (compile-time module substitution via config/test.exs already in place — no Mox/Bypass).

Changed

  • Generator gates exec_vm_* emission on real bytecode. Mix.Tasks.Cartouche.Gen now treats nil, blank strings, "0x", and "0x" <> whitespace as missing bytecode (new blank_bytecode?/1 predicate in lib/mix/cartouche.gen.ex), and the :pure dispatch branch now requires has_bytecode before emitting exec_vm_fn / exec_vm_raw_fn. Without both, the generator was emitting def bytecode, do: hex!("0x") (compile-time <<>>) plus a :pure branch that always emitted exec_vm_* — producing 762 exec_vm_* functions in Cartouche.Contract.IConsole (Hardhat console.log interface, no on-chain bytecode) that called Cartouche.VM.exec_call(<<>>, ...) and always raised VmError. Dialyzer flagged each as no_return, cascading to 1534 of 1626 total warnings. New regression tests in test/mix/cartouche_gen_test.exs cover the four blank-bytecode shapes plus the working real-bytecode path. Drops Cartouche.Contract.IConsole.{bytecode/0, deployed_bytecode/0, exec_vm_*} from the generated module (RPC-side encode_* / call_* / execute_* family preserved); regenerated file is 18705 lines (was 28084). Bundled with the bug fix: the four pre-existing credo issues in lib/mix/cartouche.gen.ex (L153 nesting in rename_dups/1, L170 cyclomatic in get_encode_calls/2, L247 cyclomatic in encode_function_call/3, L337 nesting) are resolved by extracting accumulate_named_abi/2 + dedup_named_abi/5 + maybe_rename_dup_fn/5 (rename-dups), merge_encode_call_result/2 (encode-calls reducer), and ~16 build_*_fn/1 helpers + select_emitted_fns/3 + supporting argument-spec helpers (encode-function dispatch). Generator output AST is byte-identical to pre-refactor for i_console.ex (verified via the new cartouche_gen_test.exs suite). One additional post-process: strip_zero_arity_def_parens/1 rewrites def name()def name on emitted defs (the macro source must keep the parens — def unquote(name)() is the canonical AST shape; without parens, unquote(:foo) produces a literal-atom AST and won't compile), eliminating ~382 ParenthesesOnZeroArityDefs flags from generated i_console.ex without manual annotation. String.to_atom/1 callsites in 6 generator helpers carry # sobelow_skip ["DOS.StringToAtom"] annotations (build-time codegen, not runtime input).
  • Delete Cartouche.Util grab-bag module. Helpers redistributed into five focused modules: Cartouche.Address.from_public_key/1 (renamed from Util.get_eth_address/1), Cartouche.Chain.parse_id/1 + chain registry (renamed from Util.parse_chain_id/1), Cartouche.Wei.to_wei/1, Cartouche.HTTP.normalize_finch_result/1, and Cartouche.RecoveryBit (promoted from the nested Cartouche.Util.RecoveryBit submodule). The five hex helpers decode_hex_input!/1, encode_bytes/2, pad/2, nibbles/1, and checksum_address/1 move to Cartouche.Hex. The seven @deprecated decode/encode aliases (decode_hex/1, decode_hex!/1, decode_sized_hex!/2, decode_word!/1, decode_address!/1, decode_hex_number!/1, encode_hex/2) and the keccak/1 defdelegate are removed — modern equivalents already exist in Cartouche.Hex / Cartouche.Hash. nil_map/2 is inlined as a module-local private helper in Cartouche.Trace and Cartouche.Trace.Action (its only consumers).

Fixed

  • Cartouche.RecoveryBit.normalize/2 and normalize_signature/2 specs: literal atom :no_return replaced with the no_return() type (ROADMAP Phase 1.1). Dialyzer silently accepts unknown atoms in unions, so this was semantically meaningless; now matches the documented raise behaviour.

Changed

  • Reset mix.exs version from the inherited signet pin 1.6.1 to 0.1.0-dev ahead of the first hex publish under the cartouche namespace (ROADMAP Phase 0, Task 1).
  • Swap the :abi path dep (path: "../abi", override: true) for the published hex package {:hieroglyph, "~> 1.0", override: true}. ZenHive's abi fork is now on hex.pm as hieroglyph 1.0.0; hex package name is hieroglyph but the Elixir module namespace remains ABI, so no callsite changes. Unblocks mix hex.publish for cartouche, which rejects path/git deps (ROADMAP Phase 0, Task 6).
  • Update mix.exs :package for the publish cut: maintainers: ["ZenHive"] (was ["Geoffrey Hayes"] — attribution preserved in LICENSE and in [0.0.1] below); drop test/support from :files (test helpers aren't part of the public surface), add CHANGELOG*; add CHANGELOG.md to docs[:extras] so hexdocs renders the release history; add a Changelog entry to package[:links].

Fixed

  • Pin bitstring size variables in binary matches across Cartouche.Solana.Transaction.read_instructions, Cartouche.Assembly.disassemble_opcode, and Cartouche.VM.{Memory,Operations} / Cartouche.VM.static_call for Elixir 1.20 compatibility. Behaviour-preserving; resolves all variable "X" is accessed inside size(...) ... must precede it with the pin operator warnings under 1.20-rc.4 (cleanup.md C1).
  • Pin bitstring size variable in Cartouche.VmTestHelpers.word/2 (test/support/vm_test_helpers.ex:11) — missed in the initial C1 sweep; same Elixir 1.20 compat fix.
  • Remove leading-underscore on expected in Cartouche.Solana.PDATest "wrong bump" test (test/solana/pda_test.exs:137) — variable is used inside the match?/2 guard at line 143, so the underscore was misleading and fired an Elixir 1.20 warning.
  • Cut dialyzer noise floor from 6,620 to 1,626 warnings by fixing typespecs in the upstream :abi library. Root cause was that ABI.encode/2, ABI.decode/2-3, ABI.decode_event/3-4, ABI.TypeEncoder.encode/2, and ABI.TypeDecoder.decode_raw/3 lacked @spec declarations, and ABI.FunctionSelector.t() declared returns: type (singular) while the runtime and ABI's own doctests use returns: [argument_type]. Dialyzer's inferred success typing for ABI.encode/2 collapsed the struct branch to function: nil, types: [] only, so every populated selector at every cartouche callsite was flagged as will never return, cascading through lib/cartouche/contract/i_console.ex. Fixed in the zenhive/abi fork and published to hex.pm as hieroglyph 1.0.0 (hex package name only; ABI module namespace preserved). cartouche consumes the patched library via {:hieroglyph, "~> 1.0", override: true} (see ### Changed above). ABI typespec fixes will be upstreamed via PR to poanetwork/ex_abi. (cleanup.md A1+A2; residual cascade tracked under follow-up A1b.)
  • Restore Cartouche.Signer @moduledoc (was @moduledoc false with module-level prose stuck in a @doc that collided with start_link/1's @doc). Eliminates the last compile warning under Elixir 1.20-rc.4 and aligns with cleanup.md's documentation policy (avoid @moduledoc false).
  • Replace @moduledoc false with descriptive @moduledoc on six submodules whose t() types are referenced from outer public specs: Cartouche.VM.Input, Cartouche.VM.Context, Cartouche.VM.ExecutionResult, Cartouche.Trace.Action, Cartouche.Receipt.Log, Cartouche.DebugTrace.StructLog. Eliminates all "documentation references type X but the module is hidden" warnings from mix docs; clean docs build for the publish cut (ROADMAP Task 36).
  • IAL/markdown collision in four Cartouche.Hex doctest blocks (decode_hex/1, from_hex/1, decode_hex_number/1, encode_hex_result/1): bumped doctest source indent from 4 to 6 spaces so the heredoc-stripped output reaches the 4-space code-block threshold instead of being parsed as prose lines starting with {.
  • Drop /arity suffix on private-function references in this CHANGELOG (Cartouche.Solana.Transaction.read_instructions, Cartouche.VM.static_call) so ex_doc no longer attempts to auto-link non-public functions and emit broken-link warnings.

Documentation

  • Correct DEV.md Sleuth regeneration command — the canonical ABI source is ./priv/Sleuth.json (vendored), not the previously documented ../sleuth/out/Sleuth.sol/Sleuth.json external path.

[0.0.1] — 2026-04-22

Initial placeholder release. Claims the cartouche hex namespace under ZenHive ownership.

Active development (fork of hayesgm/signet) lands in 0.1.x.

Attribution

Cartouche is an attributed fork of hayesgm/signet, originally authored by Geoffrey Hayes at Compound Labs, Inc. (2022). The upstream MIT license is preserved alongside the ZenHive copyright in LICENSE.