Skip to content

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

Draft
spalladino wants to merge 1 commit into
spl/fi2-f15-authenticate-recovery-anchorfrom
spl/fi2-required-inbox-prefix
Draft

feat(p2p): require the Inbox prefix reference on every block proposal#25445
spalladino wants to merge 1 commit into
spl/fi2-f15-authenticate-recovery-anchorfrom
spl/fi2-required-inbox-prefix

Conversation

@spalladino

Copy link
Copy Markdown
Contributor

Background

The Inbox is the L1 contract that queues L1-to-L2 messages. An L2 block consumes a prefix of that queue: not a fixed
batch, but however many messages the proposer's node had observed by the time it built the block. A block therefore
has to state where in the message sequence it stopped. Its header carries the count, and its gossiped proposal carries
a second field, InboxMessagePrefixRef — the rolling hash over exactly that many messages. Each message's rolling
hash chains the previous one, so a validator holding the same hash at the same count holds the same messages, and can
read the block's message bundle out of its own store instead of trusting a list the proposer sent.

Both halves of that pair have to be signed, or a relay could move the block to a different set of messages. The count
is in the signed header. The hash is in the proposal.

The problem

The reference was encoded as an optional tail, after the optional transaction bundle, behind its own presence flag.
Take a proposal with two transaction hashes, Fr(7) and Fr(8), and no bundle. Its last bytes were:

... 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007   txHashes[0]
... 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008   txHashes[1]
00000000                                                                   hasSignedTxs = 0
00000001                                                                   hasInboxPrefixRef = 1
000000000000000000000000000000000000000000000000000000000000002a           the rolling hash

Cut the last 36 bytes and the buffer still decodes. fromBuffer reached the end after the hasSignedTxs flag,
recorded no reference, and handed back a proposal that the type system said was perfectly well formed, because
inboxPrefixRef was InboxMessagePrefixRef | undefined.

That absent case then had to be handled everywhere downstream. The signing payload appended the reference only when
set, so an unset proposal signed a shorter payload. The validator's first streaming check existed only to reject a
proposal with no reference. proposal.inboxPrefixRef! appeared in the re-execution classifier. The sequencer threw a
"wiring bug" error before pushing a block to its own archiver. Every one of those is code guarding against a state the
protocol never wants a block to be in: even a block that consumes no new messages has a position, namely the one its
parent ended at.

This is not known to be exploited, and cutting the tail off someone else's proposal does not work as an attack: the
truncated proposal hashes a different signing payload, so signer recovery fails and the proposal is dropped. What
making the field required buys is that the absent case stops being representable at all. A proposal that omits or
truncates the reference is rejected by the decoder, before any of the checks that used to have to consider it.

What this changes

InboxMessagePrefixRef becomes a required field on a standalone BlockProposal and on the final block embedded in a
CheckpointProposal, and it moves ahead of the optional transaction bundle. The presence flag is gone, so the tail of
the same proposal is now:

... 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007   txHashes[0]
... 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008   txHashes[1]
000000000000000000000000000000000000000000000000000000000000002a           the rolling hash
00000000                                                                   hasSignedTxs = 0

A proposal that carries a reference is four bytes smaller than before, and getSize() matches toBuffer().length for
every combination. The reference is now always part of the signing payload rather than appended only when set, so
signer recovery, payload hashing, attestation-pool deduplication and the HA signer's duty protection all see it on
every proposal. Two attempts at the same duty with different prefix hashes are a slashing-protection conflict, not a
duplicate duty.

Where a final block exists, its reference is checked against the checkpoint header's inboxRollingHash — at
construction and, now that decoding builds the proposal through the constructor, on the wire too. A block that
consumes no new messages supplies the real unchanged prefix its parent ended at; the zero hash is only correct for the
empty prefix a chain starts from.

With the absent case gone, the guards against it go too: the validator's "reference present" check and its
inbox_prefix_unavailable verdict for a missing field, the non-null assertions in the re-execution classifier, the
undefined in the streaming check input type, and the sequencer's runtime check before pushing a proposed block to
the archiver.

What this does not do

This is a breaking wire-format change. A node on the old format cannot decode a proposal from a node on this one, and
the reverse fails too, since the old decoder reads the first four bytes of the rolling hash as a hasSignedTxs flag.
There is no mixed-version mode; the peer set has to be upgraded together. Both the operator changelog and the
developer migration notes say so.

The embedded last block itself stays optional. A CheckpointProposal may still carry no last block at all; the rule
is only that when there is one, it has a reference.

Historical blocks replayed from L1 blobs are untouched. The reference is peer-to-peer only and is not part of any
published block, so the archiver's L1 ingestion path takes no reference and is unchanged.

Nothing else moves: no L1 contract, no consensus policy, no proving variant, no recovery behaviour, no new slashing
offense. Local disagreement about a prefix stays non-punitive, since it can reflect an honest L1 fork.

Testing

New and updated unit tests cover both representations: round trips with and without a transaction bundle and with the
empty genesis prefix; the exact byte order, asserting the reference sits between the last transaction hash and the
hasSignedTxs flag; golden wire fixtures for the serialized proposal and the signing payload; rejection of a buffer
written in the old encoding and of one whose reference is truncated; a tampered reference breaking signer recovery and
changing the payload hash; a well-formed checkpoint buffer whose last-block reference disagrees with the header being
rejected at decode; the embedded final block recovering the same signed block identity as a standalone one; and
getSize() === toBuffer().length across bundle combinations. An HA test asserts that a second, different prefix hash
for the same block duty is refused as a slashing-protection conflict. The sequencer's existing streaming test already
asserts that a block consuming nothing re-signs its parent's prefix, and the test double for the checkpoint builder
now derives the header's inboxRollingHash from the messages it was handed, so the header-versus-final-block
agreement is exercised by the whole sequencer suite rather than asserted in isolation.

Full-suite runs, from yarn-project: @aztec/stdlib 1100 passed, @aztec/validator-client 336 passed,
@aztec/sequencer-client 293 passed, @aztec/aztec-node 183 passed, @aztec/archiver 639 passed (the archiver
runner exits 129 on SIGHUP with no failures, on untouched branches too). @aztec/p2p passed 1458 of 1458 on one run;
a later run had p2p_client.batch_tx_requester.bench.test.ts time out waiting for one of its 25 worker processes,
which is load-related and unrelated to this change.

yarn format and yarn lint both exit 0. yarn build exits 1, on one pre-existing error unrelated to this branch:
the untracked end-to-end/src/single-node/cross-chain/streaming_inbox_load.test.ts imports getInboxCutoffTimestamp,
which no longer exists. That file is someone's in-progress work and was left alone. Every other package compiles. No
e2e tests were run.

Part of A-1928

@spalladino
spalladino added this pull request to stack #25441 September 10, 2026 03:35
@spalladino
spalladino force-pushed the spl/fi2-required-inbox-prefix branch from 7341b54 to 230668b Compare September 10, 2026 04:04
@spalladino
spalladino force-pushed the spl/fi2-required-inbox-prefix branch 2 times, most recently from 2f815c7 to 477b650 Compare September 10, 2026 05:41
@spalladino
spalladino removed this pull request from stack #25441 September 10, 2026 11:49
@spalladino
spalladino added this pull request to stack #25448 September 10, 2026 11:49
@spalladino
spalladino removed this pull request from stack #25448 September 10, 2026 12:09
@spalladino
spalladino changed the base branch from spl/fi2-f15-authenticate-recovery-anchor to spl/fi2-f14-l1-endpoint-check September 10, 2026 12:10
@spalladino
spalladino changed the base branch from spl/fi2-f14-l1-endpoint-check to spl/fi2-f15-authenticate-recovery-anchor September 10, 2026 12:10
@spalladino
spalladino added this pull request to stack #25449 September 10, 2026 12:11
@spalladino
spalladino force-pushed the spl/fi2-required-inbox-prefix branch from 477b650 to e4bee5f Compare September 10, 2026 12:51
@spalladino
spalladino removed this pull request from stack #25449 September 10, 2026 13:04
@spalladino
spalladino changed the base branch from spl/fi2-f15-authenticate-recovery-anchor to spl/fi2-f14-l1-endpoint-check September 10, 2026 13:05
@spalladino
spalladino changed the base branch from spl/fi2-f14-l1-endpoint-check to spl/fi2-f15-authenticate-recovery-anchor September 10, 2026 13:05
@spalladino
spalladino added this pull request to stack #25451 September 10, 2026 13:05
@spalladino
spalladino force-pushed the spl/fi2-required-inbox-prefix branch from e4bee5f to c46e794 Compare September 10, 2026 13:52
@spalladino
spalladino removed this pull request from stack #25451 September 10, 2026 13:53
@spalladino
spalladino changed the base branch from spl/fi2-f15-authenticate-recovery-anchor to spl/fi2-f14-l1-endpoint-check September 10, 2026 13:54
@spalladino
spalladino changed the base branch from spl/fi2-f14-l1-endpoint-check to spl/fi2-f15-authenticate-recovery-anchor September 10, 2026 13:54
@spalladino
spalladino added this pull request to stack #25452 September 10, 2026 13:54
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.

Also links the three deferred backlog limitations from the code that still carries them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@spalladino
spalladino force-pushed the spl/fi2-required-inbox-prefix branch from c46e794 to e95e153 Compare September 10, 2026 14:30
@spalladino
spalladino removed this pull request from stack #25452 September 10, 2026 14:31
@spalladino
spalladino changed the base branch from spl/fi2-f15-authenticate-recovery-anchor to spl/fi2-f14-l1-endpoint-check September 10, 2026 14:31
@spalladino
spalladino changed the base branch from spl/fi2-f14-l1-endpoint-check to spl/fi2-f15-authenticate-recovery-anchor September 10, 2026 14:31
@spalladino
spalladino added this pull request to stack #25453 September 10, 2026 14:32
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.

1 participant