Skip to content

fix: resolve the confirmed Greptile findings across the Fast Inbox stack - #271

Open
spalladino wants to merge 11 commits into
mainfrom
spl/fast-inbox-greptile-fixes
Open

spalladino wants to merge 11 commits into
mainfrom
spl/fast-inbox-greptile-fixes

Conversation

@spalladino

Copy link
Copy Markdown
Collaborator

Implements the 20 confirmed Greptile findings from tmp/greptile-stack-bug-fix-plan-20260918.md.

The plan was written against the Fast Inbox PR stack and asks for a fix in each owning PR. That stack is fully
merged (main is at #243), so there are no owning branches left to edit and this lands as one PR on main.

Four findings needed no change — main already has the fix

The sixteen fixes

Each has a regression test that fails without the fix.

Archiver / Ethereum

  • A malformed L1 block hash parsed outside the guarded RPC read, so unparseable provider data threw out of a check
    whose contract is canonical/replaced/unknown. Parsing moved inside the guard.
  • fetchLogsBisectingRange halved the range on every failure, turning one persistent auth/rate-limit/outage
    failure over a 100-block window into 199 identical requests. A new isLogRangeLimitError classifier restricts
    splitting to the provider refusals a narrower range can fix, with a request budget as a safety net.

Sequencer

  • The MIN_BLOCKS_FOR_INBOX_CATCHUP exemption was a threshold on ethereumSlotDuration, which is not a statement
    about the deployment: a production network running short L1 slots was exempted too, and short L1 slots do not
    raise the per-block message cap. Replaced by an explicit allowUnsafeInboxCatchupCapacity flag
    flag, default false, set by the e2e and local-network fixtures (not settable from the environment).

Validator

  • Timeout diagnostics kept only the first attempt's error, discarding a later store or provider fault — the one
    that actually explains why the node gave up.
  • The expected-failure filter matched substrings, so database unavailable: Inbox message range ... is not fully synced was reported as ordinary sync lag. Now anchored to the complete canonical message forms.
  • Pruning the block a cached valid verdict rested on makes revalidation fail before the blocks load, so its
    failure carries no checkpoint number and could overwrite that verdict with unvalidated. The re-execution
    tracker now answers by slot and archive (hasValidOutcomeForSlot).
  • Nothing re-read the attestation deadline before signing. The check now sits in
    createCheckpointAttestationsFromProposal, the one place both the peer path and the proposer's own
    collectOwnAttestations sign through, beside the equivocation check that is there for the same reason. The
    endpoint gate keeps its bounded floor past the deadline on purpose: a late node still wants the content verdict
    for telemetry, and it is the signature the deadline stops.

Wire format

  • Proposal decoders accepted any non-zero optional-field flag and ignored trailing bytes, so one proposal had many
    valid encodings and an old-format payload could parse as a different shape. Flags now accept only 0 and 1, and a
    top-level Buffer decode must consume its whole input; nested reader decodes are unchanged.

Prover node

  • Verifier circuits started before the checkpoint's message span was validated. Those jobs go into a shared cache
    that outlives the sub-tree, so one started early survives cancellation and keeps proving for a checkpoint nothing
    will accept. The whole span and every per-block boundary is validated before any proof work begins.

Inbox bot

  • l1ToL2SeedCount is optional so an unset value is distinguishable from an explicit one; inbox mode's 512 is
    applied from absence, so an explicit 1 stays 1 and is rejected by validation instead of silently becoming 512.
  • The pending-transaction check read the pool once and then launched every candidate, overshooting a cap the
    operator set on purpose. Availability is computed once, counts in-flight attempts, and is spent per dispatch.
  • A timed-out replay probe still marked its batch probed, letting a run close as a success with replay protection
    never demonstrated. It now records a failed check (new replay_unproven reason) first.
  • The consumption poll reset the consecutive-failure counter before the jobs it dispatched had settled, so one
    failing background job per poll could repeat forever without reaching the unhealthy threshold. Poll and
    background failures are now counted apart.

End-to-end

  • findInsertingBlock retried a false confirmation but let historical reads throw straight out. The whole attempt
    is inside the retry boundary now, with the last failure as the cause. Extracted to a fixture module so the retry
    boundary has a unit test instead of an e2e injection.

Spartan

  • Prover publisher keys were sized from autoscaled KEDA agent capacity, but agents own no keys — only the prover
    node signs and submits. The fast-inbox network allocated 8000..8007 where it needs 8000,8001. Both the
    deployment script and the funding calculation now source one helper, checked by
    spartan/scripts/prover_publisher_count.test.sh.

Behaviour changes worth knowing about

  • SequencerConfig.allowUnsafeInboxCatchupCapacity is new, defaults to false, and is deliberately not
    settable from the environment (like the other test-only flags in that config) so a deployment cannot opt out of
    the floor by accident. Any fixture that derives fewer than MIN_BLOCKS_FOR_INBOX_CATCHUP block opportunities
    must set it programmatically or the sequencer refuses to start. The e2e and local-network paths set it;
    short-slot production profiles are now correctly rejected.
  • BotConfig.l1ToL2SeedCount became optional (number | undefined). applyInboxModeDefaults resolves it and
    returns the new ResolvedBotConfig.
  • New bot failure reason replay_unproven.
  • Prover publisher index ranges shrink for every environment with PROVER_AGENT_KEDA_ENABLED=true; the keys that
    drop out were never used, but the prefunding list changes. Terraform's PROVER_COUNT (which drives web3signer
    key generation) moves to the same number, so generation and funding cannot disagree. Already-deployed
    networks are unaffected: funds already sent stay sent, and the prover node keeps using the first range.

Review

Reviewed by codex (sol/high) before opening. It found three real problems, all fixed here: the deadline check was
bypassable from the proposer's own attestation path; a zero-length endpoint window was both unreliable
(AbortSignal.timeout(0) aborts on the next tick) and against the finding's intent to let late validation finish
for telemetry; and terraform still generated prover keys from agent capacity. It confirmed the four
"already fixed on main" conclusions and agreed no schema-12 bump is needed.

Verification

  • yarn build, yarn format --check and yarn lint clean in yarn-project.
    (foundation/src/crypto/poseidon and archiver/src/modules/data_store_updater.test.ts:656 report pre-existing
    type errors on main; they are unchanged here.)
  • Focused unit suites pass: archiver, ethereum log, stdlib (135 suites), sequencer-client, validator-client,
    prover-node, bot (13 suites / 177 tests).
  • spartan/scripts/prover_publisher_count.test.sh passes; it fails on the previous sizing with
    8000,8001,8002,8003,8004,8005,8006,8007.
  • Anvil-dependent integration suites (l1_publisher.integration.test.ts, the ethereum contract suites) were not
    run locally and are left to CI; they fail the same way on an unmodified main in this environment.
  • E2e suites were not run locally. streaming_inbox.test.ts and messages.parallel.test.ts are the ones this
    touches.

@greptile-apps

greptile-apps Bot commented Sep 18, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5

The PR is not yet safe to merge because late remote signing can publish attestations after the consensus cutoff and concurrent bot successes can conceal persistent consumption failures; the explicit repository-style violations must also be resolved.

Fix All in CodexFindings

  1. P1 Deadline Can Pass During Signing
  2. P1 Concurrent Successes Hide Failures
  3. P2 Retries Multiply Timeout Budget
  4. P2 Fixture Uses Forbidden Casts
  5. P2 Background Handlers Violate Style
Summary

This PR applies reliability and correctness fixes across Fast Inbox synchronization, proposal validation, proving, bot operation, wire decoding, end-to-end fixtures, and deployment key sizing.

  • Guards malformed L1 data and narrows Ethereum log-range bisection.
  • Adds explicit sequencer capacity exemptions for test environments.
  • Tightens proposal decoding and validator diagnostics.
  • Validates prover message spans before scheduling proof work.
  • Resolves bot defaults, replay accounting, dispatch capacity, and health tracking.
  • Aligns Spartan prover publisher generation and funding with prover nodes.
  • Remaining concerns include a deadline race during remote signing, concurrent bot-health accounting, and retry-budget inflation.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  L1[Fast Inbox on L1] --> A[Archiver synchronization]
  A --> S[Sequencer block assembly]
  S --> P[Block and checkpoint proposals]
  P --> V[Validator checks]
  V -->|before deadline| T[Attestation signing]
  P --> N[Canonical P2P decoding]
  S --> R[Checkpoint prover]
  R --> E[Epoch proof and L1 publication]
  B[Inbox bot] --> L1
  B --> S
  D[Spartan deployment] --> K[Publisher keys and funding]
  K --> E
Loading

Reviews (1) · Last reviewed commit: "fix: address codex review of the Greptil..."

Comment on lines +688 to 698
const attestationDeadline = this.proposalHandler.getAttestationDeadline(proposal.slotNumber);
if (+attestationDeadline <= this.dateProvider.now()) {
this.log.warn(`Attestation deadline for slot ${proposal.slotNumber} passed during validation, not signing`, {
slot: proposal.slotNumber,
archive: proposal.archive.toString(),
attestationDeadline: attestationDeadline.toISOString(),
});
return undefined;
}

const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors, checkpointNumber);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Deadline Can Pass During Signing

The deadline is checked before an asynchronous signing operation that can spend up to 30 seconds calling Web3Signer and can also perform HA coordination. If validation finishes shortly before the cutoff, the deadline can pass while signatures are being produced. There is no second check before addOwnCheckpointAttestations, so those late attestations are still stored and broadcast. Enforce the deadline through signing or check it again before publishing the result.

Knowledge Base Used:

Fix in Codex Fix in Claude Code

Comment on lines +1886 to +1889
/** Records that a background attempt or probe reached its expected outcome, clearing the background streak. */
private registerBackgroundSuccess(): void {
this.consecutiveBackgroundFailures = 0;
this.updateConsumptionHealth();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Concurrent Successes Hide Failures

registerBackgroundSuccess resets one global failure streak even though up to eight consumption attempts and replay probes can settle concurrently. One successful job can therefore erase several RPC failures from other jobs. With one consistently successful lane among failing lanes, the bot never reaches maxConsecutiveErrors and remains healthy despite persistent consumption failures. Track failures per sequential poll or batch, or aggregate concurrent outcomes before resetting the streak.

Fix in Codex Fix in Claude Code

Comment on lines +59 to +82
{ attempts = 3, timeoutSeconds = 240 }: { attempts?: number; timeoutSeconds?: number } = {},
): Promise<InsertingBlock> {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt++) {
try {
const { leafIndex } = await retryUntil(
async () => {
const index = await node.getL1ToL2MessageIndex(msgHash);
return index === undefined ? undefined : { leafIndex: index };
},
`node assigns a compact index to message ${msgHash.toString()}`,
timeoutSeconds,
0.5,
);

// The chain holds the message once its tip's tree has grown past the message's index.
const { tip } = await retryUntil(
async () => {
const tip = await node.getBlockNumber();
const count = await committedMessageCount(node, tip);
return count !== undefined && count > leafIndex ? { tip } : undefined;
},
`a block committing message ${msgHash.toString()}`,
timeoutSeconds,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Retries Multiply Timeout Budget

Each outer attempt starts two independent retryUntil calls with the full timeoutSeconds allowance. With the defaults, one search can wait up to 3 × 2 × 240s before accounting for the historical reads, which exceeds the calling suite's 900-second timeout. This can make the test time out before the helper reports its own failure. Use one absolute deadline for the complete search or divide the budget across attempts.

Fix in Codex Fix in Claude Code

return Promise.resolve(blockNumber >= insertingBlockNumber ? [leafIndex] : undefined);
},
};
return view as unknown as InsertingBlockNodeView & { historicalReads: number };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Fixture Uses Forbidden Casts

This new fixture uses as unknown as here and repeated as Error assertions in the rejection test at lines 77–79. The repository requires avoiding as Type casts in favor of type guards. Define the fixture with a structurally compatible typed object and narrow caught values with instanceof Error. This explicit repository requirement must be satisfied before merging.

Context Used: yarn-project/CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

Comment on lines 1299 to 1301
const job = this.runConsumptionAttempt(message)
.catch(err => this.registerConsumptionFailure(err))
.catch(err => this.registerBackgroundFailure(err))
.finally(() => this.attemptsInFlight.delete(message.messageId));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Background Handlers Violate Style

The changed consumption handler uses a .catch() callback here, and the replay-probe path repeats the pattern at lines 1791–1795. The repository requires preferring async/await over .then() and .catch() callbacks. Wrap these jobs in an async helper with try/catch while retaining cleanup. This explicit repository requirement must be satisfied before merging.

Context Used: yarn-project/CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

…ge-limited log queries

An L1 block hash the provider returns in a form Buffer32 cannot parse escaped the archiver's guarded read as an
exception, from a check whose whole contract is canonical/replaced/unknown. Parsing now happens inside the guard,
so unparseable provider data leaves synchronization pending instead of throwing or rolling back.

fetchLogsBisectingRange also halved the range on every failure. A persistent authentication, rate-limit, outage or
missing-state failure over a 100-block window therefore turned one failed request into 199 identical ones before
reporting the same error. Only failures a narrower range can fix — the provider's own range and response-size
refusals, classified by isLogRangeLimitError — now split, with a request budget as a safety net.
The exemption from MIN_BLOCKS_FOR_INBOX_CATCHUP was a threshold on the Ethereum slot duration, which is not a
statement about the deployment: a real network running short L1 slots was exempted too, and short L1 slots do not
raise the per-block message cap, so such a proposer can still be unable to reach a mandatory endpoint and lose all
of its slots against an aged backlog.

The exemption is now an explicit SequencerConfig flag, allowUnsafeInboxCatchupCapacity, defaulting to false and set
only by the sandbox and e2e fixtures that deliberately derive one or two blocks per slot against an Inbox nobody is
filling.
…ne honest

Four separate defects in checkpoint and block proposal handling:

- Timeout diagnostics reported only the first attempt's failure, so a store or provider fault that appeared later
  was discarded exactly when it was the reason this node gave up. The latest unexpected failure is kept instead.
- The expected-failure filter matched message substrings, so an unrelated fault quoting one of them ("database
  unavailable: Inbox message range ... is not fully synced") was reported as ordinary sync lag. Matching is now
  anchored to the complete canonical forms.
- Pruning the block a cached valid verdict rested on makes revalidation fail before the blocks are loaded, so its
  failure carries no checkpoint number and could overwrite that verdict with 'unvalidated', contaminating
  inactivity and slashing accounting. The tracker now answers by slot and archive as well.
- The Inbox endpoint gate granted at least a second even after the attestation deadline had passed, and nothing
  re-read the deadline before signing. A late validation is still finished for telemetry; it is no longer signed.
The decoders read optional-field presence as "any non-zero word" and stopped wherever the fields ran out, so one
proposal had unboundedly many encodings: trailing bytes were ignored, and a payload from an older format whose
bytes happened to line up parsed as a different proposal shape. Signature verification bounds what that buys an
attacker but does not make the byte string an identity.

Presence flags now accept only 0 and 1, and a top-level Buffer decode must consume its whole input. Nested decodes
still read their part of a larger buffer, which is why only the Buffer entry point is strict.
…g proof work

Verifier circuits were started before the checkpoint's overall message span was checked. Those jobs go into a
shared cache that outlives this checkpoint's sub-tree, so one started before the check survives the cancellation
that follows and keeps proving for a checkpoint nothing will accept.

The whole span — the total supplied count, every per-block leaf count and the monotonic boundaries between them —
is now validated before the sub-tree is created, and the per-block slices it computes are what the block loop
consumes.
…ound failures

- l1ToL2SeedCount is optional in BotConfig so an unset value is distinguishable from an explicit one. Inbox mode's
  512 is applied from the absence of a value, not from it matching the general default, so BOT_L1_TO_L2_SEED_COUNT=1
  stays 1 and is rejected by validation instead of being silently rewritten.
- The pending-transaction check read the pool once and then launched every candidate. A job does not reach the pool
  until its transaction is submitted, several awaits later, so a poll could overshoot a cap the operator set on
  purpose. Availability is computed once, counts every in-flight attempt, and is spent as jobs are dispatched.
- A replay probe that timed out still marked its batch probed, letting a run whose messages all succeeded close as
  a success with replay protection never demonstrated. It now records a failed check first.
- The consumption poll reset the consecutive-failure counter before the jobs it dispatched had settled, so one
  failing background job per poll could repeat forever without reaching the unhealthy threshold. Poll failures and
  background failures are counted apart, and only a background operation that succeeds clears the background streak.
…s confirmation

findInsertingBlock retried a false confirmation but let a historical world-state or witness read throw straight
out of the helper, which is the same kind of timing miss: a prune drops the block a bisection step is asking
about. The whole attempt now sits inside the retry boundary and the last failure is carried as the cause.

Extracted to fixtures/find_inserting_block.ts so the retry boundary has a unit test rather than an e2e injection.
Publisher keys belong to prover nodes: an agent proves and hands its result back, while the node signs and submits
to L1, and only the prover stack's node sub-chart reads PUBLISHERS_PER_PROVER. Sizing the range off autoscaled KEDA
agent capacity created keys nothing ever uses and moved the start of every index range allocated after it — the
fast-inbox network allocated 8000..8007 where it needs 8000,8001.

Both the deployment script and the funding calculation now source one helper so they cannot diverge, checked by
prover_publisher_count.test.sh.
The gate's retry window is bounded by the attestation deadline and is now zero once it has passed, so this case
has to run with budget left: retrying L1 past the deadline only buys a verdict that cannot be signed.
- The attestation deadline check moved from the peer path to `createCheckpointAttestationsFromProposal`, the one
  place both the peer path and the proposer's own `collectOwnAttestations` sign through, next to the equivocation
  check that is there for the same reason. The peer-path check was bypassable.
- The Inbox endpoint gate keeps its bounded floor past the deadline rather than returning a zero-length window: a
  late node still wants the content verdict for telemetry (which is what the finding asks for), and
  `AbortSignal.timeout(0)` aborts on the next tick rather than synchronously, so a zero window would not have been
  a reliable refusal anyway. The signature is what the deadline stops, and that is now enforced in one place.
- `allowUnsafeInboxCatchupCapacity` is no longer settable from the environment, matching the other test-only
  sequencer flags: a deployment must not be able to opt out of the catch-up floor by accident.
- The log range-limit classifier no longer matches every message containing "block range", so a pruned or
  unavailable range is not split; the provider's own last refusal is kept as the cause when the budget runs out.
- Terraform sizes the prover keystore from publisher-bearing prover nodes too, so key generation and prefunding
  cannot disagree.
- Two bot tests now prove their descriptions: a genuinely empty poll, and the batch state a timed-out replay
  probe leaves behind.
The Inbox-backlog floor no longer exempts short L1 slot durations, so the
8s ethereumSlotDuration this bench adopted to stay inside that exemption no
longer buys anything. The e2e fixture sets allowUnsafeInboxCatchupCapacity,
which covers the bench, so restore the 12s production slot duration.
@spalladino
spalladino force-pushed the spl/fast-inbox-greptile-fixes branch from cd54e4d to 810ae99 Compare September 18, 2026 21:33
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.

1 participant