Skip to content

feat(bot): add the hardened Inbox bot mode - #187

Draft
spalladino wants to merge 4 commits into
spl/a-2041-append-messages-before-txsfrom
spl/fi-n11-inbox-bot
Draft

spalladino wants to merge 4 commits into
spl/a-2041-append-messages-before-txsfrom
spl/fi-n11-inbox-bot

Conversation

@spalladino

@spalladino spalladino commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

This is the top of the Fast Inbox node stack. It adds a fourth bot mode, inbox, whose job is to
keep pressure on the Fast Inbox in a live network and to tell us, in metrics rather than in
anecdotes, whether an L1→L2 message that L1 accepted actually became usable on L2 and in how long.

The bot sends messages to the L1 Inbox in atomic batches, records every message it produced in a
durable KV store, waits for the message to become ready on L2, consumes it through either the public
or the private domain, and then checks the node's own message APIs against what L1 recorded: that
the node knows the message at all, that the event it reports matches the one L1 emitted, that the
leaf index agrees, that the readiness witness recomputes to the block header's message-tree root,
that consumption produced the expected nullifier, and that a replay of the same message is refused.
Each of those is a named check with its own counter, so a disagreement shows up as a metric and not
as a stuck bot.

An optional saturation schedule periodically sends a full Inbox bucket plus the one message that
rolls it over, which is the case most likely to expose a boundary bug and the one least likely to
occur by accident under ordinary traffic.

The durable store is what makes the bot restart-safe: in-flight messages and batches survive a
process restart and are reconciled against L1 receipts rather than abandoned, and every milestone is
exported exactly once even if its receipt is resolved again after a restart.

Stack position

This is rung 24 of 25 in the Fast Inbox node stack, stacked on spl/fi-n10-docs-corrections.

What this reconstructs

Old PR #25443, at its current source head e17b6cd0816524def0b4492d93cc88e234b53d63
rather than the older plan snapshot 6a1ce3c1e6. It reconstructs the final behavior of all seven
source changes:

  • 3c037ca5e3 — Inbox bot mode configuration, telemetry definitions and lifecycle.
  • 58230a749b — durable state, L1 batching and the saturation schedule.
  • 5cd3fafd27 — public and private consumption, node API checks and the replay probe.
  • 50d6672446 — OTel metrics, structured logs and spans.
  • 6d5d6ff6fb — integration tests, the bucket rollover check and the README.
  • 6a1ce3c1e6 — the claim-secret call correction (generateClaimSecret is called without a logger
    on the batch path, because it logs the secret when handed one).
  • e17b6cd081 — durability, RPC, reorg and state-machine hardening.

It lands as three commits, each of which builds on its own: the telemetry and env-var definitions,
the bot package itself, and the single-node e2e suite.

The L1 bucket probe is deliberate

The bot reads the L1 Inbox's bucket layout — MessageSent.bucketSeq from the event and
Inbox.getBucket(seq, { blockNumber }) pinned to the batch's own L1 block — and checks that a
batch's messages landed in the buckets the ordering implies, including the rollover when a batch
crosses a boundary.

That is not a leftover of the model the node used to carry. The node's own store is bucketless now
and stays that way; L1 still batches messages by L1 block, so the bucket sequence remains part of
L1's surface. The bot querying it is an external probe of L1's behavior, checked against a node that
does not model buckets at all — which is exactly the disagreement worth detecting. It should stay.

Saturation batch default — flagged, not changed

The ported config defaults inboxSaturationIntervalSeconds to 86400, so the daily 257-message
saturation batch is enabled by default. That batch is a real L1 cost. The deployment follow-up plan says
the bot must ship with it disabled (BOT_INBOX_SATURATION_INTERVAL_SECONDS=0). This PR does not
change the default, because that is a deployment policy decision rather than a porting one; the
deployment values in the deployment follow-up must set it to 0 explicitly.

Tests actually run

From yarn-project:

  • yarn build — green. Verified again with the e2e file removed, i.e. at the second commit's tree.
  • yarn format, yarn lint — clean over the whole project.
  • yarn workspace @aztec-labs/bot test — base 3 suites / 19 tests (the pre-existing bot suites),
    final 13 suites / 173 tests, all passing.
  • yarn workspace @aztec-labs/telemetry-client test — 5 suites / 39, passing.
  • yarn workspace @aztec-labs/foundation test — 88 suites / 1076 passing, 5 skipped. The
    telemetry-client and foundation changes are purely additive (new exported constants, three new
    EnvVar union members), so these are parity runs rather than new coverage.

end-to-end/src/single-node/cross-chain/inbox_bot.test.ts is ported and compiles, but is not run
here
. It is a single-node cross-chain suite that stands up a node and L1; earlier rungs in this
stack established that these suites exceed the local tool timeout. It is left to CI.

Red/green

Most of this rung is new code, where the honest evidence is unit coverage of new behavior rather
than a red-then-green transition — the 154 new bot tests are that coverage, not a regression proof.

The seventh source change is different: it hardens behavior that was wrong before, so it gets real
red/green. Reverting the four production files it touches (inbox_bot.ts, inbox_l1_producer.ts,
l1_to_l2_seeding.ts, store/inbox_store.ts) to their pre-hardening state, with the final tests
left in place, produces 18 failures across 4 suites, covering every condition the hardening
addresses:

  • Durability / saturation ownership — a run is not owned from the moment its batch is reserved,
    so a crashed send produces a second full-bucket batch; retention deletes the batch a run is
    waiting on; a run whose record is gone holds the schedule forever; a run reports a verdict before
    its replay probe resolves.
  • RPC failures as negative evidenceisL1BlockCanonical reports a transport failure as a
    reorged block, and getBatchOutcome reports one as a batch with no receipt.
  • Reorg — a re-mined receipt rewrites the identity of a message whose attempt was built against
    the previous one instead of failing it; a re-mine during the insertion search yields a wrong block
    relation instead of an unknown one.
  • State machine — unguarded transitions resurrect a message that timed out while its consumption
    attempt was in flight and export a second outcome for it; a consumption whose effects do not carry
    the message nullifier is completed rather than failed; dispatch ignores maxPendingTxs; the bot
    does not exit on stopWhenUnhealthy; a milestone is exported twice when a receipt is resolved
    again.

Restoring the four files returns the suite to 13/13 and 173/173.

Dependency and lockfile

bot/package.json gains @aztec-labs/constants (this repo scopes every workspace package
@aztec-labs/, so the source's @aztec/constants maps to that) for MAX_L1_TO_L2_MSGS_PER_BLOCK,
which fixes the bucket size the saturation batch targets, and exports ./testing so the e2e suite
can use the recording telemetry client. Project references were regenerated with the repo's
yarn && yarn prepare workflow rather than hand-edited. The lockfile change is a single added line
("@aztec-labs/constants": "workspace:^" under the bot's entry) and nothing else.

Deliberately left out

  • docs/docs-operate/operators/reference/changelog/v6.md stays in aztec-packages; this repo does
    not own it.
  • Deployment is out of scope. No spartan/ Helm chart, Terraform, or environment file is
    touched. Wiring BOT_MODE=inbox into the deployment stack is the deployment follow-up, which follows this
    stack. (The bot README mentions spartan/metrics in prose when explaining how OTel units reach
    Prometheus; no file under spartan/ changes.)
  • The seven deferred work families stay out. The one grep hit worth naming: the bot has a
    MessageWitnessVerdict of unverifiable in inbox_message_checks.ts, returned when a leaf index
    is too large to hash up with. That is a local merkle-witness verdict and is unrelated to the
    deferred proposal-level unverifiable / checkpoint-unverifiable status and sentinel encoding.

@spalladino
spalladino added this pull request to stack #188 September 12, 2026 04:52
@spalladino spalladino changed the title spl/fi n11 inbox bot feat(bot): add the hardened Inbox bot mode Sep 12, 2026
@spalladino spalladino added ci-draft Run CI on this draft PR ci-no-fail-fast Do not cancel remaining jobs on first failure labels Sep 12, 2026
Adds the OTel metric definitions and span attributes the Inbox bot mode
reports under, plus its three configuration environment variables.
Adds a fourth bot mode that exercises the Fast Inbox end to end: it sends
L1->L2 messages in atomic Inbox batches, tracks each message through a durable
store, consumes it on L2 through the public or private domain, and checks the
node's message APIs against what L1 actually recorded.

The bot keeps its own state in a KV store so a restart resumes in-flight
messages rather than abandoning them. Batch production reconciles against L1
receipts, message transitions are guarded by their expected state, and an L1
RPC failure is never read as negative evidence: only a viem BlockNotFoundError
means a block is no longer canonical and only TransactionReceiptNotFoundError
means a batch has no receipt, so an RPC outage leaves batch state untouched
instead of failing every in-flight batch as a reorg.

An optional saturation schedule periodically sends a full bucket plus the
message that rolls it over. The schedule owns its run from the moment the batch
is reserved, so a crash during the L1 wait cannot leave an orphan batch that
reconciliation adopts and the schedule then follows with a second full-bucket
send.

The bot reads the L1 Inbox's own bucket layout as an external probe. L1 still
batches messages by L1 block, so MessageSent.bucketSeq and Inbox.getBucket
remain part of L1's surface even though the node's own store no longer models
buckets.
Drives the Inbox bot against a real node and L1, asserting the message
lifecycle, the node API checks, the bucket rollover probe and the exported
telemetry.
dispatchAttempts starts runConsumptionAttempt without awaiting it, and the
observed -> preparing store write is the first await inside that job, so
consumeStep can return before the transition has landed. The test asserted
the state immediately after consumeStep and lost the race under CI's
two-CPU container. It now waits for preparing instead, which still fails if
the transition never arrives.

The race did not reproduce locally (single-CPU pinning and CPU contention
both pass), so there is no local red for it; the ordering is established by
reading dispatchAttempts.
@spalladino
spalladino removed this pull request from stack #188 September 15, 2026 22:42
@spalladino
spalladino changed the base branch from spl/fi-n10-docs-corrections to spl/a-2041-append-messages-before-txs September 15, 2026 22:42
@spalladino
spalladino added this pull request to stack #217 September 15, 2026 22:43
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 ci-no-fail-fast Do not cancel remaining jobs on first failure

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant