1.4.0 - 2026-05-01
Atom-creation hardening on decode_structs: true path
- Security hardening —
String.to_atom/1removed from both atom-creation call sites inlib/abi/type_decoder.ex(tuple_value/3) andlib/abi/type_encoder.ex(fetch_by_name/2). Both call sites previously created atoms from contract-supplied field names (gated bysobelow_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 throughString.to_existing_atom/1. Thesobelow_skipannotations are removed. - Decoder — observable contract change on the opt-in path.
ABI.TypeDecoder.tuple_value/3(called wheneverdecode/3/decode_call/3/decode_event/4runs withdecode_structs: true) now requires every snake_case field atom to already exist in the VM atom table. New private helperatom_key_for!/1callsString.to_existing_atom/1onMacro.underscore(name)and re-raisesArgumentErrorwith 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} = decodedor 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. Thedecode_structs: truedoctest inABI.decode/3continues to pass because the assertion%{a: 10, b: true}interns:a/:bat module load. - Encoder — silent safety upgrade, no contract change.
ABI.TypeEncoder.fetch_by_name/2's atom is only used as aMaplookup 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 helperexisting_atom/1returns the existing atom ornil; 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 callinginspect/1on a freshly-created atom — strictly clearer. - Tests. Added a
decode_structs: true atom safetydescribe block intest/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 whendecode_structsis false, fall-through to tuple when any field name is empty. Added two encoder tests intest/abi/type_encoder_test.exsexercising the newexisting_atom/1 → nilbranch (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 acrosslib/,test/, andsrc/. Test count 339 → 345; coverage onABI.TypeDecoder98.21% → 98.39% andABI.TypeEncoder99.28% → 99.30%. - Manifest.
api(:decode, ...)'soptsdescription now documents the pre-intern contract;returns.typecorrected from:list(was inaccurate) to:union.tuple_value/3's@docrewritten to lead with the atom-table contract. Manifest entry count unchanged (32). Content diffs in the regeneratedmix hieroglyph.manifestartifact: those twoapi(:decode, ...)description fields, thetuple_value/3@docbody, and thegenerated_attimestamp; in addition, the JSON output reflects a globalsignature/speckey-order swap (descripex emission ordering — content unchanged, key set unchanged). - No benchee dep added.
String.to_existing_atom/1is the same hash-table lookup asString.to_atom/1on 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: trueagainst 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 narroweddecode_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/abiwill be filed describing the DoS surface and proposing either the sameto_existing_atomswitch or adecode_structs: :existing_atomsopt-in for back-compat — maintainers' choice.
1.3.0 - 2026-05-01
function ABI type — encode + decode + packed
- New type support — Solidity
functionABI type. Lifts the parse-time rejection that previously raisedArgumentErroron any signature mentioningfunction(per upstream issue #54). Solidity'sfunctiontype 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 tobytes24), 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 containingfunction) work via the existing recursive composition — no new array/tuple plumbing needed. - Production code touched. Removed the
:functionclause fromABI.Parser.reject_unsupported!/1(parse-time gate); added:functiontoABI.FunctionSelector.@type typeand adynamic?(:function), do: falseclause; addedencode_type(:function, _)clauses toABI.TypeEncoder(binary-only input — wrong-size and non-binary inputs raiseArgumentErrorwith a payload-shape hint); added a matchingpacked_top(:function, _)pair soencode_packed/2acceptsfunction(24 bytes tight per spec) instead of falling into the unsupported-type catch-all; addeddecode_type(:function, _, _)toABI.TypeDecoderdelegating todecode_bytes/3with right-pad.encode_bytes/1anddecode_bytes/3already do the 24→32 byte rounding viaMath.pad/4andMath.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 alwaysaddr <> selif they have parts separately. Decode returns the raw 24-byte binary (symmetric with encode); caller pattern-matches<<addr::binary-20, sel::binary-4>> = decodedif 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
:functionparse-rejection tests intest/abi/function_selector_test.exsto positive-acceptance assertions in a new "functiontype acceptance" describe block; renamed/split the canonical-signature describe block since:functionis no longer dead-via-parse. Added:functionto the@leaf_typeslist andvalue_for/1dispatcher intest/abi/roundtrip_property_test.exs, plus a dedicated property — the recursive composite property at depth ≤ 5 now exercisesfunction[],function[N], and(uint256, function)automatically. Added focused unit tests intest/abi/type_encoder_test.exs(slot layout, wrong-size raise, non-binary raise) andtest/abi/type_decoder_test.exs(right-pad strip, round-trips inside(uint256, function, bool),function[3], andfunction[]). Added afunction: 24 bytes tightgolden vector + size-mismatch raise totest/abi/encode_packed_test.exs. - Manifest. No public-API surface change —
dynamic?/1arity is unchanged and the newfunction-handling lives entirely in private clauses.mix hieroglyph.manifestre-emits anapi_manifest.jsonwith identical content vs. 1.2.0 (the only diffs are the regeneratedgenerated_attimestamp 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/4error contract narrowed. Thedecode_event/4@specpreviously 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 thedecode_raw-driven payload-decode path in atry/rescueand converted raised exceptions into{:error, {:malformed_data, message}}.verify_event_signature/2already 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/0enumerating the closed error set ({:event_signature_mismatch, _},{:topics_length_mismatch, _},{:malformed_data, _}); narrowedABI.decode_event/4's@specfrom{:error, term()}to{:error, Event.decode_error()}. Theapi()declaration now carries anerrors:block mirroringdecode_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 arounddecode_event/4should switch to matching{:error, {:malformed_data, _}}.Public Surface Pass bundle —
encode_bytes/1flipped fromdeftodefp. Hygiene-only flip. Already@doc false(lib/abi/typeencoder.ex), zero callers outsidetype_encoder.exitself 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 inrevert_dataagainst a list of known error definitions (signature strings or pre-parsedFunctionSelectorstructs, 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<4bytes. Mirrorsdecode_call/3's contract: malformed payload after a selector match still raises (same behavior asdecode/3). The first definition with a matching selector wins — definition order is the disambiguation lever. ReusesABI.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 todecode_call/3modulo the multi-definition list.New API —
ABI.encode_packed/2. Solidity's non-standard packed encoding — used for Merkle airdrop leaves andkeccak256(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 forstring/bytes) so element boundaries are recoverable; tuples/structs and nested arrays are explicitly unsupported and raiseArgumentErrorwith a spec link. The wrapper accepts the same polymorphic first arg asencode/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 toencode_type/2inlib/abi/type_encoder.exwith privatepacked_top/2,packed_array/2,pack_uint/2,pack_int/2helpers — does NOT thread throughMath.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 throughencode_type/2to 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.exsplus the resulting bug fix inlib/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-elementtuple[](exercises the array-of-static-tuples layout), a mixed-elementtuple[]where each element is itself dynamic (the most stress-testing shape — head/tail offsets are computed both per-array AND per-element), and an emptytuple[]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, emptytuple[]as the only dynamic field in a struct, and emptytuple[]followed by non-empty bytes. Inlinetest "..." doblocks rather than the@fixturespattern fromdefi_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/2helper alongside the single-argroundtrip/2. New property mirrors the Balancer V2swap(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/2always 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
compositeproperty'stype_and_value_gen(3)cap was bumped to depth 5 (PendleswapExactTokenForPtexercises depth 5 in real calldata).@tag timeout: 120_000 → 300_000to 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.TypeDecoderpreviously callednul_terminate_string/1on 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 (anddecode_bytes/3 → Math.unpad/3already handled that). Removed thenul_terminate_string/1helper entirely; the:stringdecode clause now delegates straight todecode_bytes(rest, length, :right). Pre-existing in upstreamexthereum/abisince 2018 (commitbdceb719by Levi Aul) — undetected because randomStreamData.string(:utf8, ...)rarely starts with NUL, and most real Solidity strings (function names, error messages, event signatures) don't either. The mixed-elementtuple[]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:stringis decoded. Should be filed upstream alongside #53/#54/#55 (or batched with the lexerxsub-bug) — affects everyexthereum/abiconsumer 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 fromdefi-skills build --action <name> --json(defi-skills v0.3.0). Fixtures live inline as@fixtures(the originally-proposedtest/fixtures/defi_calldata.exsshape was discarded — no.exsdata-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 andABI.decode_call(sig, calldata)recovers the original args. Coverage: Aave V3 supply/borrow/setCollateral, Compound V3 supply/claim, Lido stake/unstake (the only fixture exercisinguint256[]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 explicitABI.method_id/1golden-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[])— dynamictuple[]). A seconddescribeblock round-trips the four tuple/tuple[]/fixed-array signatures throughFunctionSelector.decode/1 ∘ encode/1and 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,
hieroglyphhad a single mainnet-style fixture in the entire test suite (the ERC-20Transferevent doctest inlib/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 infunction_selector.exmatches what real chains expect.
Agent Economy
Agent Economy — Phase 1: Descripex on
ABItop-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) withapi()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/mcppayment_required_error/1precedent (oneapi()block per function name; the formal union lives on@spec).ABInowusesDescripex.Discoverablewith a single-module list — Phase 2 expands the list to all six annotated modules. Existing@docblocks (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.@specand runtime behaviour unchanged. Manifest emission viamix descripex.manifest --app hieroglyphalready works (returns 7 ABI entries plus the four Discoverable bookkeeping exports); the dedicatedmix hieroglyph.manifesttask lands in Phase 3. Doctor docs/specs coverage held at 100/100, dialyzer 0 warnings, credo--strict0 issues. New runtime dep:{:descripex, "~> 0.6"}(transitively pulls:json_spec). Version bumped from1.1.x→1.2.0because 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 falseinternalsdynamic?/1,get_function_type/1,get_state_mutability/1deliberately excluded),ABI.TypeEncoder(/codec— 2 fns;encode_bytes/1@doc falseexcluded),ABI.TypeDecoder(/codec— 4 fns), andABI.Math(/math— 4 fns). None of these 18 functions are polymorphic, so eachapi()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. Extendeduse Descripex.Discoverable, modules: [...]inlib/abi.exfrom[ABI]to all six annotated modules. Manifest now emits the full 25 user-declaredapi()entries (7 + 3 + 5 + 2 + 4 + 4) plus the 4 frameworkDiscoverableexports — verified viamix descripex.manifest --app hieroglyphand 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@specchange. - Agent Economy — Phase 3: dedicated manifest task + hint-rot validation test. New mix task
mix hieroglyph.manifest [path](defaults toapi_manifest.json) emits the JSON manifest usingABI.__descripex_modules__/0as the single source of truth — direct port of the establishedmix mpp.manifestshape. Manifest is suitable for downstream cartouche/onchain CI to diff across hieroglyph version bumps as a contract-stability artifact. New test filetest/abi/agent_economy_test.exsenforces the agent-discovery surface in three describe blocks plus a load-bearing cross-check: (1) every entry in each module's__api__/0carries:hints.description; (2)ABI.describe/0..2returns the expected modules / function lists / per-function detail; (3) namespaces (/abi,/selector,/codec,/math) match per-module assignments viaCode.fetch_docs/1. The cross-check walksmodule_info(:exports), strips Elixir/Descripex framework exports, plus an explicit allow-list of the four@doc falseinternal helpers (FunctionSelector.dynamic?/1,get_function_type/1,get_state_mutability/1,TypeEncoder.encode_bytes/1), then asserts every remaining export is declared withapi()— without this gate, hints rot silently when newdefs land withoutapi(), 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")anddecode_type("ufixed256x80")(and the same-shape forms inside arrays/tuples/function signatures) raised a leakyFunctionClauseErrorinstead of the friendlyArgumentErrorthat barefixed/ufixedalready produced. Root cause was lexer rule ordering insrc/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 singlexbetween the two integers tokenized asletters. The grammar ruletype -> typename digits 'x' digitsnever fired, the parser fell through tojuxt_type(fixed, 128), andjuxt_type/2had nofixedclause. Fix: introduced dedicatedfixed_typename/ufixed_typenameterminals (so the'x'separator is only valid infixed/ufixedcontexts), moved the'x'rule above{LETTERS}, and extended the parser'sidentifier_partto also accept'x',fixed_typename, andufixed_typenameso single-charxand the keyword forms still work as function/argument names. The explicit-M/N forms now route throughABI.Parser.reject_unsupported!/1and raise the same friendlyArgumentError(with the upstream-#54 link) that the bare forms already produced. Yecc reports 3 shift/reduce conflicts (was 1) — the 2 new ones come fromfixed_typename/ufixed_typenamebeing able to start either atypeor anidentifier_part; yecc's default shift resolution is the desired behavior (fixed128is a type prefix, not an identifier), and is documented inline in the.yrlExpect 3.comment. Regression tests added: explicit-M/N rejection (fixed128x18,ufixed256x80) plusx-keyword identifier handling (function namedx, argument namedx, function namedfixed/ufixed, and a function name containingxmid-string).
1.1.0 - 2026-05-01
- Bug fix (upstream #55):
ABI.TypeEncoder.encode_int/2rejected ALLint<N>values (including0) for small bit widths, because the overflow guard mixed up bytes and bits — it comparedbyte_size(significant_bytes)againstdesired_size_bytes - 1, which evaluates to0forint8, raising on every input. Replaced with a numeric range check against2^(N-1)performed up-front, so the encoder accepts the full signed range-2^(N-1)..2^(N-1)-1for everyint<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?/1raisedFunctionClauseErroron{:array, T, 0}(zero-length fixed array). The grammar acceptsT[0](yrl rule allowsN >= 0), so the type is parseable, but the existing clauses requiredlen > 0and no clause matched the zero case. Addeddef 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 extendingroundtrip_property_test.exs's fixed-array length domain from1..3to0..3. Pre-existing in upstreamexthereum/abi; not yet filed. ABI.FunctionSelector.@type typenow carries a@typedocclarifying thataddress payablecollapses 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 ofstate_mutabilityrather than a separate type variant — so consumers shouldn't expect a distinct atom.- Added
test/abi/roundtrip_property_test.exs— property-baseddecode(encode(x)) == xcoverage usingstream_datafor every type inABI.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 theencode_intbug 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()(selector0xd0e30db0),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>>andABI.decode("deposit()", <<>>) == [], plus thefunction: nil/types: []empty-bytes shape. - Test coverage for
FunctionSelectorselector-rendering and parser edge cases:encode/1canonical-signature rendering of{:int, N},{:struct, _, _, _}, dead-via-parse types (:function,{:fixed, M, N},{:ufixed, M, N}) andnil-typed slots (defensive against partially-built typeinfo maps); plusparse_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/1returns the 4-byte function selector (keccak256(canonical_signature)[0..3]) for a signature string orFunctionSelectorstruct. Returns<<>>for selectors withfunction: nil. Previously the same logic was private toABI.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/3is the symmetric counterpart toABI.encode/2for selector-prefixed calldata. Splits the 4-byte prefix, verifies it matches the expected selector, and decodes the payload via the existingdecode/3machinery. Returns{:ok, decoded}on match or{:error, reason}for:calldata_too_short(< 4 bytes),:selector_mismatch(prefix wrong), or:no_function_name(selector hasfunction: nil, so there's nothing to verify against — caller should usedecode/3with the payload).ABI.decode/3semantics are unchanged: it remains payload-only, matchingeth-abi/ethers/viem/alloyconventions.
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 internalabiapp name. Top-level moduleABIpreserved (the Solidity term is the correct module name). Repo renamed toZenHive/hieroglyph; upstreamexthereum/abitracked in the package's "Upstream (fork-of)" link. - Bug fix (upstream #53):
ABI.Event.decode_event/4now 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 askeccak256(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 indexedstring, indexedbytes, 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, andfunctiontypes now raiseArgumentErrorat parse time (inABI.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"insideTypeEncoder/TypeDecoder. Also filled the{:bytes, pos_integer()}gap inABI.FunctionSelector.@type type— previously omitted even though fully supported by the encoder and decoder. Note: the explicitfixed<M>x<N>/ufixed<M>x<N>forms still raise aFunctionClauseErrorupstream of this walker due to a separate pre-existing lexer-rule-ordering bug (singlextokenizes aslettersinstead of the'x'terminal) — tracked as a follow-up task. Also aligned the grammar's bare-fixed/ bare-ufixedcanonical expansion to the Solidity spec (fixed128x18/ufixed128x18; previouslyx19) so the rejection error message reports the correct form. - Simplified
ABI.Parser.parse!/2's unsupported-type walker to drop a deadis_list(returns)branch — the yecc grammar only emitsnilor a single bare type forreturns, never a list. No behavior change. - Extracted the 32-byte padding logic into
ABI.Math.pad/4andABI.Math.unpad/3.ABI.TypeEncoder.encode_bytes/1,encode_int/2, andencode_uint/2now delegate toABI.Math.pad/4;ABI.TypeDecoder.decode_bytes/3is a thin wrapper aroundABI.Math.unpad/3. No behavior change; resolves the long-standingTODO: add to ABI.Mathcomments in both modules. - Renamed
ABI.FunctionSelector.is_dynamic?/1toABI.FunctionSelector.dynamic?/1to satisfyCredo.Check.Readability.PredicateFunctionNames. The function remains@doc false(internal). No deprecation shim — the old name had@doc falsesince 2017 and zero in-repo references outside three private call-sites, which were updated. - Drove
mix credo --strictto zero violations (was 51). CoversDesign.AliasUsage(top-of-module aliases added acrossABI,ABI.Event,ABI.FunctionSelector,ABI.Parser,ABI.TypeDecoder,ABI.TypeEncoder, andABI.Hex),Readability.MaxLineLength(spec / docstring wraps + the bigEnum.reducetuple-encoder broken into anencode_tuple_element/2helper),Consistency.ParameterPatternMatching(flipped threerecord = %{…}heads to%{…} = record), andRefactor.Nesting(extractedABI.Event.verify_event_signature/2andABI.TypeEncoder.fetch_named_field/2+fetch_by_name/2helpers 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 commit46accc8, and this suite also exercises integer encoding (a43e9d5) through the map branch. - Added
@spectypespecs and@docstrings for every previously-undeclared public function acrossABI,ABI.Event,ABI.TypeDecoder,ABI.TypeEncoder, andABI.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, andABI.FunctionSelector.decode/1/decode_raw/1/parse_specification_item/1/decode_type/1/encode/3; also added docs forTypeDecoder.tuple_value/3andTypeDecoder.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:
boolwith 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, anddecode_event/4returns 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/2arity and flippedbytes<M>to supported in the Support checklist, migrated deadsolidity.readthedocs.iolinks todocs.soliditylang.org, and added runnable examples forABI.parse_specification/1,ABI.Event.decode_event/4, and map/struct input toencode/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_eventto 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
stringdecoding to truncate on encountering NUL - Fix some edge-cases in
tupleencoding/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.jsonfiles) - 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