Skip to content

fix: harden EIP-712 typed data encoding against amplification and malformed types - #214

Draft
patrislav wants to merge 1 commit into
masterfrom
fix/eip712-typed-data-budget
Draft

fix: harden EIP-712 typed data encoding against amplification and malformed types#214
patrislav wants to merge 1 commit into
masterfrom
fix/eip712-typed-data-budget

Conversation

@patrislav

Copy link
Copy Markdown
Member

Follow-up to #210. End-to-end retesting of that fix surfaced a residual value-driven amplification path the schema-only cycle check cannot cover, and auditing the surrounding code turned up a reachable panic and a latent stack overflow.

What's here

1. Memoize EncodeType/TypeHash per Encode call

HashStruct recomputed a type's TypeHash — and therefore EncodeType over its whole dependency DAG — for every array element. A message with an array of custom structs multiplied schema-processing cost by the element count.

A typeInfo cache is now created once per Encode and shared across the entire call tree (domain and message alike), so type-dependent work happens at most once per distinct type. This also fixes a second-order case unrelated to arrays: a diamond-shaped DAG previously re-expanded shared sub-types multiple times within a single EncodeType call.

Measured on a depth-6 diamond struct in an array — scaling is now flat per element (~57µs), i.e. linear in encoded values:

elements encode time
1 64µs
100 5.7ms
500 28ms

Public signatures of EncodeType, TypeHash and HashStruct are unchanged; they delegate to the cached core with a fresh cache.

2. Functional options for schema and value budgets

WithMaxTypes, WithMaxFieldsPerType, WithMaxWalkVisits, WithMaxArrayElements, WithMaxRecursionDepth, WithMaxTotalValues — accepted by ValidateTypeGraph, Encode and EncodeDigest.

The zero value of every option means unlimited, so existing callers are unaffected. Value-driven checks run before allocating or recursing, so an oversized array is rejected up front rather than after the work is done. This consolidates limits that downstream services were otherwise reimplementing on top of ethkit.

3. Fix a reachable panic in typedDataDecodePrimitiveValue

ABIUnmarshalStringValuesAny returns fewer values than requested — with a nil error — for a type token matching none of its branches, silently violating its positional contract. The caller then indexed out[0]:

panic: runtime error: index out of range [0] with length 0
  ethcoder/typed_data_json.go:344

Triggered by input as simple as {"name":"x","type":""} or "type":"foobar", "tuple", "byte", "String", "address ". Any service decoding untrusted typed data without a panic barrier crashes on it.

4. Iterative type-graph DFS + unconditional depth ceiling

ValidateTypeGraph's walk was recursive, one frame per type. An acyclic linear type chain therefore still overflowed the goroutine stack — fatally, not recoverably — at roughly 2M types. Cycle detection did not help; the graph is a valid DAG.

The walk is now an explicit-stack DFS, and type nesting is capped at maxTypeGraphDepth = 1024. The cap is unconditional rather than an Option because UnmarshalJSON validates without options, and it protects the encoders below it (encodeTypeCached, hashStruct, encodeValue), which still recurse one frame per level.

The ceiling is measured from each type's longest downward path, not the live DFS stack: memoization can cut that stack short depending on map iteration order, which would let an over-deep chain through non-deterministically. Verified exact and stable at the 1024/1025 boundary across repeated runs.

5. Reject field types no encoder can handle

ValidateTypeGraph now validates every field type up front instead of letting bad ones fail deep inside the encoders: unknown names, uint0/uint7/uint2560, bytes0/bytes33, bare uint/int (EIP-712 requires the canonical uint256/int256, and the packer cannot size them), and malformed array suffixes such as uint256[, uint256[a], []uint256.

⚠️ Behavior change

Schema validation is stricter than before. A schema declaring a type with an invalid field type previously decoded fine and only failed if that type was actually encoded — it is now rejected at decode.

The existing corpus is unaffected: all real-world payloads in the test suite (including the Seaport cases) pass unchanged, and the typed-data tests only use address, bytes, bytes32, string, uint8/128/256, arrays and custom types.

Testing

  • Full ethcoder suite passes, including under -race.
  • New: TestTypedDataMemoization, TestTypedDataBudgetLimits, TestTypedDataInvalidPrimitiveType, TestTypedDataTypeGraphHardening.
  • Validated against a 20-case malformed/malicious input battery (type confusion, overflow values, wrong-length bytesN, null values, nested arrays, scalar/array/struct mismatches) — all return errors, none panic.
  • Depth ceiling re-verified across repeated runs to confirm it does not depend on map iteration order.

Known issues left alone (pre-existing, out of scope)

  • A negative value for a uintN field encodes as its absolute value with no error, because the 128/256 path builds a big.Int via SetString without a sign or range check (the 8/16/32/64 paths do check, via ParseUint).
  • A malformed chainId such as "0xzz" silently parses to 0UnmarshalJSON ignores SetString's ok bool.
  • EncodeType/TypeHash/HashStruct called directly still skip ValidateTypeGraph, so a cyclic graph recurses without bound. Encode/EncodeDigest and the JSON decode path all validate first.

Happy to fold any of those into this PR or file them separately.

🤖 Generated with Claude Code

…formed types

Follow-up to #210. Retesting surfaced a value-driven amplification path that the
schema-only cycle check does not cover, plus a panic reachable from any caller
that decodes untrusted typed data.

- Memoize EncodeType/TypeHash per Encode call. HashStruct previously recomputed
  a type's hash for every array element, so a message holding an array of custom
  structs multiplied schema-processing cost by the element count. The cache is
  shared across the whole call tree, domain and message alike, so type-dependent
  work happens at most once per distinct type.

- Add functional Options bounding both the schema and the message: WithMaxTypes,
  WithMaxFieldsPerType, WithMaxWalkVisits, WithMaxArrayElements,
  WithMaxRecursionDepth and WithMaxTotalValues. The zero value means unlimited,
  so existing callers are unaffected. Value-driven checks run before allocating
  or recursing, so an oversized array is rejected up front.

- Fix a panic in typedDataDecodePrimitiveValue. ABIUnmarshalStringValuesAny
  returns fewer values than requested, with a nil error, for a type token it does
  not recognize; the caller then indexed out[0] and panicked on input as simple
  as {"type": ""} or {"type": "foobar"}.

- Make ValidateTypeGraph's walk an explicit-stack DFS and cap nesting at
  maxTypeGraphDepth. The encoders below it still recurse one frame per level, and
  a long enough type chain overflowed the goroutine stack fatally. The ceiling is
  measured from each type's longest downward path rather than the live DFS stack,
  which memoization can cut short depending on map iteration order.

- Reject field types no encoder can handle (unknown names, uint0/uint7/uint2560,
  bytes0/bytes33, bare uint/int, malformed array suffixes) instead of letting
  them fail deep inside the encoders.

Note: schema validation is stricter than before. A schema declaring a type with
an invalid field type previously decoded and only failed if that type was
actually encoded; it is now rejected at decode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@patrislav
patrislav requested a review from a team August 14, 2026 13:36

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a6a0553036

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread ethcoder/typed_data.go
// WithMaxWalkVisits, WithMaxArrayElements, WithMaxRecursionDepth, and
// WithMaxTotalValues. With no opts, behavior is unbounded, matching prior
// versions of this function.
func (t *TypedData) Encode(opts ...Option) ([]byte, []byte, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the no-argument Encode method

Changing this public method from Encode() ([]byte, []byte, error) to Encode(...Option) ([]byte, []byte, error) keeps direct td.Encode() calls working, but it changes the method set: downstream code that stores *TypedData behind an interface requiring Encode(), or assigns td.Encode to a func(), no longer compiles. Since this package is consumed as a library and the option-bearing path can be exposed with a separate helper/method, keeping the original signature avoids a source-compatibility break for existing callers.

Useful? React with 👍 / 👎.

@patrislav
patrislav marked this pull request as draft August 14, 2026 15:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant