Skip to content

chore: Accumulated ports to next - #25053

Merged
AztecBot merged 3 commits into
nextfrom
port-to-next-staging
Jul 30, 2026
Merged

AztecBot merged 3 commits into
nextfrom
port-to-next-staging

Conversation

@AztecBot

@AztecBot AztecBot commented Jul 29, 2026 •

Copy link
Copy Markdown
Collaborator

BEGIN_COMMIT_OVERRIDE
fix(foundation): bound deserialized sibling path and vector lengths (#25028)
fix(rpc): cap simulation overrides and checkpoint queries (#25026)
fix(stdlib): cap the unbounded arrays in Tx and HashedValues schemas (#25029)
END_COMMIT_OVERRIDE

…25028)

## Context

`SiblingPath.deserialize` read a 32-bit length prefix and then both
pre-allocated an array of that
size and looped that many times, with nothing tying the declared length
to the bytes actually
present. Its element reader was built on `Buffer.slice`, which clamps
past the end of a buffer
instead of throwing — so the loop never ran out of input and ran the
full declared count regardless
of how much data arrived.

A 4-byte field was therefore enough to build a million-element path, or
to walk a client's V8 heap
into a fatal out-of-memory abort. That abort is not catchable, so a
`try`/`catch` around RPC decoding
does not contain it. This matters because sibling paths reach clients as
RPC *outputs* (witness
lookups, which the PXE fetches automatically during private execution),
so it is the node that can
crash the client, not the other way round.

## Approach

Sibling paths now deserialize through `BufferReader.readVector`, which
bounds the declared length
three ways: an explicit `maxSize` (`MAX_SIBLING_PATH_LENGTH`, 128 — the
deepest protocol tree is 42
levels and the stacked path for L2-to-L1 message inclusion spans four
unbalanced trees, so real paths
are well under it), a new check that the size does not exceed the bytes
left after the prefix, and
`readBytes`' own per-element range check. The last of those also closes
the truncation half of the
problem, where a path declaring more elements than its payload holds
used to deserialize into
undersized buffers instead of failing.

The cap has to live in `foundation`, which cannot import
`@aztec/constants` since `constants` depends
on it, so a test in `stdlib` asserts the cap stays above every generated
`*_HEIGHT` constant.
Enumerating them rather than listing the deep ones means a future deeper
tree fails that test instead
of silently failing to deserialize.

`deserializeArrayFromVector`, the primitive behind the old code, had
exactly one caller and
duplicated `readVector` minus all of those checks, so it is deleted
rather than fixed. The
remaining-bytes bound lands in `readVector` itself, which covers its
other callers too: every element
consumes at least one byte, so any size beyond the bytes remaining is
unsatisfiable and can be
rejected before allocating or looping. It was not exploitable there in
the same way (its item
deserializers all range-check as they read), but the guard makes the
failure explicit and cheap
rather than dependent on every element reader.

That bound assumes a minimum of one byte per element, so I checked for
anything that deserializes a
count of sub-byte items: `BitVector` is the only bit-packed reader, and
it converts its bit count to
`ceil(length / 8)` bytes itself and reads through the range-checked
`readBytes`, so it never reaches
the bound with a bit-denominated length. Every other `readVector`
element reader in the tree advances
by whole bytes.

One existing `readVector` test changed: it asserted that a 32-byte
buffer declaring 65537 elements
yields 65537 elements, using a deserializer that consumed no bytes. That
amplification is what the
bound removes, so the happy path is now exercised against a well-formed
vector and the oversized case
asserts the throw.

Fixes A-1522
Two public node RPC inputs reached expensive work with no length cap.

`simulatePublicCalls` accepted an unbounded `overrides.publicStorage`
array (and an unbounded
`overrides.contracts` record). Each storage override becomes a leaf
inserted into a forked public
data tree before the simulated transaction runs, so a small request
could force a large amount of
Merkle work and memory that the transaction's gas limit does not meter.

`getCheckpointsData` range queries validated `limit` with only `min(1)`,
while the sibling
`getCheckpoints` capped it. The effective upper bound was the node's
contiguous checkpoint history
rather than a page size, so one request could ask the archiver to read
and serialize the whole chain.

## Approach

Both caps live in `stdlib/src/interfaces/api_limit.ts` next to the
existing `MAX_RPC_*` limits and are
enforced in the zod schemas, so they reject at the RPC boundary before
any handler runs:

- `MAX_RPC_PUBLIC_STORAGE_OVERRIDES_LEN` (200) and
`MAX_RPC_CONTRACT_OVERRIDES_LEN` (50) on
`SimulationOverrides.schema`. Internal callers of the override path use
a handful of entries at
most (`fastforwardContractUpdate` writes one delayed-public-mutable
value), so there is ample
  headroom.
- `MAX_RPC_CHECKPOINTS_DATA_LEN` (200) on both range variants of
`CheckpointsQuerySchema`
(`{ from, limit }` and `{ fromSlot, limit }`). Checkpoint data carries
no attestations or block
bodies, hence a larger page than a full checkpoint response. The widest
internal caller is the
prover node's catch-up fetch, bounded by `(proofSubmissionEpochs + 1) *
epochDuration`.

The existing `MAX_RPC_TXS_LEN`, `MAX_RPC_BLOCKS_LEN` and
`MAX_RPC_CHECKPOINTS_LEN` page caps stay at
50 and are unchanged. Two ideas from earlier revisions were dropped:
raising those caps to 100 while
adding a tighter `MAX_RPC_HEAVY_LEN` for reads whose `include*` options
attach a tx body or proof to
every entry, which needed a refine on four schemas plus a divergent
default for `getPendingTxs`; and
capping the per-slot attestation arrays at the committee size, which is
not actually an upper bound
since equivocation can produce more than one attestation per validator
per slot.

Tests cover each cap at the schema level (`SimulationOverrides`,
`CheckpointsQuerySchema`) and as
round-trips through the JSON-RPC client and server in
`aztec-node.test.ts`.

Fixes A-1524
Fixes A-1523
…25029)

## Context

`Tx.schema` accepted `contractClassLogFields` and
`publicFunctionCalldata` arrays of any length, and
the `HashedValues.schema` those calldata entries are built from accepted
any number of values. Three
unauthenticated node RPC methods take a full tx — `sendTx`, `isValidTx`
and `simulatePublicCalls` —
so until now the only thing bounding those arrays at the RPC boundary
was `RPC_MAX_BODY_SIZE`. A
caller could post a tx whose calldata array is orders of magnitude
larger than any real tx, and the
node would deserialize all of it and compute the tx hash before
protocol-level validation rejected
it.

Follow-up to #25026, which capped simulation overrides and
checkpoint-data range queries.

## Approach

Each array is capped at what the protocol can actually produce:

- `publicFunctionCalldata` → `MAX_ENQUEUED_CALLS_PER_TX + 1` (33). One
entry per enqueued call, plus
the teardown call. `MAX_ENQUEUED_CALLS_PER_TX` is a whole-tx bound
rather than a per-phase one: the
revertible and non-revertible call request arrays are each sized
`MAX_ENQUEUED_CALLS_PER_TX` in the
circuit ABI, but `split_to_public` in the private tail fills them by
*partitioning* a single
`ClaimedLengthArray<_, MAX_ENQUEUED_CALLS_PER_TX>`, so their lengths sum
to 32 rather than reaching
it each. The teardown request is a separate field that "can only be set
once", hence the `+ 1`.
  `mockTx` with its defaults and a teardown call produces exactly 33.
- `contractClassLogFields` → `MAX_CONTRACT_CLASS_LOGS_PER_TX` (1). Same
partitioning argument:
`getNonEmptyContractClassLogsHashes` concatenates both accumulated data
sets, but they are filled
  from one array of that size.
- The fields within a calldata entry →
`MAX_FR_CALLDATA_TO_ALL_ENQUEUED_CALLS` (16000). A tx cannot
spend more than its whole calldata budget on a single call. That also
clears every producer by a
wide margin: the largest payload the client builds is packed bytecode
for a contract class
publication, bounded by `MAX_PACKED_PUBLIC_BYTECODE_SIZE_IN_FIELDS`
(3000).

The last of those is applied through a new
`HashedValues.schemaFor(maxValues)` rather than baked into
`HashedValues.schema`, because that schema is shared with
`TxExecutionRequest.argsOfCalls`,
`PrivateExecutionResult` and the wallet's `extraHashedArgs` — a public
calldata budget is not the
right bound for private call arguments or authwit arguments. `Tx.schema`
is the only caller that opts
in.

## Note for reviewers

`Tx.schema` is also used to deserialize txs from the tx file store, so
an over-tight bound would
reject previously-valid stored data rather than just bad RPC input. The
values above are protocol
ceilings, so stored and gossiped txs stay parseable; the only behavior
change is that a tx exceeding
them is now rejected while parsing instead of later, by
`DataTxValidator`. Note that gossip and the
reqresp protocols go through `Tx.fromBuffer`, not the schema, so
validator coverage there is
unchanged.

Fixes A-1527

@ludamad ludamad left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Auto-approved

@AztecBot

Copy link
Copy Markdown
Collaborator Author

🤖 Auto-merge enabled after 8 hours of inactivity. This PR will be merged automatically once all checks pass.

@AztecBot
AztecBot added this pull request to the merge queue Jul 30, 2026
Merged via the queue into next with commit 89be604 Jul 30, 2026
24 checks passed
@AztecBot
AztecBot deleted the port-to-next-staging branch July 30, 2026 04:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants