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

[Unreleased]

[0.2.2] — 2026-06-05

Changed

  • Cartouche.Assembly.compile/1 — collapse the seven fixed-arity operand heads (opcode in @one_operand@seven_operands) into a single is_tuple/1 clause that reads each opcode's operand count from @opcodes and validates arity against it. Behavior-preserving: operand ordering (reverse-compile so args land on the stack in source order), arity validation, and raise-on-unknown/wrong-arity are all unchanged — confirmed by new test/assembly_test.exs cases for 1- and 2-operand opcodes plus a wrong-arity ({:add, 1}) raise. Also drops a dead if false && x == 0 branch in the integer clause. Motivated by keeping Dialyzer's success-typing fixpoint from expanding the 7-shape tuple product under compile/1 self-recursion — that tuple_set product was a ~30 GB downstream-dialyzer memory bomb on OTP 29. Measured downstream (onchain, plt_add_deps: :apps_direct, cold PLT build, peak RSS via /usr/bin/time -l): 27.6 GB → 1.02 GB (~27×), full IConsole left untouched (it was never the cause — see Task 103).

[0.2.1] — 2026-06-04

Added

  • Wire mix descripex.manifest + Cartouche.Manifest runtime wrapper + ## API discovery README section (Phase 12 / ROADMAP Task 89 / INE-49 / PR #62, delegated to Cursor). mix manifest alias in mix.exs runs descripex.manifest --pretty --output api_manifest.json --app cartouche — descripex 0.6's task requires --app cartouche to scope discovery to Cartouche-namespaced modules. New Cartouche.Manifest module wraps Descripex.Manifest.build(Cartouche.__descripex_modules__()) so HTTP endpoints / MCP servers / A2A agents can serve the live manifest from a running BEAM without an out-of-band JSON artifact (same shape as the static export). api_manifest.json gitignored — regenerated from source on demand, never checked in to avoid merge drift; intentionally not added to package: files so the hex package stays light. README.md ## API discovery section documents the three-level Cartouche.describe/0,1,2 progressive-disclosure surface plus the static-vs-runtime split (mix manifest build-time artifact vs Cartouche.Manifest.build/0 runtime accessor). Path A scope amendment surgically resolved a self-flagged + Codex-GitHub-bot-flagged P2 blocker before merge: descripex's api() macro embeds option default: values into the JSON-encoded annotation metadata, so Jason rejected the existing default: @sleuth_address references in lib/cartouche/sleuth.ex (the module attribute is a raw 20-byte binary; not UTF-8 encodable). Surgical 3-site swap in the metadata-only positions — default: @sleuth_addressdefault: "0xFd946Bf25C47A1Bff567B28bA78a961bf78FF9d2" at lines 25 (api(:query, ...)), 59 (api(:query_annotated, ...)), and 193 (api(:query_v2, ...)). Module attribute (line 13) and the runtime Keyword.pop(opts, :sleuth_address, @sleuth_address) paths (lines 151, 214) intentionally stay on the raw 20-byte binary — annotation metadata needs Jason-encodable hex, runtime path needs already-decoded binary, and the two shapes describe the same address by intent. CodeRabbit's literal-duplication nitpick (consolidate the 3 hex defaults into a single @sleuth_address_hex module attribute) dropped per ceremony floor (cosmetic, ≤5 LOC, the duplication is intentional shape-divergence between annotation and runtime; consolidation would force the metadata path to recompute hex from binary on every recompile, no real correctness benefit). New test/manifest_test.exs covers the runtime accessor — asserts the manifest has the descripex top-level shape (:version binary, :generated_at binary, :modules non-empty list) and that Manifest.build/0 modules match Cartouche.__descripex_modules__/0 exactly via MapSet.equal? (so future module-registration drift fails the test). CI Harness (Elixir 1.18.4 / OTP 27.3) 54s green on amend SHA cb8e93b8; CodeRabbit re-reviewed clean; sobelow drift gate clean (sleuth.ex changes were 3 single-line replacements, no line shifts; sleuth fingerprints had already cleared post-Task 48). Codex GitHub bot's P2 (the Jason-encode blocker itself) was on prior SHA f7e741b8; CI green on amend is the load-bearing confirmation. Tier-1 bot ensemble: CodeRabbit ✅ (one nit dropped), Codex GH bot P2 (resolved by amend), Copilot did not surface. Phase 12 (descripex adoption) is now fully closed — Tasks 82-89 + 93-97 all shipped. Closes ROADMAP Task 89.
  • Top-level Cartouche.Transaction.encode/1 struct dispatcher mirroring decode/1 (ROADMAP Task 93 / INE-46 / PR #56, delegated to Cursor). Each clause delegates to the matching versioned leaf encoder (V1.encode/1, V2.encode/1, V3.encode/1, V4.encode/1), all of which already emit the EIP-2718 envelope byte where applicable, so the dispatcher is a pure pattern-match-and-delegate. Resolves the round-2 PR #42 / INE-32 finding where Cartouche.describe(:transaction, :encode) advertised a function that didn't exist (Codex second-opinion). Synthetic transaction_dispatch_detail/2 helper removed from Cartouche.describe/2:encode now resolves to real metadata for the dispatcher rather than a synthesized guidance entry. Round-trip tests decode(encode(tx)) == {:ok, tx} cover V1/V2/V3/V4 through the dispatcher. Closes ROADMAP Task 93.
  • Phase 12 descripex annotation pass closed via the four metadata-only follow-ups bundled in INE-45 / PR #57 (ROADMAP Tasks 94 + 95 + 96 + 97, delegated to Cursor). Top-level Cartouche.Transaction.decode/1 api(:decode, ..., returns: ...) now enumerates all 7 dispatcher outcomes — {:ok, V1.t() | V2.t() | V3.t() | V4.t()} (the four Vn.decode/1 happy paths via EIP-2718 envelope routing), {:error, :empty_transaction}, {:error, :unknown_envelope_type}, and the delegated {:error, String.t()} from V1/V2/V3/V4.decode/1 for malformed bodies (Task 94, CodeRabbit Major + Codex). Cartouche itself now registered in @descripex_modules with :cartouche alias; the existing api(:describe, ...) block covers describe/0,1,2 via descripex's propagate_hints_to_all_arities; get_contract_address/1 annotated (required because self-registration brings it into the validation test's surface); __descripex_modules__/0 kept @doc false per the __foo__/N reserved-for-metadata convention (Task 95, CodeRabbit Major; option authorized by issue body). V1's value description aligned with @spec and the {2, :wei} doctest example — drops off-spec :eth from the unit list since to_wei/1 accepts integer of wei or {amount, :wei | :gwei} only (Task 96, CodeRabbit Minor). New misattachment-detection pass in test/descripex_validation_test.exs cross-checks Code.fetch_docs/1 meta[:hints] against Module.__api__/0 declarations — every function carrying hints must have a matching __api__() entry for its name AND meta.hints must equal one of those entries' hints (Task 97, Codex severity-5; option (c) from the issue body). Post-merge bookkeeping refined the new test pass to address Codex P2 + CodeRabbit nit on PR #57: Map.new(&{name, hints}) collapse swapped for Enum.group_by(& &1.name, & &1.hints) list-membership so multi-decl pairs (e.g. V2's two api(:new, ...) blocks for arities 9/12, V2's two api(:add_signature, ...) blocks for arities 2/4) are tracked as a list rather than collapsed (Codex's proposed {name, arity} keying would have still collapsed cartouche's V2 pairs since find_arity_and_defaults/2 records max_arity for every block — verified via Tidewave against the merged BEAMs); explicit {:error, reason} flunk branch on Code.fetch_docs/1 mirroring the earlier test pass in the file. Phase 12's annotation sweep is now complete; Task 89 (manifest wiring + README) remains open. Metadata-only — no runtime code path changes. Closes ROADMAP Tasks 94 + 95 + 96 + 97.

  • Cartouche.Block.transactions now decodes full-detail transaction objects when nodes return them via :include_transaction_details: true (ROADMAP Task 66 / INE-44 / PR #55, delegated to Cursor; bot-finding follow-ups landed locally as 13f5e50 on development). Cartouche.Block.deserialize/1 dispatches per-element on params["transactions"]: hash strings preserve the wire String.t() shape (with Hex.decode_word!/1 validation at the boundary so a malformed hash raises at the source rather than leaking downstream); full-detail JSON maps dispatch by "type" to V1/V2/V3/V4.from_json/1 (legacy / EIP-1559 / EIP-4844 / EIP-7702). "0x1" (EIP-2930) raises a specific EIP-2930 ... not yet supported error distinct from the generic unsupported envelope type raised on truly-unknown bytes — operators can tell "known type, not yet ported" apart from "we have no idea what this is" without grepping. New Cartouche.Transaction.JsonField cross-module helper module documents the six shared decoders (decode_destination/1, decode_signature_word/1, decode_y_parity/1, decode_access_list/1, decode_blob_versioned_hashes/1, decode_authorization_list/1) with real @doc strings — the helpers are callable from V1/V2/V3/V4.from_json/1, so real docs are more useful than @doc false here. Defensive nil → [] is applied symmetrically across V2 accessList / V3 blobVersionedHashes / V4 authorizationList — all three are wire-required but from_json/1 is publicly callable and tolerates omission to keep the JSON path defensively symmetric (CodeRabbit's V4-only P2 finding was kept as documented design choice rather than breaking the symmetry). Tier-1 bot ensemble findings addressed locally on development post-merge: CodeRabbit P1 (hash-validation), Codex GitHub bot (type-1 envelope), Doctor 0%-docs CI failure on JsonField. Cursor's VM OOM'd on the PLT build per the no-cloud-dialyzer convention; local mix dialyzer.json post-merge confirmed zero new warnings. Discovered follow-up: ROADMAP Task 99 tracks proper Cartouche.Transaction.V_2930 (EIP-2930, type 0x1) from_json/1 support — both bot-finding pinning tests (block_test.exs "EIP-2930 (type 0x1) raises…" and the matching raw-decode error-tuple test) flip from assert_raise / assert {:error, :unknown_envelope_type} to positive shape assertions when that lands. Closes ROADMAP Task 66.

Fixed

  • Cartouche.Transaction.V3.decode/1 now enforces the EIP-4844 VERSIONED_HASH_VERSION_KZG = 0x01 leading byte on every entry of blob_versioned_hashes, not just the 32-byte length (ROADMAP Task 90 / INE-50 / PR #61, delegated to Cursor; surfaced during INE-30 / PR #41 review by Copilot, deferred from Task 33 per the issue body's "pure decode primitive only" scope). Single-predicate tightening in defp decode_blob_versioned_hashes/1 — the Enum.all?/2 callback now requires is_binary(hash) and byte_size(hash) == 32 and binary_part(hash, 0, 1) == <<0x01>>. Error contract {:error, "invalid v3 transaction"} unchanged. Regression test in test/cartouche/transaction/v3_test.exs mirrors the existing "rejects non-word blob versioned hashes" shape with a 32-byte hash whose leading byte is 0x00. Cartouche.Transaction.V3 coverage held at 98.73% (≥ 95% critical-tier gate); existing fixtures already use 0x01-prefixed hashes (<<1, 0::248>> in representative_tx/0, 0x01908125... in the mainnet blob-tx fixture), so happy paths unchanged. Out of scope (Cursor honored): encoder side (V3.encode/1 trusts struct fields by design), Cartouche.Transaction.V4 blob versioned-hash audit, and the dispatcher in Cartouche.Transaction.decode/1 (Task 94 territory). Tier-1 bot ensemble: CodeRabbit ✅ "No actionable comments" (CHILL profile, 5/5 pre-merge checks); Copilot and Codex GitHub bot did not surface — change is small enough that no escalation was warranted. Harness CI (Elixir 1.18.4 / OTP 27.3) green in 54s. Local mix dialyzer.json --quiet post-merge confirmed zero new warnings (Cursor skipped mix dialyzer per AGENTS.md — cartouche PLT OOM-crashes the cloud VM). Single round, no rework. Phase 9 (raw transaction decode) is now fully closed. Closes ROADMAP Task 90.
  • Bump reach 1.8 → 2.2 and apply hygiene fixes flagged by 2.2's expanded smell surface across lib/cartouche/** (ROADMAP Task 59-lib / INE-47 / PR #59, delegated to Cursor; bot-finding follow-ups landed locally on development post-merge as 5668c0e). Behavior-preserving cleanups in five modules — lib/cartouche/hex.ex checksum loop swaps ~c"0123…" charlist + Enum.at/2 (O(n) per nibble lookup) for static {?0, ?1, ?2, …} tuples + elem/2 (O(1)); lib/cartouche/sleuth.ex swaps Enum.count(args)length(args) in the rescue-message string and splits two is_nil(name) or name == "" guard heads (fallback_name/2, name_keyword/1) into pattern-match clauses (nil/"") — both reach 2.2 idiom-mismatch flags; lib/cartouche/solana/pda.ex create_program_address/2 collapses two Enum.reduce binary concatenations into a single iodata list passed to :crypto.hash(:sha256, [seeds, program_id, @pda_marker]) (avoids two intermediate <<>> allocations per PDA derivation, hot-path concern for vault/PDA seed search loops); lib/cartouche/solana/transaction.ex serialize_message/1 + serialize/1 rewrite the binary-concatenation reductions to IO.iodata_to_binary/1 over a list — but carefully preserve the original raise-on-malformed contract that the prior Enum.reduce(..., fn <<key::binary-32>>, acc -> acc <> key end) enforced via FunctionClauseError, by adding explicit Enum.each(account_keys, fn <<_::binary-32>> -> :ok end) and Enum.each(sigs, fn <<_::binary-64>> -> :ok end) validation passes at the function entry. Without those validators, malformed account keys / signatures would silently flatten into the wire format and the compact-u16 length prefix would lie about how many keys are present, with downstream parsers consuming blockhash / instruction bytes as "remainder of the account-key region"; lib/cartouche/vm.ex two Enum.count/1length/1 swaps (Context.show_stack/1, push_word/2) and adds :stack_overflow to the @type vm_error union (CodeRabbit minor outside-diff; push_word/2 already returned {:error, :stack_overflow} at line 370 but the type didn't list it). New 122-LOC test pass in test/solana/transaction_test.exs covers serialize-message + serialize golden round-trips and pins the raise-on-malformed contract for both passes (account-key length validation + signature length validation) so the boundary contract is regression-tested at the type-correctness level. Tier-1 bot ensemble: CodeRabbit one Major (touched-file defps missing @spec per the project's include_defp: true Credo config) + one Minor outside-diff (vm_error union missing :stack_overflow); Copilot did not surface; Codex GitHub bot did not surface. Local post-merge bookkeeping resolved both bot findings + a CI format failure (Styler at merge-time rewrote PR #60's newly-added alias Cartouche.Solana.Transaction.{Header, Message} references in test/solana/transaction_test.exs's overlap region; the CI merge commit's regenerated long-form refs failed mix format --check-formatted) — fix added the same two aliases to PR #59's alias block so Styler's auto-rewrite matches local + CI; three @specs added to arg_name/2, build_struct_argument_spec/2, unused_name_value_pair/1 in lib/mix/cartouche.gen.ex (CodeRabbit major); :stack_overflow added to vm_error (CodeRabbit minor outside-diff). Local mix dialyzer.json --quiet post-merge confirmed zero new warnings on touched files (Cursor's cloud VM OOM'd on the full-deps PLT build per the no-cloud-dialyzer convention; verification ran on development). .sobelow-skips regenerated post-merge via mix sobelow --mark-skip-all after the three @spec adds in lib/mix/cartouche.gen.ex shifted line numbers (4 prior fingerprints rotated; 6 new fingerprints registered for the post-shift positions) — admin-merge with --admin was load-bearing here since the sobelow-skips drift only resolves on main post-merge per the project's "agents should not touch .sobelow-skips" convention. Closes ROADMAP Task 59-lib (and effectively Task 59 — the lib/mix/cartouche.gen.ex sub-items in the original Task 59 scope were either already shipped under the Task 41/42/50 generator-hardening bundle or rolled into this PR via reach 2.2's expanded smell surface).
  • Harden Cartouche.Solana.Transaction.sign/2 and add_signature/3 against caller-error silent failures (ROADMAP Tasks 91 + 92 / INE-48 / PR #60, delegated to Cursor; bundled because both pass through the same critical-tier ≥95% coverage gate, both touch the same module, and both have the same fix shape — precondition + raise). sign/2 now validates length(seeds) == message.header.num_required_signatures at function entry and raises ArgumentError with both counts named on mismatch — previously, callers could supply a seed list of any length and produce a "full transaction" whose signatures-array length didn't match the message header, surfacing only as an opaque submission failure when the Solana runtime rejected the wire payload downstream. add_signature/3 now guards 0 ≤ index < length(transaction.signatures) and raises ArgumentError on out-of-bounds — List.replace_at/3 silently returns the list unchanged on out-of-bounds indices (verified against Elixir 1.18's documented behavior), so the prior implementation reported success while leaving the signature slot unfilled, masking partially-signed transactions in sponsored-transaction flows. The function-head guard index >= 0 was moved into the body so negative indices raise ArgumentError (matching the AC) instead of FunctionClauseError. Both functions adopt the same contract style (raise) — the module's existing {:error, _} contract is reserved for parse errors (deserialize/1, deserialize_message/1, etc.), so raising on caller error matches idiomatic Elixir (Map.fetch! / Keyword.fetch! / Kernel.elem/2) without conflating the two error shapes. @doc extended on both functions documenting the new error path; @spec unchanged on both (raise contract, no return-type widening needed). Regression coverage in test/solana/transaction_test.exs covers undersupplied (1 vs 2), oversupplied (2 vs 1), and exact-match happy path for sign/2; off-by-one (index = length), negative (-1), far-OOB (99), and in-range regression for add_signature/3. Coverage on Cartouche.Solana.Transaction stayed at 100% (≥95% critical-tier gate). Tier-1 bot ensemble incomplete on this PR — CodeRabbit posted "No actionable comments were generated" (CHILL profile, Pro Plus); Copilot and Codex GitHub bot did not surface reviews, flagged as repo-config follow-up rather than Cursor failure since the PR was opened non-draft per AC. CI Harness (Elixir 1.18.4 / OTP 27.3) green; sobelow drift gate clean (PR didn't touch any .sobelow-skips-fingerprinted file). Local mix dialyzer.json --quiet post-merge confirmed zero new warnings on lib/cartouche/solana/transaction.ex. Closes ROADMAP Tasks 91 + 92.
  • Resolve all 16 outstanding mix dialyzer.json warnings on lib/mix/cartouche.gen.ex. Single root cause: build_selector_fn/1's @spec (line 905) declared a closed-map shape %{names: map(), selector: ABI.FunctionSelector.t(), sig: map()}, but its only caller (build_function_quotes/1 at line 464) passes the full 9-key ctx built in encode_function_call/5. Dialyzer flagged the closed map with invalid_contract, then cascaded — build_selector_fn "will not succeed" → build_function_quotes/1 no_return → 7 sibling build_*_fn/1 helpers (build_event_selector_fn, build_decode_event_fn, build_decode_call_fn, build_decode_error_fn, build_generic_decode_call_fn, build_generic_error_fn, build_generic_event_fn) flagged unused_fun because they were unreachable from a no-return parent → select_emitted_fns/3 similarly unreachable → 3 pattern_match warnings on merge_encode_call_result/2 clauses 221/224/227 because dialyzer believed encode_function_call/5 always crashed → abi_decode_return_spec([]) flagged unreachable for the same chain-of-narrowing reason. Fix: widen all 8 build_*_fn/1 @specs to open-map syntax (:names => map(), …, optional(atom()) => any()), preserving the required-key documentation while admitting the actual ctx shape. The eight @spec lines are functionally identical to the prior closed form except for the trailing optional(atom()) => any() — no runtime behavior change. The 12-warning cascade collapses to zero with this single fix; the standalone pattern_match_cov on normalize_return_types/1 line 1068 (defensive bare-type clause that dialyzer narrows to dead because the upstream ABI.FunctionSelector parser never currently emits a bare type, even though ABI.FunctionSelector.t().returns at deps/hieroglyph/lib/abi/function_selector.ex:51 is officially type() | [argument_type()] | nil) suppressed via @dialyzer {:no_match, normalize_return_types: 1} with a comment pinning the rationale — the clause stays for defense-in-depth against the wider public type, the suppression silences the narrowing without deleting code that the contract supports. .sobelow-skips regenerated via mix sobelow --mark-skip-all to refresh fingerprints after line shifts. Local verification: mix dialyzer.json --quiet 0 warnings (was 16, all on lib/mix/cartouche.gen.ex), mix compile --warnings-as-errors clean, mix credo --strict 0 issues on touched file (6 project-wide TagTODO entries unchanged — tracked-debt convention per feedback_todo_credos_visible.md), mix test.json --quiet 1027/1027 offline pass (29 :integration excluded; the generator has no direct unit tests but its consumers exercise emitted output), mix sobelow clean against refreshed .sobelow-skips. Touched-file scope only — lib/mix/cartouche.gen.ex and .sobelow-skips. Per CLAUDE.md "Never delegate dialyzer-scoped tasks to cloud agents", this stayed local from the start.

  • Harden Cartouche.Sleuth atom-table risks (ROADMAP Task 48 / INE-43 / PR #53, delegated to Cursor). Two-phase delivery — Phase A (b039c5a) raised Cartouche.Sleuth to ≥95% coverage per critical-rules.md "RAISE COVERAGE BEFORE MUTATING" (Sleuth is critical-tier as the public ABI dispatcher); Phase B (954e8ce) swapped the remaining runtime atom mints to String.to_existing_atom/1. query_by/3's encode_<fun> and <fun>_selector derivations now route through existing_function_atom/2, raising a RuntimeError matching the existing try_apply/3 shape on cold function names ("Sleuth module does not define required..."). try_decode/4 gained a named_returns boolean parameter and an ArgumentError rescue that surfaces the INE-17-shape {:error, "error decoding: ..."} envelope (preserves the convention established by the prior decode_structs: true audit rather than minting a new tuple variant); a new preintern_named_return_atoms/1 helper walks only top-level returns (narrower than the existing preintern_decode_struct_atoms/1 which recurses into nested ABI tuples). preintern_name_atom/1 uses an existing_atom/1 wrapper that returns {:ok, atom} | :error and raises ArgumentError on cold names; name_keyword/1's rescue is dead-code defense-in-depth covered by the upstream preintern step. Merge-resolution commit (3c8ee2a) reconciled with development after PR #54 / INE-42 landed. .sobelow-skips regenerated — all three lib/cartouche/sleuth.ex fingerprints (13012D4, 536511, 4A9C581) dropped out, leaving only generator entries for lib/mix/cartouche.gen.ex (Task 41/42/50/59-gen territory) plus config/prod.exs. Local mix dialyzer.json --quiet post-merge confirmed zero warnings on lib/cartouche/sleuth.ex (Cursor's cloud VM OOM'd on the full-deps PLT build per the no-cloud-dialyzer convention; verification ran on development); all 16 remaining warnings sit in lib/mix/cartouche.gen.ex. Closes ROADMAP Task 48 / Sleuth hardening bundle.

  • Phase 5 none() cascade closed (ROADMAP Tasks 21+22 / INE-42 / PR #54). Cartouche.Erc20.exec_trx/3 @spec narrowed from term() to {:ok, binary()} | {:error, term()}, mirroring Cartouche.RPC.execute_trx/3's contract — independently valuable since exec_trx/3 is the public Erc20 transaction-submission surface and the prior loose return type forced consumers to narrow downstream. Tasks 21+22 themselves were effectively resolved by INE-21 / Task 46's prior coverage push (Cartouche.VM.Context, Cartouche.Erc20.Call, Cartouche.VM.InvalidVm exercised the previously-untyped paths well enough that dialyzer no longer narrows the four public heads' success typing to none()); PR #54 was a Cursor cloud-agent delegation that surfaced this finding by attempting @dialyzer {:no_contracts, …} suppressions on Cartouche.VM.exec/3, exec_call/3, Cartouche.Erc20.exec_trx/3, transfer/4 plus cascade-root comment blocks documenting the suppressions. Local Tier-2 commit-review verified all four annotations were dead code (cascade no longer fires on parent commit; verified by removing the annotations and re-running mix dialyzer.json --quiet against the host's PLT — 0 invalid_contract warnings on the four heads in three configurations: PR parent, PR head as-is, PR head with annotations stripped). PR #54 was merged for the spec narrowing; the four dead @dialyzer {:no_contracts, …} annotations + their cascade-root comment blocks were removed in this post-merge bookkeeping commit on development (25 lines deleted across lib/cartouche/vm.ex + lib/cartouche/erc_20.ex). Closes ROADMAP Phase 5 / Tasks 21+22.

  • Backfill @spec on every defp across lib/cartouche/**/*.ex and test/support/**/*.ex, then enable {Credo.Check.Readability.Specs, [include_defp: true]} in .credo.exs (Task 75 / INE-41 / PR #52, delegated to Cursor). Closes the cross-codebase mandate from ~/.claude/includes/development-philosophy.md "Marking Internal API Surface" — every function (def AND defp) now carries an @spec. .credo.exs :files scope on the new check is lib/cartouche/ excluding the auto-generated lib/cartouche/contract/ (where Task 50's regen pass already emits @spec ... :: term()). Placeholder term() shapes used on hot-path data plumbing where domain types are unclear, with a TODO: marker for follow-up tightening; idiomatic shapes (MFA [term()], fn callback (term() -> term()), {:error, term()} reasons) used everywhere they fit. Reviewed and merged locally, not via the cloud-agent default flow — Cursor/Codex VMs lack the memory budget to run dialyzer against cartouche's full-deps PLT, so the harness CI's dialyzer carve-out (:plt_ignore_apps for the GCP cluster + dialyzer dropped from PR CI per CHANGELOG [0.2.0] ### Tooling) means cloud agents can't catch dialyzer regressions in PRs. Local review caught 2 such regressions Cursor introduced — the new client/1 specs on both Cartouche.Signer.CloudKMS and Cartouche.Solana.Signer.CloudKMS referenced Tesla.Env.client/0, but Tesla is in :plt_ignore_apps, so dialyzer reports unknown_type; replaced with term() plus a TODO documenting the PLT trim constraint. CodeRabbit's three findings landed in revision before merge: Cartouche.Recover.decode_signature/1 spec realigned Curvy.Signature.t() | {:error, term()}Curvy.Signature.t() | :invalid_hex (matches Hex.decode_hex/1 bare-atom error propagation through with); test/support/solana_client.ex token_account_fixture/4 amount param non_neg_integer()String.t() (callers pass "1500000000"-shaped strings; impl calls String.to_integer(amount)); Cartouche.Solana.Transaction.CompiledInstruction introduced an account_index :: 0..255 type and applied it to both program_id_index and accounts list elements (matches 1-byte serialization via <<ix.program_id_index>> and :binary.list_to_bin/1). 16 pre-existing dialyzer warnings on lib/mix/cartouche.gen.ex documented as out-of-scope (development-inherited from earlier cloud-agent PRs that couldn't run dialyzer to catch them at submit time). New project-CLAUDE.md rule pins the no-cloud-dialyzer constraint going forward — dialyzer-narrowing tasks stay local until Task 76 (restore dialyzer to a separate workflow on a larger runner) lands. Local verification: mix format --check-formatted clean, mix credo --strict 7-issue baseline (TagTODO only), mix test.json --quiet --exclude integration 1013/1013 offline pass (29 :integration excluded), mix doctor 100%/77 modules, mix sobelow clean against .sobelow-skips, mix dialyzer.json zero PR-introduced warnings. Closes ROADMAP Task 75.

  • Cartouche.Transaction.V2.encode/1 clauses now share unsigned RLP list assembly via a private unsigned_rlp_list/1 helper; the unsigned and signed paths previously held 10 lines of identical struct destructuring (Type I clone surfaced by mix ex_dna). t() widens signature_y_parity / signature_r / signature_s to allow nil so the unsigned clause can take a single struct shape through the helper. New unsigned doctest pins byte-exact equivalence with bare-address access list entries (Phase 8 / ROADMAP Tasks 29+30 / INE-40 / PR #49, delegated to Cursor). CodeRabbit follow-up landed in the merge: normalize_access_list/1 widened to accept bare-address entries (<<_::160>>) alongside {address, storage} tuples — the new bare-address doctest exposed a latent crash (FunctionClauseError) on the signed path that pre-dated the refactor and would have surfaced any time a caller passed a bare-address list to add_signature/2-style flows. Focused ExUnit assertions added in a new V2.encode/1 access_list shapes describe block in test/transaction_test.exs (unsigned bare, signed bare, mixed tuple+bare) so the contract is pinned independently of doctest prose. Refactor-tier (D:3) — no runtime behavior change for previously-supported shapes; the access-list widening is a genuine bug fix (normalize_access_list/1 previously crashed on bare-address entries in the signed path). Local verification: 1013 tests pass (29 integration excluded), mix dialyzer.json clean on lib/cartouche/transaction.ex, mix doctor --raise 100% coverage, mix sobelow --config clean with no .sobelow-skips drift, mix credo --strict 0 issues. The cartouche.gen.ex dialyzer debt that surfaced under the project-wide pre-commit gate (16 pre-existing warnings introduced by PRs #50 / #51 when CI's dialyzer carve-out couldn't catch them) is unrelated to this PR and tracked separately. Closes ROADMAP Tasks 29+30.
  • Cartouche.Typed.encode_value_map/3 @spec rewritten from binary() to bitstring() to match dialyzer's success typing for the for {field, type} <- fields, into: <<>> comprehension (Task 19+20 / Phase 4 / INE-39 / PR #50, delegated to Cursor). find_type/2 left untouched after verification — its committed spec was already {String.t(), Type.t()} matching the impl, so the Linear issue body's claim of Typed.Type.t() was stale; the pinning ExUnit assertions Task 45 added (INE-21) confirm the tuple return shape across the no-match raise, single-match, and multi-match raise branches. Both functions stay defp; no @doc false discussion needed since they're already private. Spec-only — no runtime code path changes. Dialyzer drops the typed.ex:578 invalid_contract warning. CI's full harness ran dialyzer successfully on the merged PR (Cursor's narrow Dialyzer run on Cartouche.Typed + related BEAMs was clean; the cloud VM OOM-killed the full project run, but local CI gate passed). Copilot's low-confidence push-back that the spec should remain binary() to preserve the byte-alignment invariant hash_struct/3's <> operator requires (type_hash <> encode_data would raise at runtime on a non-byte-aligned bitstring) was reviewed and accepted as a defensible internal-defp choice for now — every actual branch in Type.encode_data_value/2 returns 32-byte aligned binary, so the impl IS byte-aligned in practice. The stricter durable fix (tighten the impl so dialyzer can prove binary(), restore the original spec) filed as Task 98 follow-up. Closes ROADMAP Tasks 19+20 / Phase 4.
  • Mix.Tasks.Cartouche.Gen now emits @doc and @spec on every public def it generates (Task 50 / INE-37 / PR #51, delegated to Cursor). The generator attaches an ABI-derived doc string and a spec to each encode_*, decode_*, *_selector, call_*, estimate_gas_*, call_log_*, exec_vm_*, fallback/receive variant, and pre-intern helper. Doc strings derive from FunctionSelector-formatted signatures (literal bytes for synthesized fallback/receive calldata so the rendered prose isn't a misleading FunctionSelector dump). Specs derive via the existing ABI-type → Elixir-type mapping, with two contract-shape fixes the annotation pass surfaced: decode_*_call/1 is typed binary() :: <decoded inputs> (calldata in, decoded inputs out — not output types as an earlier draft had it), and exec_vm_* returns track unwrapped runtime behaviour (m when is_map(m) -> {:ok, m}; [decoded] -> {:ok, decoded}; els -> {:ok, els} — the multi-clause unwrap that exists in the generator template, now reflected in the spec instead of being papered over with a flat t()). Tuple-return ABI types render as Elixir tuple AST ({non_neg_integer(), boolean(), <<_::160>>}) rather than a list. .doctor.exs ignore_paths: [~r"^lib/cartouche/contract/"] removed; mix doctor clean across the regenerated Cartouche.Contract.IConsole and Cartouche.Contract.Sleuth. New generator regression tests in test/mix/cartouche_gen_test.exs walk every public def the generator emits and bind @doc NAME@spec NAMEdef NAME via a same-name regex (annotation_stanza/2), so future drift fails per-function rather than passing on a loose pattern match. .sobelow-skips regenerated for line shifts in lib/mix/cartouche.gen.ex. Three bot-ensemble revision rounds resolved before merge: Codex GitHub bot's P1 (tuple typespec AST) and P2 (decode_*_call/1 input types), Copilot's three line-level findings (fallback/receive doc using FunctionSelector for synthesized signatures, exec_vm_* spec inconsistency vs unwrapped return, regex weakness in test 218), and CodeRabbit's two findings (same regex weakness + @spec on private build_preintern_* builders); one CodeRabbit .sobelow-skips drift claim was a false positive (CI harness drift gate passed at the merged commit). Closes ROADMAP Task 50.
  • Annotate Ethereum utilities + primitive bundle with descripex api() metadata (Phase 12 / ROADMAP Task 88 / INE-35 / PR #47, delegated to Cursor). use Descripex registered with namespaces under /ethereum/ across the high-traffic utilities — hex, erc20, sleuth — and the small primitive co-domain — hash, address, wei, chain, recovery_bit — plus a sibling /base58 namespace for Cartouche.Base58 (shared between Ethereum-adjacent and Solana code paths, so neither /ethereum/ nor /solana/ claims it). api() blocks placed above each existing @doc so prose survives in slot 4 and hints land in slot 5. The 11 previously-undocced Cartouche.Hex defs and the 1 previously-undocced Cartouche.Sleuth def each get minimal @doc prose so doctor's documentation gate passes (closing the gap as a side effect of the annotation pass). Cartouche.Hex encode/decode pairs explicitly document the inverse relationship in returns: and pin input shape (0x-prefixed hex string vs raw binary) on every function — the highest-traffic agent-misuse vector for the module. Cartouche.Wei unit conversions annotate input/output unit explicitly (wei ↔ gwei ↔ ether) on every helper. The 9 spec modules expanded to 11 during review when Codex GitHub bot's P2 push-back on Cartouche.Erc20.CallData and Cartouche.Erc20.Call (both received use Descripex annotations during implementation but neither was registered in @descripex_modules, so Cartouche.describe() would never have surfaced them) was resolved by registering both submodules with :erc20_call_data / :erc20_call aliases following the existing :solana_* / :transaction_v* pattern — extends the spec slightly (9 → 11 modules) but keeps the existing annotation work intact rather than stripping use Descripex from the submodules. Three revision rounds resolved before merge: round 1 addressed three metadata correctness items from the bot ensemble — Address.from_public_key/1 description corrected from "DER-encoded" to "uncompressed secp256k1 public key in SEC1 form" (the function pattern-matches <<4, public_key_raw::binary>> per SEC1, not ASN.1 DER; flagged by Copilot + CodeRabbit Major), the misplaced api(:decode_hex_number, ...) block above decode_hex_number!/1 renamed to api(:decode_hex_number!, ...) with integer/raise semantics (flagged by Codex P2 + Copilot duplication warning), and Cartouche.Erc20's exec_trx / call_trx :errors metadata reworded to say "defaults when absent" matching the actual Keyword.put_new/3 semantics rather than "merges into caller options" (flagged by CodeRabbit Minor; defensible interpretation choice — realigned wording rather than mutating runtime semantics, since :errors defaults are an explicit metadata concern, not a runtime composition concern); round 2 rebased the PR onto origin/development after PR #46 (INE-36) merged, resolving conflicts in lib/cartouche.ex (preserved PR #46's custom Descripex alias/discovery layer alongside the new utility modules) and regenerating .sobelow-skips against the rebased source; round 3 resolved two remaining items — address.ex:23's @doc body still drifted from the corrected api() block (DER vs SEC1) after round 1's metadata fix landed but the prose line directly below it stayed unchanged (flagged by user push-back + CodeRabbit follow-up; agents reading Cartouche.describe(:address, :from_public_key) would have seen SEC1 in hints but DER in the prose), and the discoverability scope question on the Erc20 submodules described above. .sobelow-skips regenerated for line shifts triggered by Cartouche.Sleuth metadata insertion (Sleuth is on the project's accepted-pending-fix String.to_atom list pending Task 48); harness sobelow-drift gate green on the merged PR. Metadata-only — no runtime code path changes. Tasks 89 (manifest wiring + README) and the five PR-flagged Phase 12 follow-ups (Tasks 93-97) remain.
  • Mix.Tasks.Cartouche.Gen now derives separate has_bytecode (init) and has_deployed_bytecode flags from the artifact's bytecode declarations and routes generator emission accordingly (Task 41 / INE-36 / PR #46, delegated to Cursor). Constructor abort?/3 continues to gate on init has_bytecode; the :pure clause of select_emitted_fns/3 now matches a literal true on the deployed flag, so exec_vm_* only emits when deployed bytecode is actually present in the input. Asymmetric Solidity artifacts (init present + deployed absent, or vice versa) previously misbehaved — the first emitted exec_vm_* referencing an undefined deployed_bytecode/0 and produced a CompileError at consumer load; the second skipped exec_vm_* even though the runtime would have worked. Pre-existing in upstream signet, preserved by Task 40's refactor, low trigger probability because Hardhat/Foundry artifacts always pair both bytecodes — caught now under deliberate asymmetric coverage. New describe "bytecode shape coverage" block in test/mix/cartouche_gen_test.exs covers both shapes with both string-match assertions (InitOnly: refute "deployed_bytecode()" catches the original CompileError class) and runtime function_exported?/3 checks against the compiled module. lib/cartouche/contract/{i_console,sleuth}.ex regenerated zero-diff because both Hardhat artifacts ship paired bytecodes. .sobelow-skips regenerated for the line shifts in lib/mix/cartouche.gen.ex (4 of 6 fingerprints rotated; CI sobelow drift gate green on the merged PR). Closes ROADMAP Task 41 / INE-36.
  • Cartouche.VM.Context.init_from/2 @spec narrowed from the abstract t() return to a concrete %__MODULE__{...} struct literal whose field types match the function's actual initial-state construction — constants pinned where the function emits literals (pc: 0, halted: false, stack: [], memory: <<>>, tstorage: %{}, reverted: false, return_data: <<>>) and existing type aliases used for the varying fields (code, code_encoded, op_map, ffis). Cartouche.VM.Context.@type t/0 deliberately stays broad — it describes mutated runtime contexts after pc advances / stack pushes / etc., consumed by callers operating on post-init state. Spec-only; runtime behavior unchanged. Dialyzer drops lib/cartouche/vm.ex:104 invalid_contract. Closes ROADMAP Task 23 / INE-29 and Phase 6.
  • Cartouche.Solana.RPC now has permanent regression coverage for the previously-untested option/filter/encoding paths surfaced during INE-25 / PR #37 review. New test/solana/rpc_test.exs assertions ground the :encoding opt on get_account_info/2, get_multiple_accounts/2, and get_transaction/2 against recorded JSON-RPC params (covering :base58, :base64, :"base64+zstd", :json_parsed, plus binary-string passthrough); the get_token_accounts_by_owner/3 ArgumentError raise when neither :mint nor :program_id filter is provided; and the send_transaction/2 :encoding / :skip_preflight / :preflight_commitment / :max_retries opts. A RecordingClient test double captures outgoing JSON-RPC method + params via send/2 to the test process so each path fails if the wire shape regresses. Cartouche.Solana.RPC coverage clears the standard ≥80% gate. Test-only — no lib/cartouche/solana/rpc.ex mutation. Closes ROADMAP Task 77 / INE-27.

Added

  • Raw transaction decode across V1/V2/V3/V4 (Phase 9 / ROADMAP Task 33 / INE-30 / PR #41). New Cartouche.Transaction.decode/1 dispatcher routes by EIP-2718 envelope byte: 0x02V2.decode/1 (EIP-1559), 0x03V3.decode/1 (EIP-4844 blob), 0x04V4.decode/1 (EIP-7702 set-code). Legacy V1 falls through to V1.decode/1 when the leading byte is >= 0x80 (RLP list prefix); empty input returns {:error, :empty_transaction}; reserved type bytes (0x01, plus the < 0x80 envelope-byte range) return {:error, :unknown_envelope_type}. Each typed envelope's decode/1 is a strict round-trip with the corresponding encode/1 — RLP shape, address byte_size, signature r/s bounds, access lists, blob versioned hashes, authorization lists are all validated at parse time. Out of scope per the original issue body: EIP-4844 versioned-hash 0x01 version-byte enforcement (VERSIONED_HASH_VERSION_KZG) — Copilot flagged this on PR #41; filed as Task 90 ([D:1/B:2/U:2 → Eff:2.0] 🚀) since the encoder produces correct hashes and the strictness only matters when decoding adversarial mainnet payloads. Coverage for the new decode/1 paths sits at the critical-tier (≥95%); the dispatcher and each Vn.decode/1 are exercised against round-trips from their encode/1 plus malformed-envelope rejection cases.
  • Mainnet archive integration anchors for the four trace methods (trace_transaction, trace_call, trace_callMany, debug_traceCall) deferred from Task 61's v1 sweep. New describe "trace methods" block in test/rpc_integration_test.exs exercises every clause of Cartouche.Trace.Action.deserialize/1: the "call" clause via the existing type-0/type-2 receipt anchors, the "init" clause via a fresh CREATE anchor (block 18,000,000, contract-deployment tx — pins type == "create", 20-byte result_address, non-empty result_code), and the "refundAddress" clause via a fresh pre-Cancun SELFDESTRUCT anchor (block 11,500,000, CHI gas-token free-up — pins suicide-action refund_address shape). A type-4 EIP-7702 anchor (block 23,600,000, post-Pectra delegation tx) confirms the wire-format type / callType strings remain CALL-family (no new opcode mnemonics) — closing the loop on Task 68's premise correction. trace_call and debug_traceCall reuse the WETH9 totalSupply() invariant at block 18,000,000 to pin output bytes against @weth9_total_supply; trace_callMany adds a second WETH9 read (balanceOf(address(0))) to exercise multi-call list shape. Assertions pin only fields that are deterministic across geth/reth/erigon (wrapper struct, transaction_hash round-trip, top-level type, deterministic outputs); internal gas_used / subtraces / struct_logs are shape-only since node implementations differ on those. Closes ROADMAP Task 62.
  • Bootstrap descripex adoption (Phase 12 / ROADMAP Task 82). :descripex is now a direct dep instead of transitive-via-:hieroglyph, so consumer mix.exs files don't need to add it to use the discovery API. The top-level Cartouche module exposes describe/0,1,2 and __descripex_modules__/0 via use Descripex.Discoverable with an initially empty registered-module list. A new validation test in test/descripex_validation_test.exs walks the registered list and flunks-with-the-offending-function-name when any non-@doc false public function is missing meta[:hints] — trivially passes today, grows teeth as the Phase 12 annotation passes (Tasks 83-88) register modules. Tasks 83-89 are unblocked.
  • Annotate Cartouche.RPC + response/trace decoders bundle with descripex api() metadata (Phase 12 / ROADMAP Task 84 / INE-31 / PR #44). use Descripex registered across all 7 modules with namespaces under /ethereum/: rpc, block (+ block/withdrawal), receipt (+ receipt/log), fee_history, debug_trace (+ debug_trace/struct_log), trace (+ trace/action), trace_call. All 11 module entries (7 primary + 4 nested struct modules) added to Cartouche.@descripex_modules, so Cartouche.describe(:rpc) / .describe(:block) / .describe(:receipt) / .describe(:fee_history) / .describe(:debug_trace) / .describe(:trace) / .describe(:trace_call) return Level-2 listings, and .describe(:rpc, :get_block_by_number) (etc.) returns Level-3 detail with non-empty params, returns, description. Each api() block sits above the existing @doc so prose remains in slot 4 while hints land in slot 5. Block-tag args document the post-Task 60 union shape ("latest" / "pending" / integer); :exchange_data params (e.g. nonce, gas) carry source: pointers to the upstream RPC method that produces them. Metadata-only — no runtime code path changes — except for one push-back fix the metadata exposed: Cartouche.RPC.fee_history/1 now calls normalize_block_param/1 on :newest_block before forwarding to eth_feeHistory (it previously sent the raw value unchanged, which would have produced -32602 Invalid params from real Ethereum nodes whenever an agent followed the metadata's documented integer support); regression coverage added in test/rpc_test.exs asserting eth_feeHistory sends "0x37" for newest_block: 55. Tasks 85-88 remain.
  • Annotate Cartouche.Transaction + nested V1 + V2 modules with descripex api() metadata (Phase 12 / ROADMAP Task 85 / INE-32 / PR #42). use Descripex registered with three namespaces — /ethereum/transaction (top-level), /ethereum/transaction/v1 (legacy V1), /ethereum/transaction/v2 (EIP-1559 V2). 21 public defs annotated across the three modules; all three module entries added to Cartouche.@descripex_modules, so Cartouche.describe(:transaction) / Cartouche.describe(:transaction_v1) / Cartouche.describe(:transaction_v2) return Level-2 listings, and .describe(:transaction_v2, :new) (etc.) returns Level-3 detail with non-empty params / returns / description. V2's new/9 and new/12 arities each get their own api() block (different param surfaces — without/with explicit signature triplet); both packed add_signature/2 and expanded add_signature/4 forms get separate annotations. Top-level Cartouche.Transaction advertises decode/1 (the EIP-2718 envelope dispatcher from Task 33) plus a synthetic transaction_dispatch_detail/2 entry guiding consumers toward V1.encode/1 / V2.encode/1 for concrete encoding. Each api() block sits above the existing @doc so prose remains in slot 4 while hints land in slot 5. Three review rounds resolved before merge: round 1 restored two TODO: markers Cursor stripped (block.ex:294, solana/transaction.ex:399-400) and split V2.new/12 annotation from V2.new/9; round 2 landed the rebase + sig-param removal from V2.new/9; round 3 surfaced five Phase 12 follow-ups (Tasks 93-97) per the established PR-flagged-follow-up precedent — phantom Cartouche.Transaction.encode/1 in the synthetic dispatcher entry (Codex second-opinion), top-level decode/1 return-shape advertised as V1-only when the dispatcher actually returns 7 outcomes (CodeRabbit Major + Codex), unannotated Cartouche.describe/0,1,2 + __descripex_modules__/0 discovery helpers (CodeRabbit Major), V1 value annotation drift (CodeRabbit Minor), validation-test gap that can't catch wrong physical hint attachment (Codex severity 5). Metadata-only — no runtime code path changes. Tasks 86-88 remain.
  • Annotate Solana stack with descripex api() metadata (Phase 12 / ROADMAP Task 87 / INE-34 / PR #45). use Descripex registered with namespaces under /solana/ across all 9 modules — signer, transaction, keys, pda, ata, programs, system_program, token_program, token — and api() blocks placed above each existing @doc so prose survives in slot 4 and hints land in slot 5; all 9 module entries added to Cartouche.@descripex_modules. An explicit @descripex_aliases map registers :solana_signer / :solana_transaction / :solana_keys / :solana_pda / :solana_ata / :solana_programs / :solana_system_program / :solana_token_program / :solana_token so Solana short names resolve without colliding with the Ethereum surface (:signer / :keys / :transaction continue to point at the Ethereum modules). Cartouche.describe(:solana_transaction, :sign_partial) returns Level-3 detail explicitly documenting the empty-signer placeholder return shape from Task 57 ("with required signer slots filled by signatures where provided and zero-filled placeholder signatures elsewhere; an empty signer map returns the unsigned transaction message with every required signature slot set to <<0::512>>"); regression coverage in test/descripex_validation_test.exs asserts the description matches verbatim. Solana.Signer's 4 previously-undocced defs covered: child_spec/start_link get api()-generated @doc (closing the doctor gap as a side effect); GenServer init/handle_call get @doc false per the internal-surface convention. lib/cartouche.ex itself now declares use Descripex, namespace: "/cartouche" and registers api() blocks for the public discovery helpers describe/0,1,2 (closing the CodeRabbit-flagged self-discoverability gap from Task 85's review); __descripex_modules__/0 gets @doc false per the __foo__/N reserved-for-metadata convention. The transaction_dispatch_detail/2 private helper drops the explicit :transaction short-name match — alias normalization at the public entry point now resolves it before dispatch. Two revision rounds resolved before merge: round 1 addressed CodeRabbit Round 1's three test-quality push-backs (alias loop strengthened to assert each alias resolves to its expected module via ==; the async :contracts config test flipped to async: false and on_exit restores the prior value rather than deleting the whole key; wrapped_sol_mint return type corrected from :solana_program_id to :solana_mint since it returns a mint address, not a program id); round 2 resolved a lib/cartouche.ex merge conflict against newer development (Solana RPC + Ethereum aliases preserved alongside the new Solana builder aliases). Two CodeRabbit Round 2 quick-win nitpicks dropped per ceremony floor (>5 LOC cosmetic, no correctness impact) — per-arity :hints shape assertions and describe/* happy-path doctests; the existing ExUnit assertions cover the contract that matters. Codex GitHub bot's P1 (alias as loop var) was confirmed as a false positive — alias is a special form/macro, not a reserved keyword, and is valid as a variable name; CI compile + run proves it. Two pre-existing Solana.Transaction correctness gaps surfaced by the bot ensemble — sign/2 doesn't validate length(seeds) == message.header.num_required_signatures (can produce signature-count-mismatched transactions Solana RPC rejects); add_signature/3's List.replace_at/3 silently no-ops on out-of-bounds index (no error to caller, partially-signed transaction looks complete) — out of scope per the explicit "no function-body changes" rule and filed as Tasks 91 + 92 (critical-tier 95% coverage gate before mutation). Metadata-only — no runtime code path changes. Task 88 remains.
  • Annotate Cartouche.Solana.RPC with descripex api() metadata (Phase 12 / ROADMAP Task 86 / INE-33 / PR #43). use Descripex, namespace: "/solana/rpc" registered; 19 api() blocks placed above each existing @doc so prose survives in slot 4 and hints land in slot 5; Cartouche.Solana.RPC added to Cartouche.@descripex_modules. Cartouche.describe(:solana_rpc) returns Level-2 listings; .describe(:solana_rpc, :get_balance) (etc.) returns Level-3 detail with non-empty params / returns / description. Pubkey descriptions standardized on raw 32-byte binary form (<<_::256>>, Base58-encoded internally by encode_pubkey/1) — agents shouldn't pre-Base58 inputs. Three review rounds resolved before merge: round 1 corrected send_rpc/3 return metadata (type: :rpc_errortype: :term) and aligned pubkey descriptions across get_balance, get_account_info, get_multiple_accounts, get_token_account_balance, get_token_accounts_by_owner, get_recent_prioritization_fees, request_airdrop with the actual binary @spec; round 2 fixed replace_recent_blockhash (was annotated :exchange_data with a source: pointer — it's a boolean flag) and get_health return shape (:ok_error_tuple was misleading — actual shape is :ok | {:error, term()} per Task 64); round 3 standardized the four remaining :exchange_data sites (get_transaction.signature, send_transaction.transaction, simulate_transaction.transaction, send_and_confirm.transaction) to kind: :value matching the get_signature_statuses precedent — caller-supplied wire-encoded payloads are values regardless of whether the agent obtained them via prior fetches, and get_signature_statuses already established the convention for the same shape. Post-merge cleanup: get_token_accounts_by_owner/3 @doc realigned with the api/2 block + implementation (impl uses cond checking :mint first, then :program_id, raises only when neither is provided — so both filters are allowed and :mint takes precedence; the prior @doc claimed "exactly one filter" which contradicted both surfaces). Metadata-only — no runtime code path changes. Tasks 87-88 remain.

  • Annotate Cartouche.Signer + Cartouche.Keys with descripex api() metadata (Phase 12 / ROADMAP Task 83 / INE-28 / PR #39). Cartouche.Signer declares use Descripex, namespace: "/ethereum/signer" with api() blocks for child_spec/1, start_link/1, sign/3, address/1, chain_id/1, and sign_direct/4; Cartouche.Keys declares namespace: "/ethereum/keys" with an api() block for generate_keypair/0. Both modules now appear in Cartouche.__descripex_modules__/0, so Cartouche.describe(:signer) / Cartouche.describe(:keys) return Level-2 listings and Cartouche.describe(:signer, :sign_direct) (etc.) returns Level-3 detail with non-empty params, returns, description. Each api() block sits above the existing @doc so the human-readable prose remains in slot 4 while hints land in slot 5. Metadata-only — no runtime code path changes; param kinds use :value for caller-supplied inputs (chain id, signer name, MFA, message bytes) per the Task 83 instruction set. sign_direct/4's chain_id_or_name description is "Ethereum chain id integer or configured chain atom" — explicitly excludes string inputs that Cartouche.Chain.parse_id/1 would reject. Tasks 84-88 remain.

Bumped

  • mix hex.outdated refresh (the 0.2.1 publish set): decimal 3.1.0 → 3.1.1 (patch, inside the existing ~> 3.1 pin — no constraint change), descripex ~> 0.6.0~> 0.7.0, ex_unit_json ~> 0.4.3~> 0.5.0, meck ~> 1.1.1~> 1.2.0. Only decimal and descripex are runtime deps that reach the published package; ex_unit_json and meck are only: [:dev, :test] and never appear in the hex package's requirements. ex_unit_json 0.5 enables default flaky-retry healing — a bare run with failures re-runs only the failed tests once, healed flakes move to a top-level flaky[] array and stop blocking (exit 0 when every first-run failure heals); disable per-project with config :ex_unit_json, retry: false. descripex 0.7 compiles clean against Cartouche.Manifest's Descripex.Manifest.build/1 wrapper and the api()/describe/0,1,2 surface (no source changes). Toolchain bumped to Elixir 1.20.0-rc.6 / OTP 29 (.tool-versions); under 1.20's set-theoretic type checker, styler collapsed three single-clause when … , do: guards in transaction.ex / transaction/v3.ex / transaction/v4.ex, and a dynamic/1 identity helper was added in wei_test.exs to erase the static type on the intentional out-of-contract Wei.to_wei({1.5, :eth}) input (the literal otherwise trips a compile-time incompatible types warning; the contract is enforced at runtime via FunctionClauseError). Local verification: mix compile clean, mix test.json --quiet 1085/1085 offline pass (31 :integration excluded; integration coverage not verified this run), mix hex.outdated clean (all rows up-to-date).
  • decimal ~> 2.4.0~> 3.1 and doctor ~> 0.22.0~> 0.23.0 (paired bump; cartouche → 0.2.1). Decimal 3.0 (2026-05-07) tightens IEEE 754 decimal128 bounds — default precision raised 28 → 34, emax/emin capped at ±6_144, and Decimal.parse/1 / cast/1 reject inputs > 34 digits or > 6_144 absolute exponent (DoS-bounded — CVE-2026-32686 mitigations now standard). 3.1 (2026-05-08) added Decimal.new/2 keyword opts forwarded to parse/2 and fixed Decimal.to_integer/1 infinite loop when coefficient is 0 with negative exponent (e.g. Decimal.new("0.0")) — would have bitten cartouche if any code path normalized zero-amount fees/balances through to_integer. The token-math audit blocker from the prior pin comment is cleared: no changes to rounding modes, comparison (Decimal.cmp/2 / compare/2), or normalization semantics — only bounds tightened. Wei values (uint256 raw is ~78 digits; base-unit Decimals stay well under 34 sig figs in practice) fit comfortably under the new defaults, and the 6_178-char to_string cap is irrelevant for EVM amounts. Inspect, String.Chars, and JSON.Encoder protocols pass max_digits: :infinity so debug/serialization paths still succeed on any width. doctor 0.23.0 required decimal ~> 3.1, which was the load-bearing reason for the paired bump — bumping doctor alone would have failed resolution, holding decimal at 2.4 would have held doctor at 0.22. mix.exs decimal comment rewritten to record the audit verdict (CVE bounds + token-math no-op) so the next outdated pass doesn't re-litigate the question; the doctor "pinned to 0.22 because decimal" comment removed (no longer applies). Drive-by :mint added to mix.exs :plt_add_apps so mix dialyzer resolves Mint.Types.status/0 / Mint.Types.headers/0 referenced from deps/finch/lib/finch/response.ex (3 pre-existing unknown_type warnings cleared — surfaced by the prior finch 0.21 → 0.22 bump in 02334af where mint's transitive type aliases joined finch's public type, and plt_add_deps: :apps_direct doesn't pull transitives). Local verification: mix compile --warnings-as-errors clean (Tesla 1.17.0 deprecation warning from tesla/builder.ex is pre-existing dep noise — not new from this bump), mix test.json --quiet 1085/1085 offline pass (31 :integration excluded; integration coverage not verified this run), mix format --check-formatted clean, mix credo --strict 5 design suggestions (all pre-existing TODO tags — tracked-debt convention per feedback_todo_credos_visible.md, unchanged from baseline), mix sobelow --exit Low clean, mix doctor --summary --failed 100% (79 modules, 0 failures — doctor 0.23 ran identically to 0.22 against cartouche's surface), mix dialyzer.json --quiet 0/0 warnings (was 3 on development; cleared by the :mint PLT addition). Decimal changelog: https://github.com/ericmj/decimal/blob/main/CHANGELOG.md.
  • Dev/test dep refresh against mix hex.outdated: reach ~> 2.2.0~> 2.7 (2.2.0 → 2.7.1 — picks up the 5-canonical-CLI surface documented in ~/.claude/includes/reach.md and matches the include's stated minimum), ex_ast ~> 0.11.0~> 0.12 (forced by reach 2.7's ex_ast ~> 0.12.0 requirement; also brings cartouche in line with the global include recommendation), finch ~> 0.21.0~> 0.22 (0.21.0 → 0.22.0); mix deps.update also picked up bandit 1.11.0 → 1.11.1 and ex_doc 0.40.1 → 0.40.3 patch bumps that were already inside the existing ~> pins. Project-level mix.exs line 28 of ~/.claude/includes/development-commands.md updated to match (ex_ast min version ~> 0.11~> 0.12). doctor deliberately held at ~> 0.22.0 — 0.23 requires decimal ~> 3.1 which is blocked by cartouche's intentional decimal ~> 2.4.0 pin (token-amount math semantics — re-evaluate together when a decimal 3.x audit task lands). decimal itself moved 2.4.0 → 2.4.1 (patch, inside the existing pin — no semver concern). Local verification: mix compile --warnings-as-errors clean (Tesla 1.17.0 deprecation warnings from tesla/builder.ex are pre-existing dep noise, not cartouche-side), mix test.json --quiet 1085/1085 offline pass (31 :integration excluded; integration coverage not verified this run), mix doctor --raise 100% / 79 modules, mix hex.outdated four blocking rows cleared (decimal + doctor remain "Update not possible" by the deliberate pins above). No source changes required — reach 2.x's "legacy mix tasks removed" notice is past-tense for cartouche (the only mix reach.* references in the repo are historical narrative in ROADMAP.md / ROADMAP.old.md / cleanup.md dated 2026-04-21 at reach 1.6.0; harness CI does not invoke reach).
  • finch mix.exs pin tightened ~> 0.19~> 0.21 (lockfile already on 0.21.0; no resolution churn). Audit verdict: no code-level adoption opportunities across 0.20 + 0.21 — every enhancement targets streaming (Finch.stream/5, stream_while/5 halt + accumulator-on-failure), pool config (get_pool_status/2, manual termination, start_pool_metrics?), telemetry, or TLS (:supported_groups), none of which cartouche exercises. Cartouche's Finch surface is the simple subset (Finch.build/3 + client.request/3 + Cartouche.HTTP.normalize_finch_result/1 pattern-matching %Finch.Response{} and %Finch.Error{reason:}) across 4 callsites in application.ex, rpc.ex, solana/rpc.ex, open_chain.ex, http.ex. The two changes cartouche silently benefits from in 0.21.0 are the HTTP/1 idle-pool termination fix (#292) and the HTTP/2 server-push crash guard (#333) — both bug fixes, not API additions. The pin tightening exists so downstream consumers' resolvers can't pick a pre-fix 0.19.x/0.20.x even though cartouche's CI runs on 0.21.0. Existing 4 tests in test/http_test.exs continue to cover the Finch.Error / Finch.Response shapes (unchanged across the upgrade range). Closes ROADMAP Tasks 27 + 28.

[0.2.0] — 2026-05-05

Added

  • Cartouche.Transaction.V4 adds EIP-7702 set-code transaction support for Pectra-era authorization-list transactions (0x04). V4 mirrors the existing typed-transaction shape with RLP encode/decode, outer transaction signing/recovery, transaction hashing, and authorization tuple signing/recovery over the EIP-7702 0x05 || rlp([chain_id, address, nonce]) digest. A real mainnet type-4 vector pins raw signed transaction decoding, hash agreement, and both outer-signer and authorization-authority recovery.
  • Cartouche.Transaction.V3 now supports EIP-4844 blob transaction envelopes introduced with Dencun. The new typed transaction module encodes, decodes, signs, hashes, and recovers signers for canonical type-0x03 execution-layer payloads, including max_fee_per_blob_gas and blob_versioned_hashes, while keeping blob sidecars out of scope. A mainnet blob transaction fixture pins byte-for-byte RLP decoding, hashing, and sender recovery against live post-Dencun data. Closes ROADMAP Task 31 / INE-23.

Changed

  • INE-19 optional hieroglyph API adoption: ABI.method_id/1 now replaces local 4-byte selector hashing in lib/mix/cartouche.gen.ex:148 and lib/mix/cartouche.gen.ex:412, while preserving the full 32-byte event-topic hash separately; ABI.decode_error/2 now drives RPC revert decoding at lib/cartouche/rpc.ex:49, with Cartouche's existing full-signature error map contract preserved by mapping the decoded error name and selector back to the matching ABI definition. ABI.encode_packed/2 had no current adoption opportunity after searching lib/** for packed-encoding and keccak patterns.

Fixed

  • Cartouche.Typed return-shape coverage now pins the current private-helper behavior that unblocks the downstream Typed spec rewrite: primitive and custom-type encode_value_map/3 paths are exercised through hash_struct/3 as 32-byte EIP-712 hashes, find_type/2 is exercised through deserialize/encode callers for the {name, %Typed.Type{}} tuple handoff, and both no-match / multiple-match error branches are covered. The live implementation has no map-returning encode_value_map/3 branch; the map-shaped JSON path is pinned separately through serialize/1 so Tasks 19+20 can distinguish encode-data bytes from serialized value maps before rewriting specs. Closes ROADMAP Task 45 / INE-21.
  • INE-17 / Phase 11 decode_structs: true audit re-verified the generator emission shape after PR #24's false-positive closeout: lib/mix/cartouche.gen.ex emits return-field names only as strings in selector metadata (build_selector_fn/1 returns Macro.escape(selector)), not as compile-time atom literals. Both audited decode_structs: true paths therefore needed explicit handling before Hieroglyph 1.4's String.to_existing_atom/1 decode branch: generated exec_vm_* functions now call a generated private helper before ABI.decode/3, while Cartouche.Sleuth.query_v2/4 validates that runtime selector atoms already exist instead of minting caller-supplied atoms. The runtime validation now walks nested tuple and array return types, and generated test bindings were regenerated so fixture exec_vm_* paths carry the same helper. The new Sleuth regressions prove raw Hieroglyph decoding raises with a unique cold return-field atom absent, Cartouche's runtime-selector boundary fails safely without creating it, and nested cold tuple/array names are detected before decode.
  • Coverage now clears the VM dialyzer cleanup entry gate: focused ExUnit tests cover Cartouche.VM.Context.init_from/2 happy and edge setup paths plus the context display helpers, Cartouche.Erc20.Call balance/transfer public entry points, and the Cartouche.VM.InvalidVm raise/rescue contract. This unblocks the downstream Phase 5 none() cascade investigation and Phase 6 VM context type-alignment work without changing runtime behavior. Closes ROADMAP Task 46 / INE-22.
  • Cartouche.RPC.send_rpc/3 and Cartouche.Solana.RPC.send_rpc/3 now return {:error, {:invalid_params, reason}} when outbound JSON-RPC request encoding fails, instead of letting Jason.encode!/1 exceptions escape the documented return contract. Both transports catch Jason.EncodeError and Protocol.UndefinedError, document the invalid-params shape in doctests, and carry widened transport specs that match the audited runtime error paths (JSON-RPC error envelopes, invalid response sentinels, transport errors, decode errors, and the new request-encoding failure). Focused ExUnit assertions cover non-UTF-8 method binaries, tuple params, atom-keyed map params containing non-encodable values, and custom structs without encoders across both transports. Closes ROADMAP Tasks 14, 15, and 35 / INE-25.
  • Mix.Tasks.Cartouche.Gen coverage now clears the standard ≥80% gate using a small synthetic ABI fixture instead of regenerating production bindings. The new generator tests drive constructor, fallback, receive, event, error, no-argument, blank-name, tuple-argument, ABI-only JSON, AST-name fallback, invalid-file, usage-error, asymmetric bytecode, and emitted catch-all decoder paths through the public Mix task entrypoint. This closes ROADMAP Task 44 / INE-20 and unblocks the downstream Generator hardening bundle tasks (41 / 42 / 50 / 59-gen) without mutating lib/mix/cartouche.gen.ex.
  • Phase 11 Hieroglyph bug-fix audit: Cartouche now has targeted regression coverage for the silent Hieroglyph 1.0.0-1.2.0 fixes it depends on. Cartouche.Filter log decoding asserts indexed reference-type event params surface {:indexed_hash, topic}; generated contract call decoding preserves embedded NUL bytes in :string values; signed-int overflow at the ABI encode call layer raises an explicit signed-range error; and zero-length fixed arrays round-trip through ABI encode/decode without crashing. No Cartouche compensation code for the old Hieroglyph behavior was found or removed. Closes ROADMAP Phase 11 bug-fix audit / INE-18.
  • New Cartouche.Transaction.Call struct (destination: <<_::160>>, data: binary()) replaces the %V2{} masquerade for eth_call shapes. Generator-emitted Cartouche.Contract.Sleuth.build_trx_query/3 now returns %Call{} instead of a partial %V2{}; Cartouche.RPC dispatch extended to accept V1.t() | V2.t() | Call.t(). Eth_call params are not transactions — never signed, never broadcast — and the prior abstraction lie drove the invalid_contract cascade through Cartouche.Sleuth. Structural fix collapses the cascade without widening V2 to nullable. Closes ROADMAP Task 54 / INE-10; Task 49 (V2.encode/1 spec duplication) closes as superseded since the cascade is gone.

  • Cartouche.Trace.@type t (and nested Cartouche.Trace.Action) widened to admit nil on fields the runtime proves are optional, matching dialyzer's inferred shape from deserialize/1. Cartouche.TraceCall.@type t widened analogously. Action serialization now nil-safe. New ExUnit assertions in test/trace_test.exs and test/trace_call_test.exs ground the widened types against deserialize/1 JSON both with and without optional fields, per feedback_doctests_not_substitute_for_tests.md. Dialyzer drops trace.ex:408 invalid_contract and trace_call.ex:124 invalid_contract. Closes ROADMAP Phase 3 (Tasks 16+17+18) / INE-14.
  • Cartouche.Filter expired-filter recovery test (test/filter_test.exs) now asserts Process.get(:expired_seen) == true and Process.get(:new_filter_count) >= 2 after the existing receive asserts, pinning the recovery branch by fingerprint instead of relying solely on the mock returning data under a different filter id. A future implementation that silently retried on the same filter id and somehow satisfied the receive contract would now fail. Closes ROADMAP Task 58 / INE-15.
  • Cartouche.RecoveryBit doctests for normalize/2, normalize_signature/2, and recover_base/1 rewritten to chain-agnostic examples — they no longer bake the test-config chain_id=:goerli into expected output. EIP-155 chain-id-dependent behaviour (the :eip155-branch normalize/2 arithmetic and the recover_base(47) raise message) moved to focused ExUnit assertions in test/recovery_bit_test.exs per feedback_doctests_not_substitute_for_tests.md. Closes ROADMAP Task 39 / INE-7.
  • Cartouche.Wei.to_wei/1 now supports :eth inputs for whole integers and exact Decimal values, with :eth documented as the sole ETH-denomination atom (:ether is intentionally unsupported for short-form parity with :wei / :gwei). Decimal is lifted from a transitive dep to a direct runtime pin ({:decimal, "~> 2.0"}); sub-wei precision raises rather than silently rounding. Moduledoc updated to document the bare-integer-treated-as-wei form alongside the tagged-tuple denominations. Closes ROADMAP Task 74 / INE-13.
  • Cartouche.Receipt now preserves EIP-4844 receipt blob fee fields. Type-3 blob receipts decode blobGasUsed and blobGasPrice into new blob_gas_used / blob_gas_price struct fields, while pre-Cancun and non-blob receipts keep both fields as nil. Focused unit coverage pins populated, absent, and "0x0" boundary shapes; the mainnet integration suite adds a real post-Dencun type-3 receipt anchor. Closes ROADMAP Task 67.
  • Generator decode_error/1 template — collapsed dead if true do :not_found else {:ok, "Impossible", <<>>} end branch to def decode_error(_), do: :not_found (lib/mix/cartouche.gen.ex:186-197). Pre-existing Elixir 1.20 type-checker false positive (pattern can never match the type true) at lib/cartouche/contract/i_console.ex:18704 and lib/cartouche/contract/sleuth.ex:107 — never surfaced in CI before because the harness OOM'd during PLT construction before dialyzer reached analysis. Regenerated lib/cartouche/contract/{i_console,sleuth}.ex via mix cartouche.gen against sol/out/IConsole.sol/IConsole.json and priv/Sleuth.json respectively. Closes ROADMAP Task 42 (the dead-branch sub-item from the generator hardening pass).
  • Regenerated bindings now carry @moduledoc false + per-def @doc false + @spec ... :: term() from Task 73's generator pass (which previously only ran on test fixtures). Production lib/cartouche/contract/{i_console,sleuth}.ex were on the pre-Task-73 generator output until this commit. Permissive term() placeholders satisfy Doctor's presence-based gate; ABI type → Elixir type derivation remains pending under Task 50, along with .doctor.exs ignore_paths: [~r"^lib/cartouche/contract/"] removal.
  • KMS signer Goth credential-path coverage now exercises Goth.fetch!/1 end-to-end for both Ethereum secp256k1 and Solana Ed25519 CloudKMS signers. The tests use :meck to patch Goth globally, verify one fetch per credential-path call, and keep the existing Tesla.Mock Cloud KMS response fixtures as the HTTP layer. Closes ROADMAP Task 73.

Tooling

  • CI harness dialyzer step trimmed PLT scope via mix.exs dialyzer: [plt_add_apps: [:mix, :ex_unit], plt_ignore_apps: [:google_api_cloud_kms, :google_gax, :goth, :tesla, :jose]]. Pre-trim deps PLT spanned ~1044 modules, with the GCP cluster (google_api_cloud_kms ~600 generated modules + goth + google_gax + tesla + jose) being the bulk: ~387 dialyzer-expensive modules with auto-generated @specs and macro-heavy crypto. PLT construction OOM'd both the 16 GB ubuntu-latest runner (CI runs 25306079957, 25313348846, 25313517870 — all runner shutdown signal exit 143) AND a 48 GB local Mac (compressor + swap saturation, 21 GB RSS for 31 min wall, system CPU > user CPU 2:1). :plt_ignore_apps truly subtracts from the deps PLT app list (verified via deps/dialyxir/lib/dialyxir/project.ex:50-75Kernel.--(plt_ignore_apps())), not just from warning output. Trade-off: dialyzer no longer type-checks Cartouche.Signer.CloudKMS / Cartouche.Solana.Signer.CloudKMS interactions with GoogleApi.CloudKMS.V1.* / Goth.Token — acceptable since CloudKMS is an optional signer with narrow surface (4 distinct external functions × 2 callsites = 8 unknown_function warnings), Code.ensure_loaded?/1 guards keep runtime correctness independent of GCP deps presence, and the alternative is harness red on every PR until the runner gets an order-of-magnitude RAM bump.
  • New .dialyzer_ignore.exs with two file-scoped entries ({"lib/cartouche/signer/cloud_kms.ex", :unknown_function} + the Solana counterpart) suppressing the 8 GCP/Goth unknown_function warnings the PLT trim creates. Tighter than per-line (won't break on file edits), narrower than full-file (only unknown_function is suppressed — any other class of dialyzer finding in those files would still surface). Both files exist exclusively to wrap GCP signer code, so file-scope is the right granularity.
  • Reverted the .github/workflows/harness.yml swap step added at commit 8e44328 (16 GB swap via fallocate -l 16G /swapfile). ubuntu-latest images already ship with /swapfile active — fallocate against a live swap file returns ETXTBSY (fallocate: fallocate failed: Text file busy, exit 1, run 25314151347), failing the harness before dialyzer ever ran. With the :plt_ignore_apps trim above, the underlying memory pressure is addressed structurally; swap headroom becomes obsolete.
  • Dialyzer removed from cartouche's PR harness (.github/workflows/harness.yml) — last-resort, repo-specific, not a generic "drop dialyzer in CI" recommendation. After the :plt_ignore_apps trim above, cartouche's deps PLT still spans 621 modules and OOM'd the 16 GB ubuntu-latest runner mid-construction (run 25319850405 — runner has received a shutdown signal at 12:52:25 while adding 621 modules to dialyxir_erlang-27.3.4.11_elixir-1.18.4_deps-test.plt). The remaining cluster (hieroglyph, descripex, finch/mint/hpax/inets HTTP stack, ssl/public_key/asn1/crypto/xmerl OTP crypto+XML) doesn't have an obvious next "trim this and the rest fits in 16 GB" target — hieroglyph and descripex are first-party / consumed deeply by cartouche and excluding them would eliminate most of dialyzer's value. Bigger runners (ubuntu-latest-large, 32 GB) cost ~8× per minute, which is outsized for a personal pre-1.0 library where local dialyzer already works (warm priv/plts/ cache, ~30s incremental run). PR CI now runs format / compile (warnings-as-errors) / credo / doctor / sobelow / test+coverage; dialyzer signal stays available locally via mix dialyzer.json. Upstream confirms the carve-out shape: googleapis/elixir-google-api (the monorepo google_api_cloud_kms lives in) runs zero dialyzer in its own CI — .github/workflows/presubmit.yml only invokes mix do deps.get, test per changed client (the Mixfile generated from the protobuf templates ships no dialyzer: block, no :plt_optional flag, no PLT cache); 600+ generated REST modules × hundreds of clients × auto-generated @specs make dialyzer prohibitively expensive even for the package authors. Sibling deps (google_gax, goth, jose) similarly ship no dialyzer hints to consumers. The implicit upstream guidance is "don't bother dialyzing the cluster" — :plt_ignore_apps IS that, and there's no further upstream slimming path to leverage. For other repos: the recommended posture is to KEEP dialyzer in the harness by default; only drop it if the specific repo's CI shows the same OOM signature, and try :plt_ignore_apps for heavy generated-API deps before dropping. The elixir-ci-harness marketplace template (when propagated) will document this ordering — trim first, drop only as last resort. ROADMAP Task 76 captures cartouche-specific restoration paths (separate workflow on a larger runner / nightly cron / more aggressive PLT trim) for if/when cartouche outgrows local-only signal.
  • .sobelow-skips regenerated via mix sobelow --mark-skip-all to track line-number drift from the decode_error/1 template collapse (7 lines → 1 line in lib/mix/cartouche.gen.ex). Same three rules and same code paths — DOS.StringToAtom, Traversal.FileModule ×3 — pure line shifts, no new findings.
  • Mix.Tasks.Cartouche.Gen now emits @moduledoc false + per-def @doc false + @spec ... :: term() annotations on generated test-fixture modules via a new AST post-processor (annotate_internal_defs/1). Permissive term() placeholders satisfy Doctor's spec-coverage gate (presence-based, per memory feedback_doctor_internal_surface_convention.md); type fidelity follows in Task 50 once ABI type → Elixir type derivation is wired. Multi-clause functions get one annotation pair before the first clause (subsequent clauses skipped via a seen-set; duplicate @spec per arity is a compile error). Test fixtures test/support/cartouche/contract/{block_number,ierc20,rock}.ex regenerated; hand-converted test/support/{live,client,signer,solana_client,solana_signer,sleuth_handler,vm_test_helpers}.ex to the same shape. Known regen gap: the generator does not yet preserve the # credo:disable-for-this-file Credo.Check.Readability.MaxLineLength pragma that ierc20.ex needs for its 121-char bytestring topic-0 hash lines (636, 647) — re-added by hand for this commit; the proper fix (conditional pragma emission when long bytestring topics are detected) is captured by Task 50's scope. Production bindings under lib/cartouche/contract/{i_console,sleuth}.ex are intentionally NOT regenerated in this commit — they remain on the old (no @doc/@spec) generator output and the .doctor.exs ignore_paths: [~r"^lib/cartouche/contract/"] exclusion stays load-bearing until Task 50 closes both the regeneration AND the (now-also-needed) term() → ABI-derived type sweep.
  • Sobelow-skip fingerprints in .sobelow-skips re-numbered to track the annotate_internal_defs/1 insertion + its TODO(Task 50): comment block (File.read!/1 868 → 934, File.mkdir_p!/1 906 → 972, File.write!/3 907 → 973). Same three rules and same code paths — pure line-number drift from the new generator helpers above the affected sites. Verified via mix sobelow (clean output).
  • lib/cartouche/transaction.ex line 740: when guard collapsed to a single line (mix format outcome, no semantic change).
  • ExUnit coverage now ignores the generated Cartouche.Contract.IConsole binding via test_coverage: [ignore_modules: [Cartouche.Contract.IConsole]] in mix.exs, so headline mix test.json --cover percentages track hand-written code instead of generated bytecode wrappers.
  • GitHub Actions harness gate at .github/workflows/harness.yml runs mix compile --warnings-as-errors, mix format --check-formatted, mix credo --strict, mix doctor --failed, mix sobelow, and mix test.json --cover --cover-threshold 80 --summary-only --exclude integration on every PR targeting development and on every push to development. Closes the Codex-sandbox-blocked-hex.pm evidence gap (see project memory feedback_codex_sandbox_pr_gap.md) — Codex-delegated PRs now arrive with harness output as PR checks instead of needing the local reviewer to fetch the branch and re-run the suite. Single-row matrix (Elixir 1.18.4 / OTP 27.3); actions/cache@v4 keys on mix.lock hash and covers _build. Integration tests (mainnet archive node) are explicitly excluded from the gate via --exclude integration. Permissions scoped to contents: read; concurrency cancels in-progress runs on the same ref. (Dialyzer is not part of the harness — see ### Tooling above for the OOM rationale.)

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