Skip to content

feat(p2p): require the Inbox prefix reference on every block proposal - #184

Draft
spalladino wants to merge 2 commits into
spl/fi-n6-endpoint-gatefrom
spl/fi-n8-required-prefix
Draft

spalladino wants to merge 2 commits into
spl/fi-n6-endpoint-gatefrom
spl/fi-n8-required-prefix

Conversation

@spalladino

@spalladino spalladino commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

This is a coordinated P2P wire break. Every node on the network has to move together: a peer still writing the
previous optional-tail encoding produces a buffer that is 32 bytes short of a valid proposal, and a peer still
reading it decodes the new bytes as garbage. There is no negotiated fallback and none is wanted — the whole point is
that a proposal without an authenticated Inbox position is not a proposal.

Rung N8 of the Fast Inbox node port stack, on top of spl/fi-n6-endpoint-gate. It reconstructs old PR #25445,
commit e95e15327482b5ad6edda9142f2ac398813145ce, which fully implements section 1 of the guide-review code-change
plan of 2026-09-09.

What changes

The signed Inbox message-prefix reference (InboxMessagePrefixRef) becomes a required field on a standalone
BlockProposal and on the final block embedded in a CheckpointProposal. N3a introduced it as an optional tail with
an end-of-buffer-means-unset decode, explicitly as a staging shape; this rung finishes the job.

The presence flag and the EOF fallback are gone. A proposal that omits or truncates the reference is now malformed
input, rejected by the decoder, rather than a proposal that happens to have consumed no messages. Those two readings
were never distinguishable on the wire, which is exactly why the optional shape could not survive: silently treating
a stripped reference as "consumed nothing" hands a relay a way to turn an authenticated block into an unauthenticated
one.

A block that consumes no new messages now carries its real inherited prefix — it re-states the prefix its parent
ended at. The zero hash is a genuine value only for the empty global prefix a chain starts from.

Whenever a checkpoint proposal carries a final block, that block's reference is checked against the checkpoint
header's inboxRollingHash at both construction and decode, so the checkpoint's consumed position is exactly its
last block's.

The validator's per-block streaming checks lose their old "check 1: reference present" step entirely. The wire format
now guarantees it, so a proposal missing the reference never reaches the check at all; the remaining three checks
renumber.

New byte layout

BlockProposal.toBuffer():

blockHeader | indexWithinCheckpoint (4) | archiveRoot (32) | signature (68) | chainId (4) | rollupAddress (20)
  | txHashes.length (4) | txHashes (32 * n) | inboxPrefixRef (32) | hasSignedTxs (4) | signedTxs?

CheckpointProposal.toBuffer(), when hasLastBlock is 1:

... | lastBlock.blockHeader | indexWithinCheckpoint (4) | signature (68) | txHashes.length (4) | txHashes (32 * n)
  | inboxPrefixRef (32) | hasSignedTxs (4) | signedTxs?

The reference sits immediately after the transaction hashes and immediately before the hasSignedTxs flag, with no
presence flag of its own. The signing payload changes the same way:

BlockProposal.getPayloadToSign() = blockHeader | indexWithinCheckpoint | archiveRoot | txHashes.length | txHashes
                                   | inboxPrefixRef

The reference is unconditionally part of the payload, so a relay can neither strip it nor substitute it without
breaking signer recovery — and two attempts at the same block duty against different message prefixes are now
conflicting signatures, not duplicate ones.

Historical L1 blob replay is unaffected. Blocks reconstructed from L1 blob data never go through this encoding;
nothing about replaying past checkpoints is coupled to the P2P wire format, and nothing in this PR touches that path.

Source commits reconstructed

  • Old PR #25445, commit e95e153274 — the whole of it, minus the exclusions below.

Stack position

This is rung 21 of 25 in the Fast Inbox node stack, stacked on spl/fi-n6-endpoint-gate.

Tests

Run locally from yarn-project, all green:

  • yarn workspace @aztec-labs/stdlib test src/p2p/ — 6 suites, 65 passed (base 59).
  • yarn workspace @aztec-labs/stdlib test (full) — 132 suites, 1120 passed, 1 skipped.
  • yarn workspace @aztec-labs/validator-client test — 11 suites, 317 passed, 3 skipped
    (base 319/3; three "no prefix reference" cases deleted because the state they describe is now undecodable, one HA
    conflict case added).
  • yarn workspace @aztec-labs/sequencer-client test — 12 suites, 305 passed, 1 skipped (base parity).
  • yarn workspace @aztec-labs/p2p test — 75 suites, 1459 passed.
  • yarn workspace @aztec-labs/archiver test — 23 suites, 682 passed.
  • yarn build, yarn format, yarn lint — all clean.

Red/green was taken on the wire cases first: with the new tests in place against the old encoding, nine cases failed
across the two p2p suites — the pinned block and checkpoint bytes, both byte-layout assertions placing the reference
before the hasSignedTxs flag, rejection of a buffer written without the reference for both types, rejection at
decode of an embedded reference that disagrees with the checkpoint header, and the two constructor-order cases
including the tampered-reference signature check. All nine pass after the change.

Two further cases are regression pins, not red/green proof. The generic truncated-buffer rejection already passed
under the old encoding because its tail decoder also ran off the end. The zero-new-message round trip also passed when
the optional reference was set explicitly. They remain useful coverage, but they do not add to the nine observed
failures above.

E2E suites were not run locally and are left to CI. The only e2e file touched is
e2e_multi_validator_node_key_store.test.ts, whose createBlockProposal override needed the new parameter.

Deliberately left out

  • The deferred generic fee-modifier size correction. The source commit's parent had already replaced
    CheckpointProposal.getSize() with this.toBuffer().length and added a "reports the actual serialized size" test
    matrix; this repository deliberately has neither, because getSize() still accounts 8 bytes for a
    feeAssetPriceModifier that serializeSignedBigInt writes as 32. That under-reports a checkpoint proposal by
    exactly 24 bytes (measured: 1132 vs 1156). Left exactly as found — only the prefix-reference term of the sum was
    touched. BlockProposal.getSize() is exact and is asserted against toBuffer().length in the ported tests.
  • Private tracker links from three limitation comments. The source linked private tickets from code. The public
    comments instead explain the constraints directly: automine can stall on a censorship-deadline backlog larger than
    the per-block cap; node-side public simulation predicts the next block's consumed messages from the local log
    alone; and the archiver's no-new-checkpoint short-circuit compares only the pending archive root.
  • Sections 3 and 4 of the old guide-review plan, which are investigation notes rather than implementation scope.
  • docs/docs-developers/.../migration_notes.md and docs/docs-operate/.../changelog/v6.md, owned by the
    aztec-packages documentation follow-up.
  • validator-client/README.md, which the source commit edits but which does not exist in this repository.
  • Every other deferred item from the stack brief: no DutyBudget, no unverifiable status or sentinel encoding 9,
    no R − P send cutoff, no generic Rollup artifact library map, no override-type cleanup, no proof-lifetime joins.
    Audited by grep over the full branch diff.

Notes for review

The wire fixtures in stdlib/src/p2p/wire_compat_fixtures.ts were regenerated for this repository rather than copied
from the source: the destination's block header carries a tx_effects_tree_root field the source's did not, so the
source's hex is the wrong length here. The previous LEGACY_* constants are kept under new
PRE_REQUIRED_PREFIX_* names, with their bytes unchanged — they are now the negative fixtures proving the old
encoding is rejected, which is a stronger use than the compatibility assertion they used to serve.

The signed Inbox message-prefix reference is now a required field on a standalone BlockProposal and on the final
block embedded in a CheckpointProposal, serialized without a presence flag ahead of the optional SignedTxs bundle
and always included in the signing payload. The end-of-buffer fallback is gone: a proposal that omits or truncates
the reference is malformed input rather than a valid zero-message proposal. The final block's reference is checked
against the checkpoint header's inboxRollingHash whenever a final block is present, and a zero-new-message block
re-states the prefix its parent ended at instead of leaving the field unset.

This is a coordinated P2P wire break: peers on the old optional-tail encoding write a buffer 32 bytes short of a
valid proposal. Historical L1 blob replay is unaffected, since it never reads this encoding.
…t carries them

Automine cannot spread a censorship-deadline backlog over more than one block, node-side public simulation predicts
the next block's consumed messages from the local log alone, and the archiver's no-new-checkpoint short-circuit only
compares the pending archive root. Each is explained where it lives, as a self-contained constraint rather than a
tracker link.
@spalladino
spalladino added this pull request to stack #188 September 12, 2026 04:52
@spalladino spalladino changed the title spl/fi n8 required prefix feat(p2p): require the Inbox prefix reference on every block proposal Sep 12, 2026
@spalladino spalladino added the ci-draft Run CI on this draft PR label Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-draft Run CI on this draft PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant