Skip to content

fix(stdlib): keep recursive zod schemas identity-stable - #25364

Open
spalladino wants to merge 2 commits into
merge-train/spartan-v5from
spl/zod-recursive-schema-stack-overflow
Open

fix(stdlib): keep recursive zod schemas identity-stable#25364
spalladino wants to merge 2 commits into
merge-train/spartan-v5from
spl/zod-recursive-schema-stack-overflow

Conversation

@spalladino

@spalladino spalladino commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

The symptom

Any wallet/PXE talking to a node with npm-installed Aztec packages dies as soon as it simulates a transaction:

ERROR: pxe:service RangeError: RangeError: Maximum call stack size exceeded
    at get schema (.../@aztec/stdlib/dest/tx/public_simulation_output.js:23:42)
    at Object.getter (.../@aztec/stdlib/dest/tx/public_simulation_output.js:24:66)

It shows up in the aztec-up release tests (bridge_and_claim) and for anyone installing the published
packages. It does not show up anywhere in yarn-project CI.

What is going on

Two schemas describe recursive data and refer to themselves through z.lazy:

  • NestedProcessReturnValues — return values of a public call tree
  • PrivateCallExecutionResult — a private call and its nested calls

Both were written as static get schema() that builds a brand new schema object on every access:

static get schema() {
  return z.object({
    // every dereference of this lazy calls the getter again, which builds another schema
    nested: z.array(z.lazy(() => NestedProcessReturnValues.schema)),
  });
}

zod ties a recursive schema together by object identity: the lazy target is expected to be the same
schema instance each time it is dereferenced. Up to zod 4.4.x this only cost extra allocations. From
4.5.0 onwards the schema graph is walked eagerly, so a lazy target that hands back a fresh instance
every time never closes the loop and recurses until the stack overflows — at any nesting depth, including
none at all ({ nested: [] } is enough).

Why it is invisible in CI: the monorepo's yarn.lock pins zod 4.4.3, but every package declares
"zod": "^4". The release tests and real users npm-install the published packages, so they resolve
whatever is latest — zod 4.5.0 was published on 2026-08-28, and CI started failing on the first
aztec-up run after that.

When was this introduced

The pattern is old, the breakage is new — and it is not the zod 4 migration.

NestedProcessReturnValues.schema written as a rebuilt-per-access recursive getter #9672, reverted in #9875, relanded as #9878 — Nov 2024
PrivateCallExecutionResult.schema written the same way #11155 — Jan 2025
yarn-project migrated zod 3 → zod 4 #23410 — May 2026
zod 4.5.0 published, pattern becomes fatal 2026-08-28 18:14 UTC
first aztec-up CI run after that 2026-08-29 01:55 UTC — fails

Verified directly by parsing a rebuilt-per-access recursive schema against each release: zod 3.25.76,
4.0.0 and 4.4.3 all accept it; 4.5.0, 4.5.2 and 4.5.4 all overflow the stack. So the
schemas were latently wrong for ~21 months, survived the zod 4 migration untouched, and only started
failing when 4.5.0 began walking the schema graph eagerly. Nothing in this repo changed — the dependency
did.

The fix

Build each schema once on first use and hand out that same instance:

let nestedProcessReturnValuesSchema: ZodFor<NestedProcessReturnValues> | undefined;

function getNestedProcessReturnValuesSchema(): ZodFor<NestedProcessReturnValues> {
  return (nestedProcessReturnValuesSchema ??= z.object({
    nested: z.array(z.lazy(() => getNestedProcessReturnValuesSchema())),
  }) /* ... */);
}

Construction stays lazy (no module-eval-time schema building, so no new circular-import hazards), and the
recursion now closes on a stable object.

The other z.lazy uses in the tree (AbiValueSchema, AbiTypeSchema, AbiDecodedSchema,
callTraceSchema) already point at a single module-level schema, and were verified to parse fine under
zod 4.5.x.

Pinning zod to a minor

Fixing the schemas removes today's breakage; the range is what let a dependency change behaviour for users
without anyone here choosing it. All 17 workspaces declared "zod": "^4", so the published packages accept
any 4.x while the monorepo's lockfile quietly held 4.4.3 — CI and consumers were never running the same
zod, which is precisely why this reached users without a single failing test in yarn-project.

The range is now ~4.4.3 everywhere: patch releases still flow, a minor bump has to be deliberate. The
lockfile resolution is unchanged (4.4.3), so the only lockfile diff is the descriptor rename — no
transitive churn.

Verification

With zod 4.5.2 swapped into node_modules locally:

  • before the change: 5 failures across the two test files, each RangeError: Maximum call stack size exceeded — the exact CI error;
  • after the change: all 10 pass, and the full stdlib suite is green (132 suites, 1060 tests);
  • with the repo's pinned zod 4.4.3 the suite is green as well.

Reproduced independently against zod 4.5.0, 4.5.2 and 4.5.4 (all fail on a rebuilt-per-access lazy target,
all pass on a stable one); zod 4.4.3 tolerates both.

Each schema also gets a regression test asserting it hands out the same instance on every access, which is
the invariant that keeps the recursion closed.

Note

Split out of #25357, whose CI surfaced this. Unrelated to that PR's changes — the failure reproduces on the
base branch with zod 4.5.x installed.

`NestedProcessReturnValues.schema` and `PrivateCallExecutionResult.schema` refer to themselves through
`z.lazy`, and both getters built a brand new schema on every access. zod ties a recursive schema together
by object identity, so from 4.5.0 onwards a lazy target that hands back a fresh schema recurses until the
stack overflows — at any nesting depth, including none.

The published packages depend on `zod: ^4`, so an npm install picks up 4.5.x and every PXE call that parses
a public simulation output or a private execution result dies with `RangeError: Maximum call stack size
exceeded`. The monorepo's yarn.lock pins 4.4.3, which is why nothing in yarn-project CI sees it.

Both schemas are now built once on first use and reused.
@AztecBot

AztecBot commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Flakey Tests

🤖 says: This CI run detected 1 tests that failed, but were tolerated due to a .test_patterns.yml entry.

\033FLAKED\033 (8;;http://ci.aztec-labs.com/7aa7758d04fa86a9�7aa7758d04fa86a98;;�): yarn-project/kv-store/scripts/run_test.sh src/bench/sqlite-opfs-encrypted/map_bench.test.ts (1s) (code: 0)

Every workspace declared `"zod": "^4"`, so the published packages accepted any 4.x. zod 4.5.0 broke
recursive schemas built with `z.lazy`, and because the monorepo's yarn.lock pinned 4.4.3, nothing in CI
saw it — only the release tests and real users, who resolve the range fresh at install time.

Pinning to `~4.4.3` makes the range say what the lockfile already enforced, so a zod minor release can no
longer change behaviour for consumers without a deliberate bump here. The lockfile resolution is unchanged.
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.

2 participants