1.4.0 - 2026-05-01

Atom-creation hardening on decode_structs: true path

  • Security hardening — String.to_atom/1 removed from both atom-creation call sites in lib/abi/type_decoder.ex (tuple_value/3) and lib/abi/type_encoder.ex (fetch_by_name/2). Both call sites previously created atoms from contract-supplied field names (gated by sobelow_skip ["DOS.StringToAtom"] annotations on the trust assumption that "field names come from trusted ABI metadata"). The assumption breaks the moment a consumer ingests ABIs from arbitrary sources (block explorers, contract registries, user-submitted JSON, indexer feeds) — the atom table is a non-reclaimable VM resource, so an attacker who controls field names can DoS the BEAM. Both call sites now route through String.to_existing_atom/1. The sobelow_skip annotations are removed.
  • Decoder — observable contract change on the opt-in path. ABI.TypeDecoder.tuple_value/3 (called whenever decode/3 / decode_call/3 / decode_event/4 runs with decode_structs: true) now requires every snake_case field atom to already exist in the VM atom table. New private helper atom_key_for!/1 calls String.to_existing_atom/1 on Macro.underscore(name) and re-raises ArgumentError with a migration hint naming both the underscored atom (":#{underscored}") and the original ABI field ("#{name}") when the atom hasn't been interned. Code that already references the decoded map's atoms (typical: %{from: from, to: to, value: v} = decoded or a @field_atoms [:from, :to, :value] module attribute) interns them at compile time and is unaffected. Code that decodes ABIs ingested at runtime without ever referencing the resulting atoms must intern them once before the first decode call. The decode_structs: true doctest in ABI.decode/3 continues to pass because the assertion %{a: 10, b: true} interns :a / :b at module load.
  • Encoder — silent safety upgrade, no contract change. ABI.TypeEncoder.fetch_by_name/2's atom is only used as a Map lookup key, never returned as data, so atom creation was never necessary — a consumer's input map can only contain atom keys that already exist in the VM. New private helper existing_atom/1 returns the existing atom or nil; the cond branch falls through to the next clause when the atom doesn't exist, with no observable behavior difference for any caller (string-key match, atom-key match, and miss-both-keys all behave identically to before). The miss-both raise message now reports the underscored snake_case form as a string (:my_field) rather than calling inspect/1 on a freshly-created atom — strictly clearer.
  • Tests. Added a decode_structs: true atom safety describe block in test/abi/type_decoder_test.exs: raise message names both the atom and the ABI field, success path with pre-interned atoms, fall-through to tuple when decode_structs is false, fall-through to tuple when any field name is empty. Added two encoder tests in test/abi/type_encoder_test.exs exercising the new existing_atom/1 → nil branch (string-key match still succeeds when the snake_case atom doesn't exist; missing-key raise reports the snake_case form correctly). All field-name strings in the new failure tests use guaranteed-uninterned suffixes (Z47Q-style) verified absent across lib/, test/, and src/. Test count 339 → 345; coverage on ABI.TypeDecoder 98.21% → 98.39% and ABI.TypeEncoder 99.28% → 99.30%.
  • Manifest. api(:decode, ...)'s opts description now documents the pre-intern contract; returns.type corrected from :list (was inaccurate) to :union. tuple_value/3's @doc rewritten to lead with the atom-table contract. Manifest entry count unchanged (32). Content diffs in the regenerated mix hieroglyph.manifest artifact: those two api(:decode, ...) description fields, the tuple_value/3 @doc body, and the generated_at timestamp; in addition, the JSON output reflects a global signature/spec key-order swap (descripex emission ordering — content unchanged, key set unchanged).
  • No benchee dep added. String.to_existing_atom/1 is the same hash-table lookup as String.to_atom/1 on the hit path; the miss path is configuration-time (a one-time module-load cost in the consumer), not a hot user-request path. Speculative benchmarking would have added a dev-only dep without a workload to measure against. If a regression surfaces in cartouche / onchain CI post-merge, add benchee then.
  • Why minor bump (1.3.0 → 1.4.0) not patch. Behavior change on a documented opt-in path; not breaking the wire format, not breaking the default decoder, but observable for callers passing decode_structs: true against ABIs whose field names were never referenced in their code. Conservative minor matches the 1.1.0 / 1.2.0 precedent (1.1.0 added new public APIs alongside fixes; 1.2.0 narrowed decode_event/4's error contract).
  • Upstream filing. Per project policy ("No upstream gating … file issues/PRs upstream, then ship the fix here immediately"), the local change ships in this release; an upstream issue on exthereum/abi will be filed describing the DoS surface and proposing either the same to_existing_atom switch or a decode_structs: :existing_atoms opt-in for back-compat — maintainers' choice.

1.3.0 - 2026-05-01

function ABI type — encode + decode + packed

  • New type support — Solidity function ABI type. Lifts the parse-time rejection that previously raised ArgumentError on any signature mentioning function (per upstream issue #54). Solidity's function type is a 24-byte external function pointer: 20-byte address ++ 4-byte selector, encoded as a 24-byte payload right-padded to 32 bytes in standard mode (wire-format identical to bytes24), or 24 bytes tight in packed mode. Concretely: ABI.encode("foo(function)", [<<addr::binary-20, sel::binary-4>>]) produces a 36-byte calldata (4-byte selector + 32-byte slot); ABI.decode("foo(function)", payload) returns the original 24-byte binary. Composed types (function[], function[N], (uint256, function), structs containing function) work via the existing recursive composition — no new array/tuple plumbing needed.
  • Production code touched. Removed the :function clause from ABI.Parser.reject_unsupported!/1 (parse-time gate); added :function to ABI.FunctionSelector.@type type and a dynamic?(:function), do: false clause; added encode_type(:function, _) clauses to ABI.TypeEncoder (binary-only input — wrong-size and non-binary inputs raise ArgumentError with a payload-shape hint); added a matching packed_top(:function, _) pair so encode_packed/2 accepts function (24 bytes tight per spec) instead of falling into the unsupported-type catch-all; added decode_type(:function, _, _) to ABI.TypeDecoder delegating to decode_bytes/3 with right-pad. encode_bytes/1 and decode_bytes/3 already do the 24→32 byte rounding via Math.pad/4 and Math.unpad/3 — reused, not duplicated.
  • Input shape — 24-byte binary only. No {addr, sel} tuple form, no integer form. Parallel to how {:bytes, N} accepts a binary of exact size. Avoids confusion with the Solidity tuple type (bytes20, bytes4). Caller can always addr <> sel if they have parts separately. Decode returns the raw 24-byte binary (symmetric with encode); caller pattern-matches <<addr::binary-20, sel::binary-4>> = decoded if needed.
  • fixed<M>x<N> / ufixed<M>x<N> stay deferred. Solidity itself does not fully support fixed-point types — quoting the language docs: "Fixed point numbers are not fully supported by Solidity yet. They can be declared, but cannot be assigned to or from." No real contracts emit them, so there's nothing to encode/decode in the wild. Parse-time rejection narrowed to fixed/ufixed only; the rejection-clause comment now cites the Solidity-side reason rather than treating all three types as a single class. README's Support table notes the deferral inline.
  • Tests. Converted 5 :function parse-rejection tests in test/abi/function_selector_test.exs to positive-acceptance assertions in a new "function type acceptance" describe block; renamed/split the canonical-signature describe block since :function is no longer dead-via-parse. Added :function to the @leaf_types list and value_for/1 dispatcher in test/abi/roundtrip_property_test.exs, plus a dedicated property — the recursive composite property at depth ≤ 5 now exercises function[], function[N], and (uint256, function) automatically. Added focused unit tests in test/abi/type_encoder_test.exs (slot layout, wrong-size raise, non-binary raise) and test/abi/type_decoder_test.exs (right-pad strip, round-trips inside (uint256, function, bool), function[3], and function[]). Added a function: 24 bytes tight golden vector + size-mismatch raise to test/abi/encode_packed_test.exs.
  • Manifest. No public-API surface change — dynamic?/1 arity is unchanged and the new function-handling lives entirely in private clauses. mix hieroglyph.manifest re-emits an api_manifest.json with identical content vs. 1.2.0 (the only diffs are the regenerated generated_at timestamp and JSON key ordering — jq -S 'del(.generated_at)' produces a byte-identical file).
  • Upstream PR. Independent feature PR against exthereum/abi (parser-clause-deletion + encoder/decoder/packed clauses + tests) — not bundled with #53/#54/#55 follow-ups; small and self-contained.

1.2.0 - 2026-05-01

Public Surface Pass

  • Public Surface Pass bundle — decode_event/4 error contract narrowed. The decode_event/4 @spec previously declared {:ok, ...} | {:error, term()}, but the runtime path raised on malformed payloads instead of returning {:error, _} — so the typespec was a lie wherever a topic-matched log carried truncated/garbage data. Wrapped the decode_raw-driven payload-decode path in a try/rescue and converted raised exceptions into {:error, {:malformed_data, message}}. verify_event_signature/2 already returned {:error, ...} for signature mismatches but used a string format; tightened to the atom-tagged shape {:error, {:event_signature_mismatch, %{expected: ..., got: ...}}} so callers can pattern-match the failure mode without parsing strings. Added @type ABI.Event.decode_error/0 enumerating the closed error set ({:event_signature_mismatch, _}, {:topics_length_mismatch, _}, {:malformed_data, _}); narrowed ABI.decode_event/4's @spec from {:error, term()} to {:error, Event.decode_error()}. The api() declaration now carries an errors: block mirroring decode_call/3's pattern, so the manifest exposes the closed error set to agent consumers. Bugfix-honoring-typespec — not a breaking change in any sensible sense (the prior contract was unreachable for malformed payloads), but downstream callers that previously caught raises around decode_event/4 should switch to matching {:error, {:malformed_data, _}}.

  • Public Surface Pass bundle — encode_bytes/1 flipped from def to defp. Hygiene-only flip. Already @doc false (lib/abi/typeencoder.ex), zero callers outside type_encoder.ex itself across the local monorepo (cartouche, onchain, `onchain{aave,evm,js,tempo},mpp), and the agent-economy hint-rot test had already excluded it as a deliberate internal helper. Thedefwas a leftover from beforeencode_raw/2became the canonical raw-encoding entrypoint. Removed the now-redundant@doc falseand the explicit exclusion entry fromtest/abi/agent_economy_test.exs(the function is no longer inmodule_info(:exports)`, so the hint-rot cross-check skips it naturally). Manifest user-declared count unchanged (already excluded). ROADMAP's "(breaking, if changed)" wording was overcautious — the codebase already treated this as internal.

  • New API — ABI.decode_error/2. Decodes Solidity 0.8.4+ custom-error revert data: matches the first-4-byte selector in revert_data against a list of known error definitions (signature strings or pre-parsed FunctionSelector structs, mixed accepted) and decodes the payload of whichever matches. Returns {:ok, %{error: name, args: [...]}} on a hit, {:error, :no_match} when no definition's selector matches (or the list is empty), and {:error, :calldata_too_short} on <4 bytes. Mirrors decode_call/3's contract: malformed payload after a selector match still raises (same behavior as decode/3). The first definition with a matching selector wins — definition order is the disambiguation lever. Reuses ABI.method_id/1 (selector computation), ABI.decode/3 (payload decode), ABI.Parser.parse!/2 (signature normalization). api() declaration carries the closed error set under namespace /abi. Solidity 0.8.4+ custom-error revert data is selector-prefixed exactly like calldata, so the implementation is structurally identical to decode_call/3 modulo the multi-definition list.

  • New API — ABI.encode_packed/2. Solidity's non-standard packed encoding — used for Merkle airdrop leaves and keccak256(abi.encodePacked(...)) signature schemes. Per the spec: types <32 bytes concatenate tight (no padding); dynamic types (bytes, string) inline as raw payload (no length prefix); array elements are padded to 32 bytes (or 32-byte multiples for string/bytes) so element boundaries are recoverable; tuples/structs and nested arrays are explicitly unsupported and raise ArgumentError with a spec link. The wrapper accepts the same polymorphic first arg as encode/2 (binary() | FunctionSelector.t()); paren-only signatures like "(uint256,address)" parse as a single-tuple parameter (same shape as "foo((uint256,address))") and therefore raise — pass a struct-arg-free signature like "foo(uint256,address)" for a comma-separated arg list. Cross-checked against the canonical spec example (int16(-1), bytes1(0x42), uint16(0x03), string("Hello, world!")0xffff42000348656c6c6f2c20776f726c6421) and locked as a golden vector; Merkle-leaf golden vector (address ++ uint256 → 52 bytes pre-hash) included for airdrop consumers. Implementation lives next to encode_type/2 in lib/abi/type_encoder.ex with private packed_top/2, packed_array/2, pack_uint/2, pack_int/2 helpers — does NOT thread through Math.pad/4 (which always rounds to 32-byte multiples) since packed mode is the inverse of standard ABI. Inside an array, however, scalar elements DO route through encode_type/2 to reuse the 32-byte rounding correctly per spec.

  • Property Suite Expansion bundle (5 members — original 4 plus a production fix the suite surfaced). Single PR expanding test/abi/roundtrip_property_test.exs plus the resulting bug fix in lib/abi/type_decoder.ex.

    • tuple[] (dynamic array of tuples) round-trip coverage — the existing dispatcher already composes {:array, inner} with {:tuple, ...}, so no new generator clause was needed; what was missing was explicit pin-down. Three new properties: a static-only-element tuple[] (exercises the array-of-static-tuples layout), a mixed-element tuple[] where each element is itself dynamic (the most stress-testing shape — head/tail offsets are computed both per-array AND per-element), and an empty tuple[] unit test. The mixed-element property is what surfaced the string-NUL bug below on its first run.
    • Empty dynamic fields inside structs — explicit fixtures (not properties) for the four pinned shapes that mining surfaced from real protocol calldata: (bytes, string) with empty bytes and non-empty string, (bytes, bytes) both empty, empty tuple[] as the only dynamic field in a struct, and empty tuple[] followed by non-empty bytes. Inline test "..." do blocks rather than the @fixtures pattern from defi_calldata_test.exs — round-trip equality is enough; no need to lock against synthetic byte strings when the property tests already exercise the layout invariants.
    • Multiple top-level struct args — added a roundtrip_args/2 helper alongside the single-arg roundtrip/2. New property mirrors the Balancer V2 swap(SingleSwap, FundManagement, uint256, uint256) shape: two sibling structs of differing dynamic-rate (one mixed static+dynamic, one static-only) plus two scalar args at the ends. Exercises sibling-tuple offset arithmetic when adjacent top-level tuples are dynamic at different rates — roundtrip/2 always wrapped a single arg as [%{type: type}], so this layout was never exercised by the property suite.
    • Deep struct nesting (depth ≥ 4) round-trip — the recursive composite property's type_and_value_gen(3) cap was bumped to depth 5 (Pendle swapExactTokenForPt exercises depth 5 in real calldata). @tag timeout: 120_000 → 300_000 to absorb the larger generation surface; max_runs: 50 (down from the default 100) keeps CI bounded — depth-5 trees can balloon (lists of length 4 of strings up to 64 chars at multiple nesting levels), and 50 deep samples have higher information-density than 100 shallow ones.
    • Bug fix surfaced by the bundle: ABI.TypeDecoder previously called nul_terminate_string/1 on every decoded :string, splitting the binary at the first <<0>> byte and returning only the prefix. This treated Solidity strings as C strings — wrong. Per the Solidity ABI spec, strings are length-prefixed UTF-8 and may legally contain NUL codepoints (U+0000); the length is exact, so right-padded zeros after the data are the only NULs that need stripping (and decode_bytes/3 → Math.unpad/3 already handled that). Removed the nul_terminate_string/1 helper entirely; the :string decode clause now delegates straight to decode_bytes(rest, length, :right). Pre-existing in upstream exthereum/abi since 2018 (commit bdceb719 by Levi Aul) — undetected because random StreamData.string(:utf8, ...) rarely starts with NUL, and most real Solidity strings (function names, error messages, event signatures) don't either. The mixed-element tuple[] property happened to generate a NUL-prefixed string and surfaced it. Three regression unit tests added: leading-NUL string, embedded-NUL string, and all-NULs string. Production paths affected: ABI.decode/3, ABI.decode_call/3, ABI.decode_event/4, ABI.TypeDecoder.decode/3 — anywhere a :string is decoded. Should be filed upstream alongside #53/#54/#55 (or batched with the lexer x sub-bug) — affects every exthereum/abi consumer that decodes user-supplied strings.
  • DeFi Real-World Fixtures bundle. Tests-only addition; no production code touched. Closes both members of the bundle in a single commit.

    • test/abi/defi_calldata_test.exs — 10 round-trip golden vectors captured from defi-skills build --action <name> --json (defi-skills v0.3.0). Fixtures live inline as @fixtures (the originally-proposed test/fixtures/defi_calldata.exs shape was discarded — no .exs data-loading idiom exists in the repo and inline matches the existing test convention). Each fixture asserts both directions: ABI.encode(sig, args) reproduces the locked calldata byte-for-byte and ABI.decode_call(sig, calldata) recovers the original args. Coverage: Aave V3 supply/borrow/setCollateral, Compound V3 supply/claim, Lido stake/unstake (the only fixture exercising uint256[] head/tail layout), EigenLayer deposit, ERC-20 transfer, WETH unwrap. The aave_supply fixture reproduces the calldata locked in the ROADMAP planting note byte-for-byte.
    • test/abi/function_selector_real_world_test.exs — 12 explicit ABI.method_id/1 golden-vector assertions: ERC-20 (transfer, transferFrom), Aave V3 (supply, borrow), WETH/Curve gauge (withdraw(uint256) — duplicate selector by design), WETH/Rocket Pool (deposit() — duplicate by design), Lido (requestWithdrawals(uint256[],address)), Compound V3 (claim), Curve 3pool (add_liquidity(uint256[3],uint256) — fixed-size-array path), Uniswap V3 (exactInputSingle(tuple) — single tuple arg), Balancer V2 (swap — multiple top-level tuples), and EigenLayer (queueWithdrawals(tuple[]) — dynamic tuple[]). A second describe block round-trips the four tuple/tuple[]/fixed-array signatures through FunctionSelector.decode/1 ∘ encode/1 and re-asserts the selector — the 4 corner-case canonical-signature shapes were the entire reason the selector vectors are scoped this widely.
    • Why ship this. Before this bundle, hieroglyph had a single mainnet-style fixture in the entire test suite (the ERC-20 Transfer event doctest in lib/abi.ex). Every encoder/decoder change was verified only against synthetic property-suite shapes. Real-world contract evidence now lives next to the synthetic suite, giving cartouche / onchain CI a stable contract-stability surface across hieroglyph version bumps. The 4 tuple-bearing selectors specifically prove the canonical-signature serialization in function_selector.ex matches what real chains expect.

Agent Economy

  • Agent Economy — Phase 1: Descripex on ABI top-level. Annotated the seven public functions (encode/2, method_id/1, decode/3, decode_call/3, decode_event/4, event_signature/1, parse_specification/1) with api() declarations under namespace /abi. Six of the seven take a polymorphic first arg (binary() | FunctionSelector.t()); the union is described in prose per the established mpp/mcp payment_required_error/1 precedent (one api() block per function name; the formal union lives on @spec). ABI now uses Descripex.Discoverable with a single-module list — Phase 2 expands the list to all six annotated modules. Existing @doc blocks (with their doctests) preserved by ordering: api() first emits its generated @doc, then the manual @doc """...""" overrides slot 4 prose while @doc hints: survives via descripex's __before_compile__ ETS injection. @spec and runtime behaviour unchanged. Manifest emission via mix descripex.manifest --app hieroglyph already works (returns 7 ABI entries plus the four Discoverable bookkeeping exports); the dedicated mix hieroglyph.manifest task lands in Phase 3. Doctor docs/specs coverage held at 100/100, dialyzer 0 warnings, credo --strict 0 issues. New runtime dep: {:descripex, "~> 0.6"} (transitively pulls :json_spec). Version bumped from 1.1.x1.2.0 because adding a runtime dep changes downstream consumers' (cartouche, onchain) dependency closure.

  • Agent Economy — Phase 2: Descripex on the remaining five modules. Annotated all 18 documented public functions across ABI.Event (/selector — 3 fns), ABI.FunctionSelector (/selector — 5 fns; the three @doc false internals dynamic?/1, get_function_type/1, get_state_mutability/1 deliberately excluded), ABI.TypeEncoder (/codec — 2 fns; encode_bytes/1 @doc false excluded), ABI.TypeDecoder (/codec — 4 fns), and ABI.Math (/math — 4 fns). None of these 18 functions are polymorphic, so each api() block follows the standard shape — name, one-sentence description, params: keyword list, returns: map. composes_with: links wired across the natural pairings: Event.decode_event ↔ event_signature, FunctionSelector.decode ↔ encode, TypeEncoder.encode ↔ encode_raw, TypeDecoder.decode ↔ decode_raw. Extended use Descripex.Discoverable, modules: [...] in lib/abi.ex from [ABI] to all six annotated modules. Manifest now emits the full 25 user-declared api() entries (7 + 3 + 5 + 2 + 4 + 4) plus the 4 framework Discoverable exports — verified via mix descripex.manifest --app hieroglyph and per-module entry counts. @doc/doctest preservation pattern from Phase 1 carries through: existing @doc """...""" blocks override slot 4 prose; @doc hints: survives. No behaviour change, no @spec change.
  • Agent Economy — Phase 3: dedicated manifest task + hint-rot validation test. New mix task mix hieroglyph.manifest [path] (defaults to api_manifest.json) emits the JSON manifest using ABI.__descripex_modules__/0 as the single source of truth — direct port of the established mix mpp.manifest shape. Manifest is suitable for downstream cartouche/onchain CI to diff across hieroglyph version bumps as a contract-stability artifact. New test file test/abi/agent_economy_test.exs enforces the agent-discovery surface in three describe blocks plus a load-bearing cross-check: (1) every entry in each module's __api__/0 carries :hints.description; (2) ABI.describe/0..2 returns the expected modules / function lists / per-function detail; (3) namespaces (/abi, /selector, /codec, /math) match per-module assignments via Code.fetch_docs/1. The cross-check walks module_info(:exports), strips Elixir/Descripex framework exports, plus an explicit allow-list of the four @doc false internal helpers (FunctionSelector.dynamic?/1, get_function_type/1, get_state_mutability/1, TypeEncoder.encode_bytes/1), then asserts every remaining export is declared with api() — without this gate, hints rot silently when new defs land without api(), and silent rot here propagates as silent contract drift through cartouche-generated bindings into every onchain_<protocol> package.
  • Bug fix (sub-bug of upstream #54, upstream filing deferred — will be batched into a future combined-bugs issue with PR offer): ABI.FunctionSelector.decode_type("fixed128x18") and decode_type("ufixed256x80") (and the same-shape forms inside arrays/tuples/function signatures) raised a leaky FunctionClauseError instead of the friendly ArgumentError that bare fixed/ufixed already produced. Root cause was lexer rule ordering in src/ethereum_abi_lexer.xrl: the LETTERS rule ([a-zA-Z_]+) was listed before the standalone 'x' terminal, so leex picked LETTERS on equal-length matches and the single x between the two integers tokenized as letters. The grammar rule type -> typename digits 'x' digits never fired, the parser fell through to juxt_type(fixed, 128), and juxt_type/2 had no fixed clause. Fix: introduced dedicated fixed_typename / ufixed_typename terminals (so the 'x' separator is only valid in fixed/ufixed contexts), moved the 'x' rule above {LETTERS}, and extended the parser's identifier_part to also accept 'x', fixed_typename, and ufixed_typename so single-char x and the keyword forms still work as function/argument names. The explicit-M/N forms now route through ABI.Parser.reject_unsupported!/1 and raise the same friendly ArgumentError (with the upstream-#54 link) that the bare forms already produced. Yecc reports 3 shift/reduce conflicts (was 1) — the 2 new ones come from fixed_typename/ufixed_typename being able to start either a type or an identifier_part; yecc's default shift resolution is the desired behavior (fixed128 is a type prefix, not an identifier), and is documented inline in the .yrl Expect 3. comment. Regression tests added: explicit-M/N rejection (fixed128x18, ufixed256x80) plus x-keyword identifier handling (function named x, argument named x, function named fixed/ufixed, and a function name containing x mid-string).

1.1.0 - 2026-05-01

  • Bug fix (upstream #55): ABI.TypeEncoder.encode_int/2 rejected ALL int<N> values (including 0) for small bit widths, because the overflow guard mixed up bytes and bits — it compared byte_size(significant_bytes) against desired_size_bytes - 1, which evaluates to 0 for int8, raising on every input. Replaced with a numeric range check against 2^(N-1) performed up-front, so the encoder accepts the full signed range -2^(N-1)..2^(N-1)-1 for every int<N>. The pre-existing "int overflow raises data overflow" test passed only because the encoder was broken for any value; tightened the test to assert specific in-range values encode AND specific boundary cases (128, -129) raise.
  • Bug fix: ABI.FunctionSelector.dynamic?/1 raised FunctionClauseError on {:array, T, 0} (zero-length fixed array). The grammar accepts T[0] (yrl rule allows N >= 0), so the type is parseable, but the existing clauses required len > 0 and no clause matched the zero case. Added def dynamic?({:array, _type, 0}), do: false — a zero-length fixed array has no head/tail layout and no payload, so it is static by any sensible definition. Encoder and decoder paths already handle zero-length arrays (encode_type({:array, _, 0}) produces an empty repeated-type tuple; decode_type({:array, _, 0}, data, _opts) -> {[], data}); verified by extending roundtrip_property_test.exs's fixed-array length domain from 1..3 to 0..3. Pre-existing in upstream exthereum/abi; not yet filed.
  • ABI.FunctionSelector.@type type now carries a @typedoc clarifying that address payable collapses to :address. Solidity's ABI JSON only emits "address" for both forms, the on-the-wire encoding is identical (20-byte left-padded), and payability is a property of state_mutability rather than a separate type variant — so consumers shouldn't expect a distinct atom.
  • Added test/abi/roundtrip_property_test.exs — property-based decode(encode(x)) == x coverage using stream_data for every type in ABI.FunctionSelector.@type type/0: uint, int, address, bool, string, bytes, bytesN, fixed and dynamic arrays, and recursively nested tuples (depth ≤ 3). Per-type properties localize failures to a single clause; the recursive composite property exercises nested {:tuple, [{:array, ...}]} shapes where head/tail offsets matter. Surfaced the encode_int bug above on its first run. Test-only dep {:stream_data, "~> 1.1"} added.
  • Test coverage for the empty-args calldata path (f() shape — 4-byte selector with zero ABI-encoded args). weth.deposit() (selector 0xd0e30db0), rocket_pool.deposit(), and similar zero-arg calls were untested by the round-trip property suite (every generator produced at least one value). Pinned both directions: ABI.encode("deposit()", []) == <<0xD0, 0xE3, 0x0D, 0xB0>> and ABI.decode("deposit()", <<>>) == [], plus the function: nil/types: [] empty-bytes shape.
  • Test coverage for FunctionSelector selector-rendering and parser edge cases: encode/1 canonical-signature rendering of {:int, N}, {:struct, _, _, _}, dead-via-parse types (:function, {:fixed, M, N}, {:ufixed, M, N}) and nil-typed slots (defensive against partially-built typeinfo maps); plus parse_specification/1's %{"indexed" => _}-without-"name" branch (older Solidity versions and hand-written ABIs may omit names on indexed event params).
  • New API: ABI.method_id/1 returns the 4-byte function selector (keccak256(canonical_signature)[0..3]) for a signature string or FunctionSelector struct. Returns <<>> for selectors with function: nil. Previously the same logic was private to ABI.TypeEncoder; exposing it is a useful primitive for callers that need to compute selectors without encoding args (selector-table routing, log-topic matching, calldata pre-validation).
  • New API: ABI.decode_call/3 is the symmetric counterpart to ABI.encode/2 for selector-prefixed calldata. Splits the 4-byte prefix, verifies it matches the expected selector, and decodes the payload via the existing decode/3 machinery. Returns {:ok, decoded} on match or {:error, reason} for :calldata_too_short (< 4 bytes), :selector_mismatch (prefix wrong), or :no_function_name (selector has function: nil, so there's nothing to verify against — caller should use decode/3 with the payload). ABI.decode/3 semantics are unchanged: it remains payload-only, matching eth-abi / ethers / viem / alloy conventions.

1.0.0 - 2026-04-24

First hex.pm release as hieroglyph. This is a maintained fork of exthereum/abi; the module namespace is unchanged (ABI.encode/2, ABI.decode/2, etc. — consumers just swap the hex dep name). Version resets to 1.0.0 under the new package name; the 1.0.0-alpha* / 1.0.0-bravo1 lines below this entry are the upstream's pre-release history, carried forward for context but never published to hex under hieroglyph.

  • Published as hieroglyph — hex package renamed from the internal abi app name. Top-level module ABI preserved (the Solidity term is the correct module name). Repo renamed to ZenHive/hieroglyph; upstream exthereum/abi tracked in the package's "Upstream (fork-of)" link.
  • Bug fix (upstream #53): ABI.Event.decode_event/4 now returns {:indexed_hash, <<32 bytes>>} for indexed parameters of reference type (string, bytes, all arrays — fixed-size or dynamic — and tuples/structs) instead of silently misdecoding the keccak topic as if it were a raw ABI-encoded value. Per the Solidity ABI spec, indexed reference-type values are stored in topics as keccak256(value) and the original is unrecoverable — the tagged tuple preserves the hash (useful for log filtering and equality checks) and makes the "unrecoverable" signal pattern-matchable. This is broader than the ABI head/tail "dynamic" rule: uint256[2] and tuples of all-static members are static for regular ABI encoding but are still hashed in event topics, and this fix handles both. Breaking for callers that consumed the previous garbage bytes directly; static value-type indexed params (uint/int/address/bool/bytesN/function/fixed/ufixed) are unchanged. Regression tests added for indexed string, indexed bytes, indexed dynamic array, indexed fixed-size static array (uint256[2]), indexed tuple of static members, and mixed static+dynamic+non-indexed cases.
  • Bug fix (upstream #54): fixed, ufixed, and function types now raise ArgumentError at parse time (in ABI.Parser.parse!/2, walking nested arrays and tuples) with a link to the tracking issue, instead of parsing silently into unsupported internal terms and later raising the cryptic "Unsupported encoding type" inside TypeEncoder / TypeDecoder. Also filled the {:bytes, pos_integer()} gap in ABI.FunctionSelector.@type type — previously omitted even though fully supported by the encoder and decoder. Note: the explicit fixed<M>x<N> / ufixed<M>x<N> forms still raise a FunctionClauseError upstream of this walker due to a separate pre-existing lexer-rule-ordering bug (single x tokenizes as letters instead of the 'x' terminal) — tracked as a follow-up task. Also aligned the grammar's bare-fixed / bare-ufixed canonical expansion to the Solidity spec (fixed128x18 / ufixed128x18; previously x19) so the rejection error message reports the correct form.
  • Simplified ABI.Parser.parse!/2's unsupported-type walker to drop a dead is_list(returns) branch — the yecc grammar only emits nil or a single bare type for returns, never a list. No behavior change.
  • Extracted the 32-byte padding logic into ABI.Math.pad/4 and ABI.Math.unpad/3. ABI.TypeEncoder.encode_bytes/1, encode_int/2, and encode_uint/2 now delegate to ABI.Math.pad/4; ABI.TypeDecoder.decode_bytes/3 is a thin wrapper around ABI.Math.unpad/3. No behavior change; resolves the long-standing TODO: add to ABI.Math comments in both modules.
  • Renamed ABI.FunctionSelector.is_dynamic?/1 to ABI.FunctionSelector.dynamic?/1 to satisfy Credo.Check.Readability.PredicateFunctionNames. The function remains @doc false (internal). No deprecation shim — the old name had @doc false since 2017 and zero in-repo references outside three private call-sites, which were updated.
  • Drove mix credo --strict to zero violations (was 51). Covers Design.AliasUsage (top-of-module aliases added across ABI, ABI.Event, ABI.FunctionSelector, ABI.Parser, ABI.TypeDecoder, ABI.TypeEncoder, and ABI.Hex), Readability.MaxLineLength (spec / docstring wraps + the big Enum.reduce tuple-encoder broken into an encode_tuple_element/2 helper), Consistency.ParameterPatternMatching (flipped three record = %{…} heads to %{…} = record), and Refactor.Nesting (extracted ABI.Event.verify_event_signature/2 and ABI.TypeEncoder.fetch_named_field/2 + fetch_by_name/2 helpers to drop nesting below 3).
  • Added regression tests for the map-input encoder path (ABI.TypeEncoder.data_to_list/2): atom-keyed maps, string-keyed maps, camelCase→snake_case name resolution, string-over-atom key priority, integer values inside nested named-struct maps, and the missing-field / unnamed-type error raises. The map branch previously had zero test coverage; the string-key path was added in commit 46accc8, and this suite also exercises integer encoding (a43e9d5) through the map branch.
  • Added @spec typespecs and @doc strings for every previously-undeclared public function across ABI, ABI.Event, ABI.TypeDecoder, ABI.TypeEncoder, and ABI.FunctionSelector: ABI.event_signature/1, ABI.parse_specification/1, ABI.TypeDecoder.decode/3, ABI.TypeDecoder.tuple_value/3, ABI.TypeEncoder.encode_raw/2, ABI.Event.decode_event/4 / event_signature/1 / canonical/2, and ABI.FunctionSelector.decode/1 / decode_raw/1 / parse_specification_item/1 / decode_type/1 / encode/3; also added docs for TypeDecoder.tuple_value/3 and TypeDecoder.decode_bytes/3. Matches the style widened in PR #52. Doctor spec coverage 42% → 88%, doc coverage 88% → 96%.
  • Added regression tests for eleven previously-uncovered error paths: bool with non-boolean values, bytes<N> size mismatches and wrong-datatype values, unsupported type atoms across encoder / decoder / function-selector, int/uint overflow, trailing decode data, and decode_event/4 returns for mismatched event signatures and invalid topic counts.
  • README refreshed: dropped the stale "tuples with multiple elements don't parse" caveat (false since JSON-ABI support), corrected ABI.encode/2 arity and flipped bytes<M> to supported in the Support checklist, migrated dead solidity.readthedocs.io links to docs.soliditylang.org, and added runnable examples for ABI.parse_specification/1, ABI.Event.decode_event/4, and map/struct input to encode/2.

1.0.0-bravo1

  • Fix ABI tuple encoding for nested inlined tuples

1.0.0-alpha9

  • Add Names to Event Signatures

1.0.0-alpha8

  • Add Event Signature check to ABI.Event.decode_event
  • Change decode_event to return an {:ok, event_name, event_params} tuple.
  • Add ability to add "indexed" keyword to ABI canonicals

1.0.0-alpha7

  • Bugfix for event decoding with dynamic parameters

1.0.0-alpha6

  • Bugfix for is_dynamic

0.1.15

  • Properly treat all function encodes as tuple encodings

0.1.14

  • Fix 0-length type[] encoding

0.1.13

  • Drop dependency on exth crypto and move in functionality

0.1.12

  • Fix string decoding to truncate on encountering NUL
  • Fix some edge-cases in tuple encoding/decoding

0.1.11

  • Add support for method ID calculation of all standard types

0.1.10

  • Fix parsing of function names containing uppercase letters/digits/underscores
  • Add support for bytes<M>

0.1.9

  • Add support for parsing ABI specification documents (.abi.json files)
  • Reimplement function signature parsing using a BNF grammar
  • Fix potential stack overflow during encoding/decoding

0.1.8

  • Fix ordering of elements in tuples

0.1.7

  • Fix support for arrays of uint types

0.1.6

  • Add public interface to raw function versions.

0.1.5

  • Bugfix so that addresses are still left padded.

0.1.4

  • Bugfix for tuples to properly handle tail pointer poisition.

0.1.3

  • Bugfix for tuples to properly handle head/tail encoding

0.1.2

  • Add support for tuples, fixed-length and variable length arrays