All notable changes to this project will be documented in this file.
[0.3.0] — 2026-06-12
Changed
- Dependency constraint bumps:
descripex ~> 0.7.0→~> 0.9.1andhieroglyph ~> 1.4.0→~> 1.5. hieroglyph 1.5.0 is the current hex line. descripex 0.8/0.9 fill JSON Schema from@spec/declaredtype:— additive for consumers — but the floor is pinned to 0.9.1, not 0.9.0: 0.9.0's runtime enrichment raised aCaseClauseError(in itsjson_specdep) on the%{non_neg_integer() => <<_::256>>}spec ofSolana.Transaction.sign_partial/2, crashingCartouche.describe/0and__api__/1; descripex 0.9.1 fixes it. No Cartouche code changes, only the floor moves. Minor bump (not 0.2.3) because the public dependency floor changed. - Dependency updates within existing constraints (carried from the 0.2.3 refresh): direct dev/test tooling
credo1.7.18 → 1.7.19,dialyzer_json0.2.0 → 0.2.1; transitivemint1.8.0 → 1.9.0,thousand_island1.4.3 → 1.5.0.banditstays at 1.11.1 — 1.12.0 is outside the intentional~> 1.11.0dev-only pin (PLT-stability constraint).teslaheld at 1.18.2 (not bumped to 1.20.0): 1.20.0'sTesla.Middleware.Compressionnow mandates a:max_body_sizeoption thatgoogle_api_cloud_kms's middleware stack does not pass, raisingfetch_max_body_size!and breaking all 14 CloudKMS signer tests. Pinned inmix.lockuntil the GCP cluster passes the option upstream.
Fixed
- Generator function-name dedup (
dedup_named_abi/5inlib/mix/cartouche.gen.ex) now keys collision detection onMacro.underscore/1instead ofString.downcase/1. The generated identifiers are built fromMacro.underscore(name)(function_names/1), but the dedup keyed onString.downcase(name)— a divergent normalization. A contract exposing bothgetValueandget_valuedowncases them differently ("getvalue"vs"get_value") yet underscores both to"get_value", so neither the exact-dup skip nor the rename fired and the second function emitted a silently-shadowedencode_get_value/1clause (plus shadoweddecode_get_value_call,get_value_selector, …) —encode_get_valuealways producedgetValue's calldata.Macro.underscoresubsumes the prior downcase (it lowercases) and additionally folds camelCase/snake_case collisions. New behavioral regression test (describe "underscore-collision dedup"): generates[getValue(uint256), get_value(uint256)], compiles the module, and asserts two distinctencode_get_value*functions emit distinct calldata — pre-fix they collapse to one. (ROADMAP Task 106 filed for the related unlinked-library/immutable-placeholder bytecode edge.)
Tooling
- Restored dialyzer to the PR harness (
.github/workflows/harness.yml), reverting the 2026-05-04 carve-out (ROADMAP Task 76). The carve-out attributed anubuntu-latestOOM (run 25319850405) to the 621-module deps PLT size and deferred dialyzer to "a larger runner / nightly cron." That diagnosis was wrong: the OOM wasCartouche.Assembly.compile/1's 7-aritytuple_setexploding dialyzer's success-typing fixpoint (the ~30 GB bomb fixed in 0.2.2 / Task 103). A clean coldmix dialyzer(PLT build + analysis) now peaks at ~1.0 GB / ~26s — ~15× under the 16 GB budget — with zero warnings. The step runs inMIX_ENV=devand cachespriv/plts/(pinned outside_build/for exactly this). No larger runner, separate workflow, or extra:plt_ignore_appstrim needed. Closes ROADMAP Task 76.
[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 singleis_tuple/1clause that reads each opcode's operand count from@opcodesand 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 newtest/assembly_test.exscases for 1- and 2-operand opcodes plus a wrong-arity ({:add, 1}) raise. Also drops a deadif false && x == 0branch in the integer clause. Motivated by keeping Dialyzer's success-typing fixpoint from expanding the 7-shape tuple product undercompile/1self-recursion — thattuple_setproduct 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.Manifestruntime wrapper +## API discoveryREADME section (Phase 12 / ROADMAP Task 89 / INE-49 / PR #62, delegated to Cursor).mix manifestalias inmix.exsrunsdescripex.manifest --pretty --output api_manifest.json --app cartouche— descripex 0.6's task requires--app cartoucheto scope discovery to Cartouche-namespaced modules. NewCartouche.Manifestmodule wrapsDescripex.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.jsongitignored — regenerated from source on demand, never checked in to avoid merge drift; intentionally not added topackage: filesso the hex package stays light.README.md## API discoverysection documents the three-levelCartouche.describe/0,1,2progressive-disclosure surface plus the static-vs-runtime split (mix manifestbuild-time artifact vsCartouche.Manifest.build/0runtime accessor). Path A scope amendment surgically resolved a self-flagged + Codex-GitHub-bot-flagged P2 blocker before merge: descripex'sapi()macro embeds optiondefault:values into the JSON-encoded annotation metadata, so Jason rejected the existingdefault: @sleuth_addressreferences inlib/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_address→default: "0xFd946Bf25C47A1Bff567B28bA78a961bf78FF9d2"at lines 25 (api(:query, ...)), 59 (api(:query_annotated, ...)), and 193 (api(:query_v2, ...)). Module attribute (line 13) and the runtimeKeyword.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_hexmodule 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). Newtest/manifest_test.exscovers the runtime accessor — asserts the manifest has the descripex top-level shape (:versionbinary,:generated_atbinary,:modulesnon-empty list) and thatManifest.build/0modules matchCartouche.__descripex_modules__/0exactly viaMapSet.equal?(so future module-registration drift fails the test). CI Harness (Elixir 1.18.4 / OTP 27.3) 54s green on amend SHAcb8e93b8; 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 SHAf7e741b8; 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/1struct dispatcher mirroringdecode/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 whereCartouche.describe(:transaction, :encode)advertised a function that didn't exist (Codex second-opinion). Synthetictransaction_dispatch_detail/2helper removed fromCartouche.describe/2—:encodenow resolves to real metadata for the dispatcher rather than a synthesized guidance entry. Round-trip testsdecode(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/1api(:decode, ..., returns: ...)now enumerates all 7 dispatcher outcomes —{:ok, V1.t() | V2.t() | V3.t() | V4.t()}(the fourVn.decode/1happy paths via EIP-2718 envelope routing),{:error, :empty_transaction},{:error, :unknown_envelope_type}, and the delegated{:error, String.t()}fromV1/V2/V3/V4.decode/1for malformed bodies (Task 94, CodeRabbit Major + Codex).Cartoucheitself now registered in@descripex_moduleswith:cartouchealias; the existingapi(:describe, ...)block coversdescribe/0,1,2via descripex'spropagate_hints_to_all_arities;get_contract_address/1annotated (required because self-registration brings it into the validation test's surface);__descripex_modules__/0kept@doc falseper the__foo__/Nreserved-for-metadata convention (Task 95, CodeRabbit Major; option authorized by issue body). V1'svaluedescription aligned with@specand the{2, :wei}doctest example — drops off-spec:ethfrom the unit list sinceto_wei/1acceptsintegerof wei or{amount, :wei | :gwei}only (Task 96, CodeRabbit Minor). New misattachment-detection pass intest/descripex_validation_test.exscross-checksCode.fetch_docs/1meta[:hints]againstModule.__api__/0declarations — every function carrying hints must have a matching__api__()entry for its name ANDmeta.hintsmust 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 forEnum.group_by(& &1.name, & &1.hints)list-membership so multi-decl pairs (e.g. V2's twoapi(:new, ...)blocks for arities 9/12, V2's twoapi(: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 sincefind_arity_and_defaults/2recordsmax_arityfor every block — verified via Tidewave against the merged BEAMs); explicit{:error, reason}flunk branch onCode.fetch_docs/1mirroring 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.transactionsnow 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 as13f5e50ondevelopment).Cartouche.Block.deserialize/1dispatches per-element onparams["transactions"]: hash strings preserve the wireString.t()shape (withHex.decode_word!/1validation at the boundary so a malformed hash raises at the source rather than leaking downstream); full-detail JSON maps dispatch by"type"toV1/V2/V3/V4.from_json/1(legacy / EIP-1559 / EIP-4844 / EIP-7702)."0x1"(EIP-2930) raises a specificEIP-2930 ... not yet supportederror distinct from the genericunsupported envelope typeraised on truly-unknown bytes — operators can tell "known type, not yet ported" apart from "we have no idea what this is" without grepping. NewCartouche.Transaction.JsonFieldcross-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@docstrings — the helpers are callable from V1/V2/V3/V4.from_json/1, so real docs are more useful than@doc falsehere. Defensivenil → []is applied symmetrically across V2accessList/ V3blobVersionedHashes/ V4authorizationList— all three are wire-required butfrom_json/1is 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 ondevelopmentpost-merge: CodeRabbit P1 (hash-validation), Codex GitHub bot (type-1 envelope), Doctor 0%-docs CI failure onJsonField. Cursor's VM OOM'd on the PLT build per the no-cloud-dialyzer convention; localmix dialyzer.jsonpost-merge confirmed zero new warnings. Discovered follow-up: ROADMAP Task 99 tracks properCartouche.Transaction.V_2930(EIP-2930, type 0x1)from_json/1support — both bot-finding pinning tests (block_test.exs"EIP-2930 (type 0x1) raises…" and the matching raw-decode error-tuple test) flip fromassert_raise/assert {:error, :unknown_envelope_type}to positive shape assertions when that lands. Closes ROADMAP Task 66.
Fixed
Cartouche.Typed.encode_value_map/3now keeps the stricterbinary()private@specby forcing each encoded field branch throughIO.iodata_to_binary/1before appending into the EIP-712 encoded-data binary (ROADMAP Task 98 / harness runrun-1780622112906-4a1b918a). This closes the Phase 4 follow-up from Tasks 19+20: the earlierbitstring()spec matched Dialyzer's conservative success typing, but every actual branch is byte-aligned andhash_struct/3depends on binary concatenation. New regression assertions exercise primitive and custom-type paths throughhash_struct/3and pin 32-byte binary hashes..sobelow-skipswas regenerated for accepted baseline fingerprint drift; the stray duplicate.sobelow-skips.committedfile was removed in review. Closes ROADMAP Task 98.Cartouche.Transaction.V3.decode/1now enforces the EIP-4844VERSIONED_HASH_VERSION_KZG = 0x01leading byte on every entry ofblob_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 indefp decode_blob_versioned_hashes/1— theEnum.all?/2callback now requiresis_binary(hash) and byte_size(hash) == 32 and binary_part(hash, 0, 1) == <<0x01>>. Error contract{:error, "invalid v3 transaction"}unchanged. Regression test intest/cartouche/transaction/v3_test.exsmirrors the existing"rejects non-word blob versioned hashes"shape with a 32-byte hash whose leading byte is0x00.Cartouche.Transaction.V3coverage held at 98.73% (≥ 95% critical-tier gate); existing fixtures already use0x01-prefixed hashes (<<1, 0::248>>inrepresentative_tx/0,0x01908125...in the mainnet blob-tx fixture), so happy paths unchanged. Out of scope (Cursor honored): encoder side (V3.encode/1trusts struct fields by design),Cartouche.Transaction.V4blob versioned-hash audit, and the dispatcher inCartouche.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. Localmix dialyzer.json --quietpost-merge confirmed zero new warnings (Cursor skippedmix dialyzerper 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
reach1.8 → 2.2 and apply hygiene fixes flagged by 2.2's expanded smell surface acrosslib/cartouche/**(ROADMAP Task 59-lib / INE-47 / PR #59, delegated to Cursor; bot-finding follow-ups landed locally ondevelopmentpost-merge as5668c0e). Behavior-preserving cleanups in five modules —lib/cartouche/hex.exchecksum 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.exswapsEnum.count(args)→length(args)in the rescue-message string and splits twois_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.excreate_program_address/2collapses twoEnum.reducebinary 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.exserialize_message/1+serialize/1rewrite the binary-concatenation reductions toIO.iodata_to_binary/1over a list — but carefully preserve the original raise-on-malformed contract that the priorEnum.reduce(..., fn <<key::binary-32>>, acc -> acc <> key end)enforced via FunctionClauseError, by adding explicitEnum.each(account_keys, fn <<_::binary-32>> -> :ok end)andEnum.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.extwoEnum.count/1→length/1swaps (Context.show_stack/1,push_word/2) and adds:stack_overflowto the@type vm_errorunion (CodeRabbit minor outside-diff;push_word/2already returned{:error, :stack_overflow}at line 370 but the type didn't list it). New 122-LOC test pass intest/solana/transaction_test.exscovers 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-filedefps missing@specper the project'sinclude_defp: trueCredo config) + one Minor outside-diff (vm_errorunion 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-addedalias Cartouche.Solana.Transaction.{Header, Message}references intest/solana/transaction_test.exs's overlap region; the CI merge commit's regenerated long-form refs failedmix 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 toarg_name/2,build_struct_argument_spec/2,unused_name_value_pair/1inlib/mix/cartouche.gen.ex(CodeRabbit major);:stack_overflowadded tovm_error(CodeRabbit minor outside-diff). Localmix dialyzer.json --quietpost-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 ondevelopment)..sobelow-skipsregenerated post-merge viamix sobelow --mark-skip-allafter the three@specadds inlib/mix/cartouche.gen.exshifted line numbers (4 prior fingerprints rotated; 6 new fingerprints registered for the post-shift positions) — admin-merge with--adminwas load-bearing here since the sobelow-skips drift only resolves onmainpost-merge per the project's "agents should not touch .sobelow-skips" convention. Closes ROADMAP Task 59-lib (and effectively Task 59 — thelib/mix/cartouche.gen.exsub-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/2andadd_signature/3against 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/2now validateslength(seeds) == message.header.num_required_signaturesat function entry and raisesArgumentErrorwith 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/3now guards0 ≤ index < length(transaction.signatures)and raisesArgumentErroron out-of-bounds —List.replace_at/3silently 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 guardindex >= 0was moved into the body so negative indices raiseArgumentError(matching the AC) instead ofFunctionClauseError. 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.@docextended on both functions documenting the new error path;@specunchanged on both (raise contract, no return-type widening needed). Regression coverage intest/solana/transaction_test.exscovers undersupplied (1 vs 2), oversupplied (2 vs 1), and exact-match happy path forsign/2; off-by-one (index = length), negative (-1), far-OOB (99), and in-range regression foradd_signature/3. Coverage onCartouche.Solana.Transactionstayed 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). Localmix dialyzer.json --quietpost-merge confirmed zero new warnings onlib/cartouche/solana/transaction.ex. Closes ROADMAP Tasks 91 + 92. Resolve all 16 outstanding
mix dialyzer.jsonwarnings onlib/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/1at line 464) passes the full 9-keyctxbuilt inencode_function_call/5. Dialyzer flagged the closed map withinvalid_contract, then cascaded —build_selector_fn"will not succeed" →build_function_quotes/1no_return→ 7 siblingbuild_*_fn/1helpers (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) flaggedunused_funbecause they were unreachable from a no-return parent →select_emitted_fns/3similarly unreachable → 3pattern_matchwarnings onmerge_encode_call_result/2clauses 221/224/227 because dialyzer believedencode_function_call/5always crashed →abi_decode_return_spec([])flagged unreachable for the same chain-of-narrowing reason. Fix: widen all 8build_*_fn/1@specs to open-map syntax (:names => map(), …, optional(atom()) => any()), preserving the required-key documentation while admitting the actualctxshape. The eight@speclines are functionally identical to the prior closed form except for the trailingoptional(atom()) => any()— no runtime behavior change. The 12-warning cascade collapses to zero with this single fix; the standalonepattern_match_covonnormalize_return_types/1line 1068 (defensive bare-typeclause that dialyzer narrows to dead because the upstreamABI.FunctionSelectorparser never currently emits a bare type, even thoughABI.FunctionSelector.t().returnsatdeps/hieroglyph/lib/abi/function_selector.ex:51is officiallytype() | [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-skipsregenerated viamix sobelow --mark-skip-allto refresh fingerprints after line shifts. Local verification:mix dialyzer.json --quiet0 warnings (was 16, all onlib/mix/cartouche.gen.ex),mix compile --warnings-as-errorsclean,mix credo --strict0 issues on touched file (6 project-wide TagTODO entries unchanged — tracked-debt convention perfeedback_todo_credos_visible.md),mix test.json --quiet1027/1027 offline pass (29:integrationexcluded; the generator has no direct unit tests but its consumers exercise emitted output),mix sobelowclean against refreshed.sobelow-skips. Touched-file scope only —lib/mix/cartouche.gen.exand.sobelow-skips. Per CLAUDE.md "Never delegate dialyzer-scoped tasks to cloud agents", this stayed local from the start.Harden
Cartouche.Sleuthatom-table risks (ROADMAP Task 48 / INE-43 / PR #53, delegated to Cursor). Two-phase delivery — Phase A (b039c5a) raisedCartouche.Sleuthto ≥95% coverage percritical-rules.md"RAISE COVERAGE BEFORE MUTATING" (Sleuth is critical-tier as the public ABI dispatcher); Phase B (954e8ce) swapped the remaining runtime atom mints toString.to_existing_atom/1.query_by/3'sencode_<fun>and<fun>_selectorderivations now route throughexisting_function_atom/2, raising aRuntimeErrormatching the existingtry_apply/3shape on cold function names ("Sleuth module does not define required...").try_decode/4gained anamed_returnsboolean parameter and anArgumentErrorrescue that surfaces the INE-17-shape{:error, "error decoding: ..."}envelope (preserves the convention established by the priordecode_structs: trueaudit rather than minting a new tuple variant); a newpreintern_named_return_atoms/1helper walks only top-level returns (narrower than the existingpreintern_decode_struct_atoms/1which recurses into nested ABI tuples).preintern_name_atom/1uses anexisting_atom/1wrapper that returns{:ok, atom} | :errorand raisesArgumentErroron cold names;name_keyword/1's rescue is dead-code defense-in-depth covered by the upstream preintern step. Merge-resolution commit (3c8ee2a) reconciled withdevelopmentafter PR #54 / INE-42 landed..sobelow-skipsregenerated — all threelib/cartouche/sleuth.exfingerprints (13012D4,536511,4A9C581) dropped out, leaving only generator entries forlib/mix/cartouche.gen.ex(Task 41/42/50/59-gen territory) plusconfig/prod.exs. Localmix dialyzer.json --quietpost-merge confirmed zero warnings onlib/cartouche/sleuth.ex(Cursor's cloud VM OOM'd on the full-deps PLT build per the no-cloud-dialyzer convention; verification ran ondevelopment); all 16 remaining warnings sit inlib/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@specnarrowed fromterm()to{:ok, binary()} | {:error, term()}, mirroringCartouche.RPC.execute_trx/3's contract — independently valuable sinceexec_trx/3is 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.InvalidVmexercised the previously-untyped paths well enough that dialyzer no longer narrows the four public heads' success typing tonone()); PR #54 was a Cursor cloud-agent delegation that surfaced this finding by attempting@dialyzer {:no_contracts, …}suppressions onCartouche.VM.exec/3,exec_call/3,Cartouche.Erc20.exec_trx/3,transfer/4plus 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-runningmix dialyzer.json --quietagainst 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 ondevelopment(25 lines deleted acrosslib/cartouche/vm.ex+lib/cartouche/erc_20.ex). Closes ROADMAP Phase 5 / Tasks 21+22.Backfill
@specon everydefpacrosslib/cartouche/**/*.exandtest/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 (defANDdefp) now carries an@spec..credo.exs:filesscope on the new check islib/cartouche/excluding the auto-generatedlib/cartouche/contract/(where Task 50's regen pass already emits@spec ... :: term()). Placeholderterm()shapes used on hot-path data plumbing where domain types are unclear, with aTODO: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_appsfor 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 newclient/1specs on bothCartouche.Signer.CloudKMSandCartouche.Solana.Signer.CloudKMSreferencedTesla.Env.client/0, but Tesla is in:plt_ignore_apps, so dialyzer reportsunknown_type; replaced withterm()plus a TODO documenting the PLT trim constraint. CodeRabbit's three findings landed in revision before merge:Cartouche.Recover.decode_signature/1spec realignedCurvy.Signature.t() | {:error, term()}→Curvy.Signature.t() | :invalid_hex(matchesHex.decode_hex/1bare-atom error propagation throughwith);test/support/solana_client.extoken_account_fixture/4amountparamnon_neg_integer()→String.t()(callers pass"1500000000"-shaped strings; impl callsString.to_integer(amount));Cartouche.Solana.Transaction.CompiledInstructionintroduced anaccount_index :: 0..255type and applied it to bothprogram_id_indexandaccountslist elements (matches 1-byte serialization via<<ix.program_id_index>>and:binary.list_to_bin/1). 16 pre-existing dialyzer warnings onlib/mix/cartouche.gen.exdocumented 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.mdrule 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-formattedclean,mix credo --strict7-issue baseline (TagTODO only),mix test.json --quiet --exclude integration1013/1013 offline pass (29:integrationexcluded),mix doctor100%/77 modules,mix sobelowclean against.sobelow-skips,mix dialyzer.jsonzero PR-introduced warnings. Closes ROADMAP Task 75.Cartouche.Transaction.V2.encode/1clauses now share unsigned RLP list assembly via a privateunsigned_rlp_list/1helper; the unsigned and signed paths previously held 10 lines of identical struct destructuring (Type I clone surfaced bymix ex_dna).t()widenssignature_y_parity/signature_r/signature_sto allownilso 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/1widened 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 toadd_signature/2-style flows. Focused ExUnit assertions added in a newV2.encode/1 access_list shapesdescribe block intest/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/1previously crashed on bare-address entries in the signed path). Local verification: 1013 tests pass (29 integration excluded),mix dialyzer.jsonclean onlib/cartouche/transaction.ex,mix doctor --raise100% coverage,mix sobelow --configclean with no.sobelow-skipsdrift,mix credo --strict0 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@specrewritten frombinary()tobitstring()to match dialyzer's success typing for thefor {field, type} <- fields, into: <<>>comprehension (Task 19+20 / Phase 4 / INE-39 / PR #50, delegated to Cursor).find_type/2left untouched after verification — its committed spec was already{String.t(), Type.t()}matching the impl, so the Linear issue body's claim ofTyped.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 staydefp; no@doc falsediscussion needed since they're already private. Spec-only — no runtime code path changes. Dialyzer drops thetyped.ex:578 invalid_contractwarning. CI's full harness ran dialyzer successfully on the merged PR (Cursor's narrow Dialyzer run onCartouche.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 remainbinary()to preserve the byte-alignment invarianthash_struct/3's<>operator requires (type_hash <> encode_datawould raise at runtime on a non-byte-aligned bitstring) was reviewed and accepted as a defensible internal-defpchoice for now — every actual branch inType.encode_data_value/2returns 32-byte aligned binary, so the impl IS byte-aligned in practice. The stricter durable fix (tighten the impl so dialyzer can provebinary(), restore the original spec) filed as Task 98 follow-up. Closes ROADMAP Tasks 19+20 / Phase 4.Mix.Tasks.Cartouche.Gennow emits@docand@specon every publicdefit generates (Task 50 / INE-37 / PR #51, delegated to Cursor). The generator attaches an ABI-derived doc string and a spec to eachencode_*,decode_*,*_selector,call_*,estimate_gas_*,call_log_*,exec_vm_*, fallback/receive variant, and pre-intern helper. Doc strings derive fromFunctionSelector-formatted signatures (literalbytesfor 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/1is typedbinary() :: <decoded inputs>(calldata in, decoded inputs out — not output types as an earlier draft had it), andexec_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 flatt()). Tuple-return ABI types render as Elixir tuple AST ({non_neg_integer(), boolean(), <<_::160>>}) rather than a list..doctor.exsignore_paths: [~r"^lib/cartouche/contract/"]removed;mix doctorclean across the regeneratedCartouche.Contract.IConsoleandCartouche.Contract.Sleuth. New generator regression tests intest/mix/cartouche_gen_test.exswalk every publicdefthe generator emits and bind@doc NAME→@spec NAME→def NAMEvia a same-name regex (annotation_stanza/2), so future drift fails per-function rather than passing on a loose pattern match..sobelow-skipsregenerated for line shifts inlib/mix/cartouche.gen.ex. Three bot-ensemble revision rounds resolved before merge: Codex GitHub bot's P1 (tuple typespec AST) and P2 (decode_*_call/1input types), Copilot's three line-level findings (fallback/receive doc usingFunctionSelectorfor synthesized signatures,exec_vm_*spec inconsistency vs unwrapped return, regex weakness in test 218), and CodeRabbit's two findings (same regex weakness +@specon privatebuild_preintern_*builders); one CodeRabbit.sobelow-skipsdrift 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 Descripexregistered 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/base58namespace forCartouche.Base58(shared between Ethereum-adjacent and Solana code paths, so neither/ethereum/nor/solana/claims it).api()blocks placed above each existing@docso prose survives in slot 4 and hints land in slot 5. The 11 previously-undoccedCartouche.Hexdefs and the 1 previously-undoccedCartouche.Sleuthdef each get minimal@docprose so doctor's documentation gate passes (closing the gap as a side effect of the annotation pass).Cartouche.Hexencode/decode pairs explicitly document the inverse relationship inreturns:and pin input shape (0x-prefixed hex string vs raw binary) on every function — the highest-traffic agent-misuse vector for the module.Cartouche.Weiunit 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 onCartouche.Erc20.CallDataandCartouche.Erc20.Call(both receiveduse Descripexannotations during implementation but neither was registered in@descripex_modules, soCartouche.describe()would never have surfaced them) was resolved by registering both submodules with:erc20_call_data/:erc20_callaliases following the existing:solana_*/:transaction_v*pattern — extends the spec slightly (9 → 11 modules) but keeps the existing annotation work intact rather than strippinguse Descripexfrom the submodules. Three revision rounds resolved before merge: round 1 addressed three metadata correctness items from the bot ensemble —Address.from_public_key/1description 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 misplacedapi(:decode_hex_number, ...)block abovedecode_hex_number!/1renamed toapi(:decode_hex_number!, ...)with integer/raise semantics (flagged by Codex P2 + Copilot duplication warning), andCartouche.Erc20'sexec_trx/call_trx:errorsmetadata reworded to say "defaults when absent" matching the actualKeyword.put_new/3semantics rather than "merges into caller options" (flagged by CodeRabbit Minor; defensible interpretation choice — realigned wording rather than mutating runtime semantics, since:errorsdefaults are an explicit metadata concern, not a runtime composition concern); round 2 rebased the PR ontoorigin/developmentafter PR #46 (INE-36) merged, resolving conflicts inlib/cartouche.ex(preserved PR #46's custom Descripex alias/discovery layer alongside the new utility modules) and regenerating.sobelow-skipsagainst the rebased source; round 3 resolved two remaining items —address.ex:23's@docbody still drifted from the correctedapi()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 readingCartouche.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-skipsregenerated for line shifts triggered byCartouche.Sleuthmetadata insertion (Sleuth is on the project's accepted-pending-fixString.to_atomlist 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.Gennow derives separatehas_bytecode(init) andhas_deployed_bytecodeflags from the artifact's bytecode declarations and routes generator emission accordingly (Task 41 / INE-36 / PR #46, delegated to Cursor). Constructorabort?/3continues to gate on inithas_bytecode; the:pureclause ofselect_emitted_fns/3now matches a literaltrueon the deployed flag, soexec_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 emittedexec_vm_*referencing an undefineddeployed_bytecode/0and produced aCompileErrorat consumer load; the second skippedexec_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. Newdescribe "bytecode shape coverage"block intest/mix/cartouche_gen_test.exscovers both shapes with both string-match assertions (InitOnly:refute "deployed_bytecode()"catches the originalCompileErrorclass) and runtimefunction_exported?/3checks against the compiled module.lib/cartouche/contract/{i_console,sleuth}.exregenerated zero-diff because both Hardhat artifacts ship paired bytecodes..sobelow-skipsregenerated for the line shifts inlib/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@specnarrowed from the abstractt()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/0deliberately stays broad — it describes mutated runtime contexts afterpcadvances / stack pushes / etc., consumed by callers operating on post-init state. Spec-only; runtime behavior unchanged. Dialyzer dropslib/cartouche/vm.ex:104 invalid_contract. Closes ROADMAP Task 23 / INE-29 and Phase 6.Cartouche.Solana.RPCnow has permanent regression coverage for the previously-untested option/filter/encoding paths surfaced during INE-25 / PR #37 review. Newtest/solana/rpc_test.exsassertions ground the:encodingopt onget_account_info/2,get_multiple_accounts/2, andget_transaction/2against recorded JSON-RPC params (covering:base58,:base64,:"base64+zstd",:json_parsed, plus binary-string passthrough); theget_token_accounts_by_owner/3ArgumentErrorraise when neither:mintnor:program_idfilter is provided; and thesend_transaction/2:encoding/:skip_preflight/:preflight_commitment/:max_retriesopts. ARecordingClienttest double captures outgoing JSON-RPC method + params viasend/2to the test process so each path fails if the wire shape regresses.Cartouche.Solana.RPCcoverage clears the standard ≥80% gate. Test-only — nolib/cartouche/solana/rpc.exmutation. 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/1dispatcher routes by EIP-2718 envelope byte:0x02→V2.decode/1(EIP-1559),0x03→V3.decode/1(EIP-4844 blob),0x04→V4.decode/1(EIP-7702 set-code). Legacy V1 falls through toV1.decode/1when the leading byte is>= 0x80(RLP list prefix); empty input returns{:error, :empty_transaction}; reserved type bytes (0x01, plus the< 0x80envelope-byte range) return{:error, :unknown_envelope_type}. Each typed envelope'sdecode/1is a strict round-trip with the correspondingencode/1— RLP shape, address byte_size, signaturer/sbounds, access lists, blob versioned hashes, authorization lists are all validated at parse time. Out of scope per the original issue body: EIP-4844 versioned-hash0x01version-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 newdecode/1paths sits at the critical-tier (≥95%); the dispatcher and eachVn.decode/1are exercised against round-trips from theirencode/1plus 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. Newdescribe "trace methods"block intest/rpc_integration_test.exsexercises every clause ofCartouche.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 — pinstype == "create", 20-byteresult_address, non-emptyresult_code), and the"refundAddress"clause via a fresh pre-Cancun SELFDESTRUCT anchor (block 11,500,000, CHI gas-token free-up — pins suicide-actionrefund_addressshape). A type-4 EIP-7702 anchor (block 23,600,000, post-Pectra delegation tx) confirms the wire-formattype/callTypestrings remain CALL-family (no new opcode mnemonics) — closing the loop on Task 68's premise correction.trace_callanddebug_traceCallreuse the WETH9totalSupply()invariant at block 18,000,000 to pin output bytes against@weth9_total_supply;trace_callManyadds 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_hashround-trip, top-level type, deterministic outputs); internalgas_used/subtraces/struct_logsare shape-only since node implementations differ on those. Closes ROADMAP Task 62. - Bootstrap descripex adoption (Phase 12 / ROADMAP Task 82).
:descripexis now a direct dep instead of transitive-via-:hieroglyph, so consumermix.exsfiles don't need to add it to use the discovery API. The top-levelCartouchemodule exposesdescribe/0,1,2and__descripex_modules__/0viause Descripex.Discoverablewith an initially empty registered-module list. A new validation test intest/descripex_validation_test.exswalks the registered list and flunks-with-the-offending-function-name when any non-@doc falsepublic function is missingmeta[: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 descripexapi()metadata (Phase 12 / ROADMAP Task 84 / INE-31 / PR #44).use Descripexregistered 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 toCartouche.@descripex_modules, soCartouche.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-emptyparams,returns,description. Eachapi()block sits above the existing@docso 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_dataparams (e.g.nonce,gas) carrysource: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/1now callsnormalize_block_param/1on:newest_blockbefore forwarding toeth_feeHistory(it previously sent the raw value unchanged, which would have produced-32602 Invalid paramsfrom real Ethereum nodes whenever an agent followed the metadata's documented integer support); regression coverage added intest/rpc_test.exsassertingeth_feeHistorysends"0x37"fornewest_block: 55. Tasks 85-88 remain. - Annotate
Cartouche.Transaction+ nested V1 + V2 modules with descripexapi()metadata (Phase 12 / ROADMAP Task 85 / INE-32 / PR #42).use Descripexregistered 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 toCartouche.@descripex_modules, soCartouche.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-emptyparams/returns/description. V2'snew/9andnew/12arities each get their ownapi()block (different param surfaces — without/with explicit signature triplet); both packedadd_signature/2and expandedadd_signature/4forms get separate annotations. Top-levelCartouche.Transactionadvertisesdecode/1(the EIP-2718 envelope dispatcher from Task 33) plus a synthetictransaction_dispatch_detail/2entry guiding consumers towardV1.encode/1/V2.encode/1for concrete encoding. Eachapi()block sits above the existing@docso prose remains in slot 4 while hints land in slot 5. Three review rounds resolved before merge: round 1 restored twoTODO:markers Cursor stripped (block.ex:294,solana/transaction.ex:399-400) and splitV2.new/12annotation fromV2.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 — phantomCartouche.Transaction.encode/1in the synthetic dispatcher entry (Codex second-opinion), top-leveldecode/1return-shape advertised as V1-only when the dispatcher actually returns 7 outcomes (CodeRabbit Major + Codex), unannotatedCartouche.describe/0,1,2+__descripex_modules__/0discovery helpers (CodeRabbit Major), V1valueannotation 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 Descripexregistered with namespaces under/solana/across all 9 modules —signer,transaction,keys,pda,ata,programs,system_program,token_program,token— andapi()blocks placed above each existing@docso prose survives in slot 4 and hints land in slot 5; all 9 module entries added toCartouche.@descripex_modules. An explicit@descripex_aliasesmap registers:solana_signer/:solana_transaction/:solana_keys/:solana_pda/:solana_ata/:solana_programs/:solana_system_program/:solana_token_program/:solana_tokenso Solana short names resolve without colliding with the Ethereum surface (:signer/:keys/:transactioncontinue 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 intest/descripex_validation_test.exsasserts the description matches verbatim.Solana.Signer's 4 previously-undocced defs covered:child_spec/start_linkgetapi()-generated@doc(closing the doctor gap as a side effect); GenServerinit/handle_callget@doc falseper the internal-surface convention.lib/cartouche.exitself now declaresuse Descripex, namespace: "/cartouche"and registersapi()blocks for the public discovery helpersdescribe/0,1,2(closing the CodeRabbit-flagged self-discoverability gap from Task 85's review);__descripex_modules__/0gets@doc falseper the__foo__/Nreserved-for-metadata convention. Thetransaction_dispatch_detail/2private helper drops the explicit:transactionshort-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:contractsconfig test flipped toasync: falseandon_exitrestores the prior value rather than deleting the whole key;wrapped_sol_mintreturn type corrected from:solana_program_idto:solana_mintsince it returns a mint address, not a program id); round 2 resolved alib/cartouche.exmerge conflict against newerdevelopment(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:hintsshape assertions anddescribe/*happy-path doctests; the existing ExUnit assertions cover the contract that matters. Codex GitHub bot's P1 (aliasas loop var) was confirmed as a false positive —aliasis a special form/macro, not a reserved keyword, and is valid as a variable name; CI compile + run proves it. Two pre-existingSolana.Transactioncorrectness gaps surfaced by the bot ensemble —sign/2doesn't validatelength(seeds) == message.header.num_required_signatures(can produce signature-count-mismatched transactions Solana RPC rejects);add_signature/3'sList.replace_at/3silently 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.RPCwith descripexapi()metadata (Phase 12 / ROADMAP Task 86 / INE-33 / PR #43).use Descripex, namespace: "/solana/rpc"registered; 19api()blocks placed above each existing@docso prose survives in slot 4 and hints land in slot 5;Cartouche.Solana.RPCadded toCartouche.@descripex_modules.Cartouche.describe(:solana_rpc)returns Level-2 listings;.describe(:solana_rpc, :get_balance)(etc.) returns Level-3 detail with non-emptyparams/returns/description. Pubkey descriptions standardized on raw 32-byte binary form (<<_::256>>, Base58-encoded internally byencode_pubkey/1) — agents shouldn't pre-Base58 inputs. Three review rounds resolved before merge: round 1 correctedsend_rpc/3return metadata (type: :rpc_error→type: :term) and aligned pubkey descriptions acrossget_balance,get_account_info,get_multiple_accounts,get_token_account_balance,get_token_accounts_by_owner,get_recent_prioritization_fees,request_airdropwith the actual binary@spec; round 2 fixedreplace_recent_blockhash(was annotated:exchange_datawith asource:pointer — it's a boolean flag) andget_healthreturn shape (:ok_error_tuplewas misleading — actual shape is:ok | {:error, term()}per Task 64); round 3 standardized the four remaining:exchange_datasites (get_transaction.signature,send_transaction.transaction,simulate_transaction.transaction,send_and_confirm.transaction) tokind: :valuematching theget_signature_statusesprecedent — caller-supplied wire-encoded payloads are values regardless of whether the agent obtained them via prior fetches, andget_signature_statusesalready established the convention for the same shape. Post-merge cleanup:get_token_accounts_by_owner/3@docrealigned with theapi/2block + implementation (impl usescondchecking:mintfirst, then:program_id, raises only when neither is provided — so both filters are allowed and:minttakes precedence; the prior@docclaimed "exactly one filter" which contradicted both surfaces). Metadata-only — no runtime code path changes. Tasks 87-88 remain.- Annotate
Cartouche.Signer+Cartouche.Keyswith descripexapi()metadata (Phase 12 / ROADMAP Task 83 / INE-28 / PR #39).Cartouche.Signerdeclaresuse Descripex, namespace: "/ethereum/signer"withapi()blocks forchild_spec/1,start_link/1,sign/3,address/1,chain_id/1, andsign_direct/4;Cartouche.Keysdeclaresnamespace: "/ethereum/keys"with anapi()block forgenerate_keypair/0. Both modules now appear inCartouche.__descripex_modules__/0, soCartouche.describe(:signer)/Cartouche.describe(:keys)return Level-2 listings andCartouche.describe(:signer, :sign_direct)(etc.) returns Level-3 detail with non-emptyparams,returns,description. Eachapi()block sits above the existing@docso the human-readable prose remains in slot 4 while hints land in slot 5. Metadata-only — no runtime code path changes; param kinds use:valuefor caller-supplied inputs (chain id, signer name, MFA, message bytes) per the Task 83 instruction set.sign_direct/4'schain_id_or_namedescription is "Ethereum chain id integer or configured chain atom" — explicitly excludes string inputs thatCartouche.Chain.parse_id/1would reject. Tasks 84-88 remain.
Bumped
mix hex.outdatedrefresh (the 0.2.1 publish set):decimal3.1.0 → 3.1.1 (patch, inside the existing~> 3.1pin — 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. Onlydecimalanddescripexare runtime deps that reach the published package;ex_unit_jsonandmeckareonly: [:dev, :test]and never appear in the hex package's requirements.ex_unit_json0.5 enables default flaky-retry healing — a bare run with failures re-runs only the failed tests once, healed flakes move to a top-levelflaky[]array and stop blocking (exit 0 when every first-run failure heals); disable per-project withconfig :ex_unit_json, retry: false.descripex0.7 compiles clean againstCartouche.Manifest'sDescripex.Manifest.build/1wrapper and theapi()/describe/0,1,2surface (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-clausewhen … , do:guards intransaction.ex/transaction/v3.ex/transaction/v4.ex, and adynamic/1identity helper was added inwei_test.exsto erase the static type on the intentional out-of-contractWei.to_wei({1.5, :eth})input (the literal otherwise trips a compile-timeincompatible typeswarning; the contract is enforced at runtime viaFunctionClauseError). Local verification:mix compileclean,mix test.json --quiet1085/1085 offline pass (31:integrationexcluded; integration coverage not verified this run),mix hex.outdatedclean (all rows up-to-date).decimal~> 2.4.0→~> 3.1anddoctor~> 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/emincapped at ±6_144, andDecimal.parse/1/cast/1reject inputs > 34 digits or > 6_144 absolute exponent (DoS-bounded — CVE-2026-32686 mitigations now standard). 3.1 (2026-05-08) addedDecimal.new/2keyword opts forwarded toparse/2and fixedDecimal.to_integer/1infinite 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 throughto_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-charto_stringcap is irrelevant for EVM amounts.Inspect,String.Chars, andJSON.Encoderprotocols passmax_digits: :infinityso debug/serialization paths still succeed on any width.doctor0.23.0 requireddecimal ~> 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:mintadded tomix.exs:plt_add_appssomix dialyzerresolvesMint.Types.status/0/Mint.Types.headers/0referenced fromdeps/finch/lib/finch/response.ex(3 pre-existingunknown_typewarnings cleared — surfaced by the priorfinch0.21 → 0.22 bump in02334afwhere mint's transitive type aliases joined finch's public type, andplt_add_deps: :apps_directdoesn't pull transitives). Local verification:mix compile --warnings-as-errorsclean (Tesla 1.17.0 deprecation warning fromtesla/builder.exis pre-existing dep noise — not new from this bump),mix test.json --quiet1085/1085 offline pass (31:integrationexcluded; integration coverage not verified this run),mix format --check-formattedclean,mix credo --strict5 design suggestions (all pre-existing TODO tags — tracked-debt convention perfeedback_todo_credos_visible.md, unchanged from baseline),mix sobelow --exit Lowclean,mix doctor --summary --failed100% (79 modules, 0 failures — doctor 0.23 ran identically to 0.22 against cartouche's surface),mix dialyzer.json --quiet0/0 warnings (was 3 ondevelopment; cleared by the:mintPLT 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.mdand matches the include's stated minimum),ex_ast~> 0.11.0→~> 0.12(forced by reach 2.7'sex_ast ~> 0.12.0requirement; also brings cartouche in line with the global include recommendation),finch~> 0.21.0→~> 0.22(0.21.0 → 0.22.0);mix deps.updatealso picked upbandit1.11.0 → 1.11.1 andex_doc0.40.1 → 0.40.3 patch bumps that were already inside the existing~>pins. Project-levelmix.exsline 28 of~/.claude/includes/development-commands.mdupdated to match (ex_ast min version~> 0.11→~> 0.12).doctordeliberately held at~> 0.22.0— 0.23 requiresdecimal ~> 3.1which is blocked by cartouche's intentionaldecimal ~> 2.4.0pin (token-amount math semantics — re-evaluate together when adecimal3.x audit task lands).decimalitself moved 2.4.0 → 2.4.1 (patch, inside the existing pin — no semver concern). Local verification:mix compile --warnings-as-errorsclean (Tesla 1.17.0 deprecation warnings fromtesla/builder.exare pre-existing dep noise, not cartouche-side),mix test.json --quiet1085/1085 offline pass (31:integrationexcluded; integration coverage not verified this run),mix doctor --raise100% / 79 modules,mix hex.outdatedfour blocking rows cleared (decimal+doctorremain "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 onlymix reach.*references in the repo are historical narrative inROADMAP.md/ROADMAP.old.md/cleanup.mddated 2026-04-21 at reach 1.6.0; harness CI does not invoke reach). finchmix.exspin 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/5halt + 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/1pattern-matching%Finch.Response{}and%Finch.Error{reason:}) across 4 callsites inapplication.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 intest/http_test.exscontinue to cover theFinch.Error/Finch.Responseshapes (unchanged across the upgrade range). Closes ROADMAP Tasks 27 + 28.
[0.2.0] — 2026-05-05
Added
Cartouche.Transaction.V4adds 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-77020x05 || 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.V3now supports EIP-4844 blob transaction envelopes introduced with Dencun. The new typed transaction module encodes, decodes, signs, hashes, and recovers signers for canonical type-0x03execution-layer payloads, includingmax_fee_per_blob_gasandblob_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/1now replaces local 4-byte selector hashing inlib/mix/cartouche.gen.ex:148andlib/mix/cartouche.gen.ex:412, while preserving the full 32-byte event-topic hash separately;ABI.decode_error/2now drives RPC revert decoding atlib/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/2had no current adoption opportunity after searchinglib/**for packed-encoding and keccak patterns.
Fixed
Cartouche.Typedreturn-shape coverage now pins the current private-helper behavior that unblocks the downstream Typed spec rewrite: primitive and custom-typeencode_value_map/3paths are exercised throughhash_struct/3as 32-byte EIP-712 hashes,find_type/2is 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-returningencode_value_map/3branch; the map-shaped JSON path is pinned separately throughserialize/1so 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: trueaudit re-verified the generator emission shape after PR #24's false-positive closeout:lib/mix/cartouche.gen.exemits return-field names only as strings in selector metadata (build_selector_fn/1returnsMacro.escape(selector)), not as compile-time atom literals. Both auditeddecode_structs: truepaths therefore needed explicit handling before Hieroglyph 1.4'sString.to_existing_atom/1decode branch: generatedexec_vm_*functions now call a generated private helper beforeABI.decode/3, whileCartouche.Sleuth.query_v2/4validates 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 fixtureexec_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/2happy and edge setup paths plus the context display helpers,Cartouche.Erc20.Callbalance/transfer public entry points, and theCartouche.VM.InvalidVmraise/rescue contract. This unblocks the downstream Phase 5none()cascade investigation and Phase 6 VM context type-alignment work without changing runtime behavior. Closes ROADMAP Task 46 / INE-22. Cartouche.RPC.send_rpc/3andCartouche.Solana.RPC.send_rpc/3now return{:error, {:invalid_params, reason}}when outbound JSON-RPC request encoding fails, instead of lettingJason.encode!/1exceptions escape the documented return contract. Both transports catchJason.EncodeErrorandProtocol.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.Gencoverage 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 mutatinglib/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.Filterlog decoding asserts indexed reference-type event params surface{:indexed_hash, topic}; generated contract call decoding preserves embedded NUL bytes in:stringvalues; 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.Callstruct (destination: <<_::160>>, data: binary()) replaces the%V2{}masquerade foreth_callshapes. Generator-emittedCartouche.Contract.Sleuth.build_trx_query/3now returns%Call{}instead of a partial%V2{};Cartouche.RPCdispatch extended to acceptV1.t() | V2.t() | Call.t(). Eth_call params are not transactions — never signed, never broadcast — and the prior abstraction lie drove theinvalid_contractcascade throughCartouche.Sleuth. Structural fix collapses the cascade without widening V2 to nullable. Closes ROADMAP Task 54 / INE-10; Task 49 (V2.encode/1spec duplication) closes as superseded since the cascade is gone.Cartouche.Trace.@type t(and nestedCartouche.Trace.Action) widened to admitnilon fields the runtime proves are optional, matching dialyzer's inferred shape fromdeserialize/1.Cartouche.TraceCall.@type twidened analogously. Action serialization now nil-safe. New ExUnit assertions intest/trace_test.exsandtest/trace_call_test.exsground the widened types againstdeserialize/1JSON both with and without optional fields, perfeedback_doctests_not_substitute_for_tests.md. Dialyzer dropstrace.ex:408 invalid_contractandtrace_call.ex:124 invalid_contract. Closes ROADMAP Phase 3 (Tasks 16+17+18) / INE-14.Cartouche.Filterexpired-filter recovery test (test/filter_test.exs) now assertsProcess.get(:expired_seen) == trueandProcess.get(:new_filter_count) >= 2after 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.RecoveryBitdoctests fornormalize/2,normalize_signature/2, andrecover_base/1rewritten to chain-agnostic examples — they no longer bake the test-configchain_id=:goerliinto expected output. EIP-155 chain-id-dependent behaviour (the:eip155-branchnormalize/2arithmetic and therecover_base(47)raise message) moved to focused ExUnit assertions intest/recovery_bit_test.exsperfeedback_doctests_not_substitute_for_tests.md. Closes ROADMAP Task 39 / INE-7.Cartouche.Wei.to_wei/1now supports:ethinputs for whole integers and exactDecimalvalues, with:ethdocumented as the sole ETH-denomination atom (:etheris intentionally unsupported for short-form parity with:wei/:gwei).Decimalis 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.Receiptnow preserves EIP-4844 receipt blob fee fields. Type-3 blob receipts decodeblobGasUsedandblobGasPriceinto newblob_gas_used/blob_gas_pricestruct fields, while pre-Cancun and non-blob receipts keep both fields asnil. 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/1template — collapsed deadif true do :not_found else {:ok, "Impossible", <<>>} endbranch todef 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) atlib/cartouche/contract/i_console.ex:18704andlib/cartouche/contract/sleuth.ex:107— never surfaced in CI before because the harness OOM'd during PLT construction before dialyzer reached analysis. Regeneratedlib/cartouche/contract/{i_console,sleuth}.exviamix cartouche.genagainstsol/out/IConsole.sol/IConsole.jsonandpriv/Sleuth.jsonrespectively. 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). Productionlib/cartouche/contract/{i_console,sleuth}.exwere on the pre-Task-73 generator output until this commit. Permissiveterm()placeholders satisfy Doctor's presence-based gate; ABI type → Elixir type derivation remains pending under Task 50, along with.doctor.exsignore_paths: [~r"^lib/cartouche/contract/"]removal. - KMS signer Goth credential-path coverage now exercises
Goth.fetch!/1end-to-end for both Ethereum secp256k1 and Solana Ed25519 CloudKMS signers. The tests use:meckto 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.exsdialyzer: [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 GBubuntu-latestrunner (CI runs 25306079957, 25313348846, 25313517870 — allrunner shutdown signalexit 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_appstruly subtracts from the deps PLT app list (verified viadeps/dialyxir/lib/dialyxir/project.ex:50-75—Kernel.--(plt_ignore_apps())), not just from warning output. Trade-off: dialyzer no longer type-checksCartouche.Signer.CloudKMS/Cartouche.Solana.Signer.CloudKMSinteractions withGoogleApi.CloudKMS.V1.*/Goth.Token— acceptable since CloudKMS is an optional signer with narrow surface (4 distinct external functions × 2 callsites = 8unknown_functionwarnings),Code.ensure_loaded?/1guards 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.exswith two file-scoped entries ({"lib/cartouche/signer/cloud_kms.ex", :unknown_function}+ the Solana counterpart) suppressing the 8 GCP/Gothunknown_functionwarnings the PLT trim creates. Tighter than per-line (won't break on file edits), narrower than full-file (onlyunknown_functionis 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.ymlswap step added at commit8e44328(16 GB swap viafallocate -l 16G /swapfile).ubuntu-latestimages already ship with/swapfileactive —fallocateagainst a live swap file returnsETXTBSY(fallocate: fallocate failed: Text file busy, exit 1, run 25314151347), failing the harness before dialyzer ever ran. With the:plt_ignore_appstrim 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_appstrim above, cartouche's deps PLT still spans 621 modules and OOM'd the 16 GBubuntu-latestrunner mid-construction (run 25319850405 —runner has received a shutdown signalat 12:52:25 while adding 621 modules todialyxir_erlang-27.3.4.11_elixir-1.18.4_deps-test.plt). The remaining cluster (hieroglyph,descripex,finch/mint/hpax/inetsHTTP stack,ssl/public_key/asn1/crypto/xmerlOTP crypto+XML) doesn't have an obvious next "trim this and the rest fits in 16 GB" target —hieroglyphanddescripexare 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 (warmpriv/plts/cache, ~30s incremental run). PR CI now runs format / compile (warnings-as-errors) / credo / doctor / sobelow / test+coverage; dialyzer signal stays available locally viamix dialyzer.json. Upstream confirms the carve-out shape:googleapis/elixir-google-api(the monorepogoogle_api_cloud_kmslives in) runs zero dialyzer in its own CI —.github/workflows/presubmit.ymlonly invokesmix do deps.get, testper changed client (the Mixfile generated from the protobuf templates ships nodialyzer:block, no:plt_optionalflag, 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_appsIS 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_appsfor heavy generated-API deps before dropping. Theelixir-ci-harnessmarketplace 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-skipsregenerated viamix sobelow --mark-skip-allto track line-number drift from thedecode_error/1template collapse (7 lines → 1 line inlib/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.Gennow 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). Permissiveterm()placeholders satisfy Doctor's spec-coverage gate (presence-based, per memoryfeedback_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@specper arity is a compile error). Test fixturestest/support/cartouche/contract/{block_number,ierc20,rock}.exregenerated; hand-convertedtest/support/{live,client,signer,solana_client,solana_signer,sleuth_handler,vm_test_helpers}.exto the same shape. Known regen gap: the generator does not yet preserve the# credo:disable-for-this-file Credo.Check.Readability.MaxLineLengthpragma thatierc20.exneeds 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 underlib/cartouche/contract/{i_console,sleuth}.exare intentionally NOT regenerated in this commit — they remain on the old (no@doc/@spec) generator output and the.doctor.exsignore_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-skipsre-numbered to track theannotate_internal_defs/1insertion + itsTODO(Task 50):comment block (File.read!/1868 → 934,File.mkdir_p!/1906 → 972,File.write!/3907 → 973). Same three rules and same code paths — pure line-number drift from the new generator helpers above the affected sites. Verified viamix sobelow(clean output). lib/cartouche/transaction.exline 740:whenguard collapsed to a single line (mix formatoutcome, no semantic change).- ExUnit coverage now ignores the generated
Cartouche.Contract.IConsolebinding viatest_coverage: [ignore_modules: [Cartouche.Contract.IConsole]]inmix.exs, so headlinemix test.json --coverpercentages track hand-written code instead of generated bytecode wrappers. - GitHub Actions harness gate at
.github/workflows/harness.ymlrunsmix compile --warnings-as-errors,mix format --check-formatted,mix credo --strict,mix doctor --failed,mix sobelow, andmix test.json --cover --cover-threshold 80 --summary-only --exclude integrationon every PR targetingdevelopmentand on every push todevelopment. Closes the Codex-sandbox-blocked-hex.pm evidence gap (see project memoryfeedback_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@v4keys onmix.lockhash and covers_build. Integration tests (mainnet archive node) are explicitly excluded from the gate via--exclude integration. Permissions scoped tocontents: read;concurrencycancels in-progress runs on the same ref. (Dialyzer is not part of the harness — see### Toolingabove for the OOM rationale.)
[0.1.3] — 2026-05-02
Bumped
google_api_cloud_kms0.38.1 → 0.43.0 (loosenedmix.exspin~> 0.38.1→~> 0.43). Breaking change at 0.40.0 —cloudkms_..._get_public_keyand..._asymmetric_signcollapsed from arity 7/8 (split path components:(connection, project, location, keychain, key, version, ...)) to arity 4 ((connection, name, optional_params \\ [], opts \\ [])) wherenameis the fullprojects/{p}/locations/{l}/keyRings/{kc}/cryptoKeys/{k}/cryptoKeyVersions/{v}resource path. Both KMS signer modules (lib/cartouche/signer/cloud_kms.exfor Ethereum secp256k1 +lib/cartouche/solana/signer/cloud_kms.exfor Solana Ed25519) updated to construct thenamestring internally via a new privatekey_version_name/5helper; public API ofCartouche.Signer.CloudKMS.{get_address,sign}andCartouche.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) +:publicKeyFormatto PublicKey additively. Pre-mutation coverage push (percritical-rules.md"RAISE COVERAGE BEFORE MUTATING") added focused ExUnit assertions for both modules:get_address/6algorithm-mismatch error contracts on both, plus malformed-DER (wrong SubjectPublicKeyInfo OID) on Solana —Cartouche.Signer.CloudKMS69.23% → 88.24%,Cartouche.Solana.Signer.CloudKMS68.75% → 90.00%. The remaining uncovered lines on each module are exclusively thedefp client(cred) do; %{token: token, type: "Bearer"} = Goth.fetch!(cred); Connection.new(token); endconfig-glue path, which (a) is not affected by this mutation, (b) requires:meck/Moxtest 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 existingTesla.MockURL-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_ED25519already shipped viaCartouche.Solana.Signer.CloudKMSin 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_formatter3.3.1 → 3.4.0 (loosenedmix.exspin~> 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 commit860ac52("Tighten dep pins to match refreshed lockfile") rewriting~> 3.3→~> 3.3.1; loosening back to~> 3.3lets future patches land without re-triggering the pin gate. Closes ROADMAP Task 71.bandit1.10.4 → 1.11.0 (lock-only refresh —mix.exspin~> 1.10already permitted 1.11.0). Single dev-only callsite (mix.exstidewavealias,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.Recoverpromoted 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 falseand adds a moduledoc covering the EIP-191personal_signframing, the two accepted signature shapes (%Curvy.Signature{}struct vs raw 65-byte<<r::256, s::256, v::8>>), the three recid encodings thevbyte may carry (raw0/1,personal_sign27/28, EIP-15535 + 2 * chain_id + recid), and the two consumer audiences (internalCartouche.Signerrecover-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 accompanyingprefix_eth/1docstring is rewritten to spell out the four-part byte structure (0x19version byte +"Ethereum Signed Message:\n"prefix + decimal-ASCII length + message) and to flag that the\x19and\nin 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 aCartouche.Recoverrow betweenCartouche.Typed(EIP-712) andCartouche.RecoveryBit(v-byte normalisation), grouping the three signature-side primitives together. Also fixes the long-standing "Etheruem" typo in theprefix_eth/1docstring inherited from upstream signet. Doc-only — no library code changed; the public function set is unchanged. Future agent-economy work (descripexapi()annotations) deliberately deferred — see internal discussion 2026-05-02 — pending aCartouche.Verifyfacade 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 mastercore/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) andAUTHCALL(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'senable7702incore/vm/eips.go:571–575adjusts 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-wireopstrings 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.CLZis in geth master'snewOsakaInstructionSet()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.exspins to match what the lockfile (and test suite) is now built against:hieroglyph1.0.0 → 1.4.0; pin~> 1.0→~> 1.4(consumer-visible — raises floor to>= 1.4.0). Picks up thedecode_structs: trueString.to_existing_atomhardening (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, _},:stringdecode no longer truncating at NUL,encode_int/2overflow guard,dynamic?/1no longer crashes onT[0]).ex_dna1.4.1 → 1.4.3 (dev/test only, pin~> 1.3→~> 1.4).ex_ast0.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-describingapi()macro; cartouche doesn't import either directly. - Phase 11 advisory audit (the two
decode_structs: truecallsites atlib/mix/cartouche.gen.ex:611-614andlib/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.Blockextended with seven Ethereum hard-fork fields that the integration suite (Task 61) had pinned withrefute Map.has_key?/2decoder-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), andmix_hash(pre-Merge PoW mix hash; post-Merge PREVRANDAO per EIP-4399). All seven fields are... | nilin@type tso pre-fork blocks deserialize cleanly through the existingmap(x, f)nil-tolerant helper.withdrawalsdecodes to[Cartouche.Block.Withdrawal.t()] | nilvia a new nestedCartouche.Block.Withdrawalsubmodule (mirrors theCartouche.Receipt.Logprecedent — owndefstruct,@type t,deserialize/1, doctest) carryingindex,validator_index,address,amountper 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 intest/block_test.exs's newdescribe "deserialize/1 — fork-tier optional fields (Tasks 63 + 64 + 65)"anddescribe "Cartouche.Block.Withdrawal.deserialize/1 (Task 64)"blocks perfeedback_doctests_not_substitute_for_tests.md— covering pre-London nil defaults, the post-Londonbase_fee_per_gasboundary, the post-Shanghai withdrawals-list-of-structs shape with non-empty + empty boundaries, the post-Cancun all-fields-populated shape, the cross-forkmix_hashdecode, the zero-amount Withdrawal boundary, and the uint64-max amount round-trip. Integration tests attest/rpc_integration_test.exsstrengthen the three priorrefuteblocks 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-byteparent_beacon_block_root, integerblob_gas_used+excess_blob_gas, 32-bytemix_hash). RPC doctests atlib/cartouche/rpc.ex:463/522(get_block_by_number/2andget_block_by_hash/2) updated to reflect the new struct shape — the mock test client attest/support/client.ex:893already returnsmixHashin its fixture, so the doctests showmix_hashpopulated and the six other new fields asnil. Pre-mutation gate cleared (Cartouche.Blockalready at 100% coverage); post-mutationCartouche.BlockandCartouche.Block.Withdrawalboth at 100%; dialyzer clean onblock.ex; totalinvalid_contractcount holds at 8 (no regressions). The:include_transaction_detailsopt remains hardcodedtransactions: []per ROADMAP Task 66 (filed standalone, separate concern). A newTODO(Task 66):marker atlib/cartouche/block.ex:294makes that deferral visible to credo. Closes ROADMAP Tasks 63, 64, 65.- Mainnet archive integration test suite (
test/rpc_integration_test.exs) withCartouche.Test.Livehelper module (test/support/live.ex). Opt-in viamix integrationormix test --include integration; excluded frommix test.jsonby 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). NoApplication.put_envmutation, noon_exitcleanup, tests stayasync: true. Required a private:clientopt onCartouche.RPC.send_rpc/3— every wrapper already forwardsopts, 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(theblockwatch-oneSSH tunnel:ssh -L 8545:127.0.0.1:8545 -L 8546:127.0.0.1:8546 blockwatch-one); override viaCARTOUCHE_LIVE_NODE_URL.Cartouche.Test.Live.assert_node_available!/0flunks 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 torefute Map.has_key?assertions — Tasks 63 (Blockbase_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: :testincli/0preferred_envs,test_helper.exsnowExUnit.start(exclude: [:integration]).
- Methods covered (13 read-only):
Fixed
Cartouche.RPC.get_block_by_hash/2was sending only[block_hash]on the wire — JSON-RPCeth_getBlockByHashrequires 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 attest/support/client.ex:881accepted 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 in0.1.0. Fix:get_block_by_hash/2now reads:include_transaction_detailsfrom opts (defaultfalse, matchingget_block_by_number/2's contract) and forwards both params on the wire. Mock client'seth_getBlockByHash/1widened toeth_getBlockByHash/2with a default-falsesecond 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/2inCartouche.RPC) forCartouche.Transaction.V1encoded thedatafield viaCartouche.Hex.encode_short_hex/1, which strips leading zeros and produces"0x0"for empty calldata. Real mainnet nodes rejectdata: "0x0"with-32602 Invalid params— the JSON-RPCDATAtype requires bytes-preserving hex ("0x"for empty, full-width otherwise). The mock client accepted any value, masking the bug. Fix: V1'sdatanow usesCartouche.Hex.encode_big_hex/1, matching V2's encoding (which was already correct). The other V1 fields (gasPrice,gas,value) stay onencode_short_hex— they'reQUANTITYtype, where"0x0"is the spec-mandated form. Discovered by the integration suite'seth_estimateGastest 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/1spec narrowedinteger() | {integer(), :wei | :gwei}) :: integer()→non_neg_integer() | {non_neg_integer(), :wei | :gwei}) :: non_neg_integer(), with matchingamount >= 0guards on all three clauses. Wei is a discrete count by domain — every internal caller (Cartouche.Transactionconstructors at:67/:70/:384/:386/:389,Cartouche.RPCfee-suggestion fallbacks at:1569/:1584/:1591) already passes non-negative values; the spec was simply loose. Negative inputs now raiseFunctionClauseErrorat the contract boundary instead of silently propagating a nonsense wei count downstream. Newdescribe "spec boundaries (Phase 1.2)"block intest/wei_test.exspins the zero boundary in all three clauses, the large-value identity round-trip, the:gweimultiplier, and the negative-input rejection — grounded as ExUnit assertions perfeedback_doctests_not_substitute_for_tests.md.Cartouche.Weicoverage stays at 100%; full suite green; dialyzer clean onwei.ex. Closes ROADMAP Tasks 7+8+9, completing Phase 1 (1.1RecoveryBit, 1.3Signermfa(), 1.4Hexreturns already shipped). The downstream onchain@dialyzer {:no_match}strip acrossOnchain.Hex/ ABI / ERC / ENS / Multicall callers becomes load-bearing once cartouche0.1.xships (Task 6).Cartouche.Trace.deserialize/1no longer raisesProtocol.UndefinedErroron RPC payloads with missing orniltraceAddress. TheEnum.map(params["traceAddress"], &decode_address_or_number/1)line attrace.ex:423(tracked underTODO(Task 55)since Tasks 51 + 52) now routes through adecode_trace_address/1helper that raisesArgumentError, "missing traceAddress in trace_transaction result element"when the key is absent ornil, and maps the list otherwise. The audit attached to ROADMAP Task 55 (Codex consultation 2026-04-26 reviewing OpenEthereum + Infuratrace_transactionschemas) confirmedtraceAddressis 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). Newdescribe "deserialize/1 — traceAddress absent/nil (Task 55)"block intest/trace_test.exspins both the missing-key and explicit-nil shapes viaassert_raise ArgumentError, ~r/missing traceAddress/.Cartouche.Tracecoverage stays at 100%; full suite green; dialyzer clean ontrace.ex. Closes ROADMAP Task 55.Cartouche.RPC.get_block_by_number/2and 9 companion JSON-RPC callsites no longer send raw integers as block parameters. The@specdeclarednon_neg_integer() | String.t()and the doctest atlib/cartouche/rpc.ex:450exercisedget_block_by_number(55)— butsend_rpc/3Jason.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 attest/support/client.exaccepted any value, masking the bug for the entire history of the upstream codebase. Fix shape: a new publicCartouche.Hex.encode_quantity/1produces JSON-RPC-spec-compliant lowercase quantity strings (0→"0x0", no leading zeros, lowercase hex digits) — distinct fromencode_short_hex/1, which is uppercase by design for transaction-field encoding. A privatenormalize_block_param/1helper insideCartouche.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, anddebug_trace_call/2. The existing integer doctest at:450continues to pass — the mock returns the same fixture for any block param — and now accurately documents real-node behavior. New ExUnit coverage: adescribe "encode_quantity/1"block intest/hex_test.exs(zero, small int, single-digit, large multi-byte block number, all-letter hex, negative-rejection) percritical-rules.md"Doctests Are Documentation, Not Tests"; and adescribe "block-param wire encoding"block intest/rpc_test.exsusing aCaptureClienttest double that delegates toCartouche.Test.Clientwhilesending the decoded JSON-RPC body back to the test pid — pins the integer-in →"0x37"-on-wire wiring through the publicget_block_by_number/2path and proves companion normalization viaget_balance/2+get_nonce/2opt-reader assertions. Pre-mutation coverage gate:Cartouche.Hexalready 100% (Phase 1.4 closeout);Cartouche.RPC91.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 chaineth_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.Hexreturn-shape spec audit closeout. The five spec corrections (decode_hex/1,from_hex/1,from_hex!/1,decode_hex_number/1, plus the privatedecode_hex_/1) had previously shipped as a drive-by under commit8d4bc18("doctor, credo fixes", 2026-04-26):{:ok, t()} | :error→{:ok, t()} | :invalid_hexon the four soft-return functions andt() -> String.t()→t() -> t()onfrom_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 tofrom_hex/1(parity withdecode_hex/1— both now show:invalid_hexon bad input); (b) raise-path doctest added tofrom_hex!/1(parity withdecode_hex!/1— both now show theCartouche.Hex.InvalidHexexception); (c) newdescribe "spec boundaries (Phase 1.4)"block intest/hex_test.exspinning 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: adescribe "deep_encode_binaries/1"block covering the four previously-uncovered lines of the@doc falserecursive helper, liftingCartouche.Hexcoverage 94.29% → 100% and clearing the ≥95% critical-tier gate prophylactically for any future Hex mutation. No runtime behavior change. Dialyzer outcome: 0hex.exwarnings (unchanged — the spec corrections were already absorbed); totalinvalid_contractcount remains 8 (the 6 insleuth.exare tracked under Task 54, the 2 intyped.exunder Tasks 19+20). Onchain's@dialyzer {:no_match}strip onOnchain.Hexand the cascading ABI / ERC / ENS / Multicall callers becomes load-bearing once cartouche0.1.xships (Task 6). Closes ROADMAP Tasks 10+11+12+13.Cartouche.Transaction.V1r/s/v storage unified to integers throughout, settling a Schrödinger spec that produced three different runtime shapes for the same field.V1.t()declaredr: integer(), s: integer(), v: integer(), butadd_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>>), whileV1.new/7andV1.decode/1stored them as integers. The mismatch was latent until a public-API path exercised it:V1.decode → recover_signerraisedArgumentErroron any signed legacy RLP transaction, becauseget_signature/1's second clause built the signature with<<r::binary-size(32), …>>anddecode/1had stored r/s via:binary.decode_unsigned/1.add_signature/2now: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, soadd_signature → get_signatureround-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/1now guardsbyte_size(r) <= 32 and byte_size(s) <= 32on 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 inget_signature/1would raiseArgumentErroron r ≥ 2^256 reachable throughdecode → recover_signer. Theadd_signaturedoctest intransaction.exwas updated to showr: 1, s: 2post-call (the only doctest whose expected output changed). New ExUnitdescribe "V1 (Task 53)"block intest/transaction_test.exscovers the malformed-RLP fallback indecode/1(closing the coverage gate), the fullbuild_signed_trx → encode → decode → recover_signerround-trip (the previously-broken path; failed pre-fix exactly as the type system predicted), the empty-sig boundary (r:0, s:0→recover_signerreports missing signature), and the adversarial 33-byte r/s fixture asserting{:error, "invalid legacy transaction"}— four cases grounded as ExUnit assertions percritical-rules.md"Doctests Are Documentation, Not Tests". Coverage onCartouche.Transaction.V193.33% → 100%; dialyzer dropstransaction.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 remaining0.1.0item.Cartouche.Solana.Transaction.deserialize/1andsign_partial/2were specced as well-formed but raised on malformed input.deserialize/1(specced{:ok, t()} | {:error, term()}) raisedFunctionClauseErroron empty / truncated compact-u16 prefixes (bothdecode_compact_u16_acc/3clauses required<<byte, rest::binary>>),FunctionClauseErroron sub-3-byte message headers (the onlydeserialize_message/1head-clause required three header bytes), andMatchErroron truncated instruction bodies (three raw<<...>> = restmatches inread_instructions/3body). Hardened with a privatesafe_decode_compact_u16/1returning{:ok, val, rest} | {:error, :truncated_compact_u16}(publicdecode_compact_u16/1tuple contract preserved); aread_instructions/3rewrite usingwith+ aread_size_prefixed/2guard helper that mirrors the existingread_signatures/3/read_pubkeys/3shape, plus a fallback(_, _, _) -> {:error, :insufficient_instruction_data}clause; and adeserialize_message(_) -> {:error, :invalid_message_header}fallback.sign_partial/2evaluatedEnum.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//1step (0..(n - 1)//1), giving the empty range whenn == 0(also silences the Elixir 1.20 deprecation on the unstepped form). Both behaviours grounded by new ExUnitdescribeblocks intest/solana/transaction_test.exscovering<<>>, 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, allassert {:error, _}/ boundary-shape assertions percritical-rules.md"NEVER HIDE TEST FAILURES". Coverage onCartouche.Solana.Transaction95.56% → 98.97%; full suite green; dialyzer clean on the touched file. Closes ROADMAP Tasks 56 and 57.Cartouche.Trace.t().trace_addressandCartouche.TraceCall.t().tracewere typed as singular values but always built as lists at runtime —trace_addressviaEnum.map(params["traceAddress"], …)andtraceviaCartouche.Trace.deserialize_many/1(whose@specreturns[t()]). Consumer code pattern-matching on the documented singular shape would have raisedMatchErroron every realtrace_transaction/trace_callManyresponse. 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 thetrace.ex:412andtrace_call.ex:124invalid_contractwarnings (invalid_contractcount 11 → 9). New focused ExUnit blocks intest/trace_test.exsandtest/trace_call_test.exsground the list shapes against edge cases (mixed-element union[42, <<_::160>>], empty list,deserialize_many/1round-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/niltraceAddress(theEnum.map(nil, _)path attrace.ex:423) is tracked underTODO(Task 55)and filed as ROADMAP Task 55 for follow-up — the test suite intentionally does not pin the broken behavior withassert_raisepercritical-rules.md"NEVER HIDE TEST FAILURES".
Documentation
- README's Solana example block modernized to use the
Cartouche.Solana.SignerGenServer (Signer.address/0,Signer.sign/1) instead of raw seed handling, paralleling the Ethereum example's reliance onCartouche.Signer. AddsCartouche.Solana.Transaction.serialize_message/1to the example for explicit message-byte handoff to the signer. Old shape used an undefinedfee_payer_seedvariable inherited from upstream — the new example is runnable end-to-end against a configured signer. A trailing prose note links offline (raw-seed) signing viaTransaction.sign/2and sponsored-transaction signing viasign_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, ignoresMix.Tasks.Cartouche.Genandlib/cartouche/contract/— Doctor's source-level AST walker counts the generator'sdef unquote(name)(args)literals insidequote doblocks as defs of the Mix task itself, producing 23 false-positive missing-doc warnings; BEAM introspection confirms onlyrun/1is exported). Adds missing@specand/or@doc/@doc falseentries on 18 modules:Cartouche,Cartouche.{Application, Assembly, Block, Erc20, Filter, Hash, Hex, Keys, OpenChain, Recover, RPC, Signer, Sleuth, Transaction, Typed, VM}, andCartouche.Solana.Programs. Behavior-preserving — pure documentation/typespec coverage.
Fixed
Cartouche.Signer.start_link/1andCartouche.Signer.sign_direct/4specs: replace the Erlangmfa()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 dialyzersigner.ex:141 invalid_contractwarning that ROADMAP Phase 1.3 tracked, and forestalls the same regression that was about to ship via the newstart_linkspec. Bundled with the typespec sweep above per the touched-files credo rule.Cartouche.get_contract_address/1spec: widen the input type fromCartouche.contract()(which excludes hex strings —address() :: <<_::160>>) tobinary() | atom()to match the impl, which routes anyis_binary/1value throughCartouche.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/2spec: return type tightened from the%__MODULE__{}literal tot()for consistency withV2.add_signature/2,4.Cartouche.Transaction.V2.add_signature/2(binary form): tighten the second-arg spec from loosebinary()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/1no longer mints atoms from RPC input. The previous implementation calledString.to_atom(params["op"])for every entry of aneth_debug_traceCallstructLogsarray — 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..4ranged families) and resolves opcodes viaMap.fetch/2. The whitelist also carries two alias pairs verified against go-ethereum'score/vm/opcodes.gomaster:KECCAK256/SHA3for opcode 0x20 (SHA3covers pre-1.8 Geth and some non-Geth nodes), andDIFFICULTY/PREVRANDAOfor opcode 0x44 (current Geth still emits"DIFFICULTY"— the rename TODO is still open in go-ethereum master, so withoutDIFFICULTYthe whitelist would crash on every modern Geth trace;PREVRANDAOis forward-compat for clients that already emit the post-Merge name). Unknown strings raiseArgumentErrorcarrying the offending value, surfacing future-EVM additions as visible failures instead of silent corruption. New regression coverage intest/debug_trace_test.exs(per-family boundary tests + nil/invalid rejections) and a non-async atom-table-stability test intest/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'sDOS.StringToAtomfinding atlib/cartouche/debug_trace.ex:71(fingerprint4F16CCA) is removed from.sobelow-skips. Audit of remainingString.to_atomcallsites inlib/cartouche/sleuth.ex(thequery_by/3atom-deriving pair plusname_keyword/1) is tracked as ROADMAP Task 48 — those are bounded by compile-time atoms but warrant the sameString.to_existing_atomtreatment after raising Sleuth coverage to the 95% gate. The Mix-task callsite atlib/mix/cartouche.gen.ex:817is dev-time only and stays in.sobelow-skips.
Changed
- Breaking: rename
Cartouche.Hex.HexError→Cartouche.Hex.InvalidHexandCartouche.VM.VmError→Cartouche.VM.InvalidVmfor consistency with the codebase'sInvalid*exception strategy (InvalidAssembly,InvalidCode,InvalidOpcode,InvalidFileError). Public callers thatrescue Cartouche.Hex.HexError/rescue Cartouche.VM.VmErrormust update. Settles cleanup.md B7 in favor of consistency over the no-breakage option, since cartouche is pre-1.x. Affects alldecode_hex!/decode_address!/decode_word!/decode_sized!/decode_hex_number!/encode_addressraise paths andCartouche.VM.exec/3's error-rescue path. - Tooling: add
.credo.exs(strict, withTagTODO: [exit_status: 2]to keep TODOs visible per project policy andRefactor.FunctionArity max_arity: 12to permitTransaction.V2's EIP-1559-mirroring constructors),.sobelow-conf(exit: "Low"), and.sobelow-skips(fingerprints for two runtimeString.to_atomcallsites inCartouche.Sleuth.query_by/3bounded by compile-time-known atoms (full audit tracked as Task 48), one build-timeString.to_atomcallsite in the generator's contract-name binding (lib/mix/cartouche.gen.ex:817), and threeFile.*traversal flags in the generator's IO writers). The previously-suppressed runtimeString.to_atominCartouche.DebugTrace.StructLog.deserialize/1is now hardened (see Security above) rather than suppressed. - Cross-module helper-extraction refactor to bring
mix credo --strictto zero issues onlib/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/3clauses;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}/*, ~25do_*opcode-handler helpers, and puresafe_floor_div/safe_rem/safe_addmod/safe_mulmod/int_lt/int_gt/int_eq/int_is_zerocallbacks for theunsigned_op*/signed_op*dispatch. Behavior-preserving —mix test.json --quietgreen pre-commit. Two# credo:disable-for-next-line Refactor.CyclomaticComplexityretained onAssembly.show_opcode/1andVM.run_single_op/3(EVM opcode-dispatch tables — splitting would add indirection without reducing real complexity), oneReadability.FunctionNamesdisable onBase58.sigil_B58/2(Elixir requires sigil names start with uppercase), and twocredo: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_applynow usesreraise __STACKTRACE__instead ofraiseinside the rescue, preserving the original exception's stacktrace alongside the descriptiveRuntimeErrormessage about the missingbytecode/0(or other required) function. Settles cleanup.md B8.Cartouche.OpenChain.lookup_*decode_response/1now 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 raisingCaseClauseError. Pre-existing gap surfaced when the inline case was extracted into the helper.Cartouche.OpenChain.lookup/3raise_on_multiple: truepath:Enum.join(found_signatures, ",")was iterating a list of{sig, name}tuples and crashingProtocol.UndefinedErrorinstead of returning{:error, "Multiple matching signatures: ..."}. Fixed atlib/cartouche/open_chain.ex:200withEnum.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; percritical-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-drivenshow_opcode/1table covering every named arm inCartouche.Assembly(PUSH/DUP/SWAP/INVALID tuple coverage + show-only atoms:blobhash/:blobbasefee/:log) pluscompile/13–7-operand cases,transform_jumpsmissing-jump-dest path, full PUSH1–32 / DUP1–16 / SWAP1–16 disassembly, per-clauseopcode_size/1, and exception-struct defaults; contract-creationCartouche.Receiptwithto: nil/contractAddresspopulated and log shapes for empty / 2-topic / 4-topic data;Cartouche.OpenChainTest.TestClientextended with magic-byte signature dispatch (0xee000001–05) routing took=false/ non-JSON-body / transport-error / multi-result / empty-result paths in one async-safe inline client, pluslookup_error/lookup_error_and_valuesshort-binary clauses andSignatures.deserialize/1filter behaviour; V2 transaction roundtrip throughCartouche.Test.Signer.start_signer/0+ signer recovery, plusV2.new/9chain-id-nil fallback,V2.new/12nil-fee passthrough, ABI-tuple vs raw-binarybuild_trx_v2call-data, callback-short-circuit onbuild_signed_trx_v2, andV2.decode/1malformed-RLP rejection;Cartouche.Sleuth.try_applyrescue exercising the descriptiveRuntimeErrorwhen the contract module is missingbytecode/0; explicit cache-hit test forCartouche.Solana.Signerusing:sys.get_state/1to confirm the:addresskey is populated after the firstaddress/1call. No new test files; no new test dependencies (compile-time module substitution viaconfig/test.exsalready in place — no Mox/Bypass).
Changed
- Generator gates
exec_vm_*emission on real bytecode.Mix.Tasks.Cartouche.Gennow treatsnil, blank strings,"0x", and"0x" <> whitespaceas missing bytecode (newblank_bytecode?/1predicate inlib/mix/cartouche.gen.ex), and the:puredispatch branch now requireshas_bytecodebefore emittingexec_vm_fn/exec_vm_raw_fn. Without both, the generator was emittingdef bytecode, do: hex!("0x")(compile-time<<>>) plus a:purebranch that always emittedexec_vm_*— producing 762exec_vm_*functions inCartouche.Contract.IConsole(Hardhat console.log interface, no on-chain bytecode) that calledCartouche.VM.exec_call(<<>>, ...)and always raisedVmError. Dialyzer flagged each asno_return, cascading to 1534 of 1626 total warnings. New regression tests intest/mix/cartouche_gen_test.exscover the four blank-bytecode shapes plus the working real-bytecode path. DropsCartouche.Contract.IConsole.{bytecode/0, deployed_bytecode/0, exec_vm_*}from the generated module (RPC-sideencode_*/call_*/execute_*family preserved); regenerated file is 18705 lines (was 28084). Bundled with the bug fix: the four pre-existing credo issues inlib/mix/cartouche.gen.ex(L153 nesting inrename_dups/1, L170 cyclomatic inget_encode_calls/2, L247 cyclomatic inencode_function_call/3, L337 nesting) are resolved by extractingaccumulate_named_abi/2+dedup_named_abi/5+maybe_rename_dup_fn/5(rename-dups),merge_encode_call_result/2(encode-calls reducer), and ~16build_*_fn/1helpers +select_emitted_fns/3+ supporting argument-spec helpers (encode-function dispatch). Generator output AST is byte-identical to pre-refactor fori_console.ex(verified via the newcartouche_gen_test.exssuite). One additional post-process:strip_zero_arity_def_parens/1rewritesdef name()→def nameon 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 ~382ParenthesesOnZeroArityDefsflags from generatedi_console.exwithout manual annotation.String.to_atom/1callsites in 6 generator helpers carry# sobelow_skip ["DOS.StringToAtom"]annotations (build-time codegen, not runtime input). - Delete
Cartouche.Utilgrab-bag module. Helpers redistributed into five focused modules:Cartouche.Address.from_public_key/1(renamed fromUtil.get_eth_address/1),Cartouche.Chain.parse_id/1+ chain registry (renamed fromUtil.parse_chain_id/1),Cartouche.Wei.to_wei/1,Cartouche.HTTP.normalize_finch_result/1, andCartouche.RecoveryBit(promoted from the nestedCartouche.Util.RecoveryBitsubmodule). The five hex helpersdecode_hex_input!/1,encode_bytes/2,pad/2,nibbles/1, andchecksum_address/1move toCartouche.Hex. The seven@deprecateddecode/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 thekeccak/1defdelegate are removed — modern equivalents already exist inCartouche.Hex/Cartouche.Hash.nil_map/2is inlined as a module-local private helper inCartouche.TraceandCartouche.Trace.Action(its only consumers).
Fixed
Cartouche.RecoveryBit.normalize/2andnormalize_signature/2specs: literal atom:no_returnreplaced with theno_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.exsversion from the inherited signet pin1.6.1to0.1.0-devahead of the first hex publish under thecartouchenamespace (ROADMAP Phase 0, Task 1). - Swap the
:abipath dep (path: "../abi", override: true) for the published hex package{:hieroglyph, "~> 1.0", override: true}. ZenHive'sabifork is now on hex.pm ashieroglyph 1.0.0; hex package name ishieroglyphbut the Elixir module namespace remainsABI, so no callsite changes. Unblocksmix hex.publishfor cartouche, which rejects path/git deps (ROADMAP Phase 0, Task 6). - Update
mix.exs:packagefor the publish cut:maintainers: ["ZenHive"](was["Geoffrey Hayes"]— attribution preserved inLICENSEand in[0.0.1]below); droptest/supportfrom:files(test helpers aren't part of the public surface), addCHANGELOG*; addCHANGELOG.mdtodocs[:extras]so hexdocs renders the release history; add aChangelogentry topackage[:links].
Fixed
- Pin bitstring size variables in binary matches across
Cartouche.Solana.Transaction.read_instructions,Cartouche.Assembly.disassemble_opcode, andCartouche.VM.{Memory,Operations}/Cartouche.VM.static_callfor Elixir 1.20 compatibility. Behaviour-preserving; resolves allvariable "X" is accessed inside size(...) ... must precede it with the pin operatorwarnings 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
expectedinCartouche.Solana.PDATest"wrong bump"test (test/solana/pda_test.exs:137) — variable is used inside thematch?/2guard 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
:abilibrary. Root cause was thatABI.encode/2,ABI.decode/2-3,ABI.decode_event/3-4,ABI.TypeEncoder.encode/2, andABI.TypeDecoder.decode_raw/3lacked@specdeclarations, andABI.FunctionSelector.t()declaredreturns: type(singular) while the runtime and ABI's own doctests usereturns: [argument_type]. Dialyzer's inferred success typing forABI.encode/2collapsed the struct branch tofunction: nil, types: []only, so every populated selector at every cartouche callsite was flagged aswill never return, cascading throughlib/cartouche/contract/i_console.ex. Fixed in thezenhive/abifork and published to hex.pm ashieroglyph 1.0.0(hex package name only;ABImodule namespace preserved). cartouche consumes the patched library via{:hieroglyph, "~> 1.0", override: true}(see### Changedabove). ABI typespec fixes will be upstreamed via PR topoanetwork/ex_abi. (cleanup.md A1+A2; residual cascade tracked under follow-up A1b.) - Restore
Cartouche.Signer@moduledoc(was@moduledoc falsewith module-level prose stuck in a@docthat collided withstart_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 falsewith descriptive@moduledocon six submodules whoset()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 frommix docs; clean docs build for the publish cut (ROADMAP Task 36). - IAL/markdown collision in four
Cartouche.Hexdoctest 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
/aritysuffix 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.mdSleuth regeneration command — the canonical ABI source is./priv/Sleuth.json(vendored), not the previously documented../sleuth/out/Sleuth.sol/Sleuth.jsonexternal 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.