CHANGELOG.md

# Changelog

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

## [Unreleased]

## [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 `send`ing 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/1`s 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:0` → `recover_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.HexError` → `Cartouche.Hex.InvalidHex` and `Cartouche.VM.VmError` → `Cartouche.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 (`0xee000001`–`05`) 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](https://github.com/hayesgm/signet), originally authored by Geoffrey Hayes at Compound Labs, Inc. (2022). The upstream MIT license is preserved alongside the ZenHive copyright in `LICENSE`.