Skip to content

fix(validator-client): slash a checkpoint attester only for the payload it signed - #346

Open
rkarabut wants to merge 3 commits into
mainfrom
rk/fix-a2163-attester-slash-payload-hash
Open

rkarabut wants to merge 3 commits into
mainfrom
rk/fix-a2163-attester-slash-payload-hash

Conversation

@rkarabut

Copy link
Copy Markdown
Contributor

An invalid block proposal flags the whole slot, so honest checkpoint attesters in that slot became slashable.

Slash a checkpoint attester only when its payload hash is one this node rejected as an invalid checkpoint proposal, matching the evidence AttestedInvalidProposalWatcher uses.

Fixes A-2163

@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Critical risk] Changes when checkpoint attesters are slashed for invalid proposals.

The PR appears safe to merge; a same-slot, different-payload regression test would strengthen coverage of its central safeguard.

Fix All in Claude CodeFindings

  1. P2 Different-payload case lacks coverage ▶

Summary

The PR limits checkpoint-attester slashing to attestations whose signed payload hash matches a checkpoint proposal this node rejected.

  • Adds a test showing that an invalid block alone must not make a checkpoint attester slashable.
  • Revises a zero-penalty test to record and attest to an invalid checkpoint payload.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Checkpoint attestation received] --> B{Slot has invalid proposal<br/>and no equivocation?}
  B -- No --> C[Do not slash]
  B -- Yes --> D{Payload hash matches a recorded<br/>invalid checkpoint proposal?}
  D -- No --> C
  D -- Yes --> E[Emit attester slash event]
Loading

Reviews (1) · Last reviewed commit: "fix(validator-client): dont slash checkp..."

Comment on lines +1151 to +1154
it('does not slash a checkpoint attester when only a block proposal in the slot was invalid', async () => {
await validatorClient.registerHandlers();
const attestationCallback = p2pClient.registerCheckpointAttestationCallback.mock.calls[0][0];
const emitSpy = jest.spyOn(validatorClient, 'emit');

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 Different-payload case lacks coverage The new negative test records only an invalid block, so there is no invalid checkpoint hash to compare. The revised positive test uses a matching hash. Please also test an invalid checkpoint proposal followed by an attestation to a different payload in the same slot. Without that case, a future change that slashes whenever any checkpoint hash is recorded could reintroduce the false positive this fix prevents.

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 Claude Code

@rkarabut
rkarabut force-pushed the rk/fix-a2163-attester-slash-payload-hash branch 2 times, most recently from d9a2440 to 7aa87a4 Compare September 28, 2026 04:43

@spalladino spalladino 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.

This fixes the case where one bad block made every signer of the slot's valid checkpoint slashable. It also stops slashing an attester who blindly signs a checkpoint built on a block this node rejected: that rejection records no invalid-checkpoint payload hash, and both slashing paths now require one. Requesting changes so the rejection is recorded as evidence that binds it to the attested checkpoint, and so the two copies of this rule become one (details inline).

Separately, the red e2e in attested_invalid_proposal ("slashes a lazy attester for an invalid checkpoint and clears it on delayed equivocation") expects the slash this PR removes, so it needs rewriting under this PR's premise. invalidBlockProposalIndexWithinCheckpoint only randomizes the archive claimed by the gossiped block proposal (validation_service.ts:59-61). The block's header stays real, and the proposer checkpoints the real block (checkpoint_proposal_job.ts:1324-1335). So the rejection concerns an archive the checkpoint never signed, and the attester signed a valid payload. The inline comments are about rejections that invalidate something the signed payload commits to.

written by claude

Comment on lines +857 to +860
const invalidCheckpointHashes = new Set(this.proposalHandler.getInvalidCheckpointProposalHashes(slotNumber));
if (!invalidCheckpointHashes.has(attestation.getPayloadHash())) {
return;
}

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.

An attester who blindly signs a checkpoint built on a block this node rejected is no longer slashed by either path.

Scenario: the proposer gossips block A with an invalid archive, then a checkpoint whose last block is A, and nobody equivocates. An attester that signs everything, for example a node running with skipCheckpointProposalValidation, signs it. On an honest node, A is rejected for a slashable reason and the slot is marked (validator.ts:498), but A is not accepted into the local chain. Checkpoint validation looks for the checkpoint's archive only among local blocks (proposal_handler.ts:1657-1671), retries until the deadline, and returns last_block_not_found (proposal_handler.ts:2129-2141). That reason is not slashable (proposal_handler.ts:416), so markInvalidCheckpointProposal never runs (proposal_handler.ts:753-756).

This check then returns early. AttestedInvalidProposalWatcher already applied the same rule before this PR (attested_invalid_proposal_watcher.ts:123-129), so nothing slashes that attester now. Until this PR, the slot-wide check here was the only path that caught it, and only when the block's rejection finished before the attestation arrived.

Suggestion: record the rejection as evidence, but only when it is bound to the payload the attester signed. That payload is the checkpoint header, archive and fee modifier (checkpoint_proposal.ts:191-193), so the evidence needs two things:

  • The rejected block is in the block sequence the payload commits to. Neither the archive the rejected proposal claims nor the checkpoint's embedded last block proves this. The claimed archive is just a value the proposer signed: a proposer can build a valid checkpoint ending at block n with archive R, and gossip an extra, invalid block at index n+1 that also claims R, before the checkpoint or reordered in transit. This node rejects it, and every honest signer of the valid checkpoint would match R. The embedded block is outside the signed payload, so a proposer can attach a different one to the copy a given node receives. The header's blockHeadersHash does commit to the sequence, so inclusion could be checked against it using the slot's retained block proposals. Missing evidence must never count as guilt.
  • The rejection invalidates something the payload commits to: the block's header, or the checkpoint's archive when the rejected claim equals it. Today state_mismatch covers both a wrong header and a wrong archive claim (proposal_handler.ts:1817-1827), and a wrong claim for an archive the checkpoint does not sign proves nothing about the checkpoint. So that reason would need splitting. invalid_embedded_txs (proposal_handler.ts:362) says nothing about either.

The existing bookkeeping records only that the slot had an invalid block (validator.ts:497-498), so this needs per-proposal verdicts.

written by claude

Comment on lines +853 to +856
// An invalid BLOCK proposal flags the whole slot (hasInvalidProposals), but that must not make the
// slot's honest checkpoint attesters slashable. Slash only an attester whose signed payload is one
// this node rejected as an invalid CHECKPOINT proposal, the same per-payload evidence
// AttestedInvalidProposalWatcher uses.

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.

This function and AttestedInvalidProposalWatcher.scanSlot now run the same three checks: the slot is marked, the slot has no equivocation, and the payload hash is recorded. A-2163 came from these two copies drifting apart after #137 fixed only the watcher.

I suggest consolidating on the watcher. This callback decides once, when p2p adds the attestation (libp2p_service.ts:1335). A blind signer attests as soon as it receives the checkpoint. With skipCheckpointProposalValidation it runs the checkpoint callback before the embedded block (libp2p_service.ts:1548-1551). So its attestation can reach an honest node while that node is still re-executing the block, before any evidence exists, and this callback never looks at it again. The watcher reads the same attestation pool later, one slot behind the synced slot. It runs on validators and on non-validator offense collectors alike (aztec-node/src/factory.ts:397-419, 464-476).

Concretely: record the evidence in the proposal handler, then delete handleCheckpointAttestation, slashAttestedToInvalidCheckpointProposal, badAttestationOffenseKeys with MAX_TRACKED_BAD_ATTESTATIONS, and the registration at validator.ts:421-423. That registration is the only production consumer of the p2p checkpoint-attestation callback, so that plumbing can go too, along with its tests in libp2p_service.test.ts. Keep the clear on late equivocation in the duplicate-proposal handler (validator.ts:943), which is separate code. The unit tests for this path move to the watcher's tests.

This is not free. The watcher also scans each slot once, even after a failed pool read, and looks back only four slots on startup. Its clock is the synced L2 slot, so it lags when the archiver lags. It also depends on the attestation pool, which is pruned on finalization (p2p_client.ts:725). An equivocation that lands during its pool read can still slip past its check. The single scan may deserve a retry if the watcher becomes the only path. If you'd rather keep both paths, at least move the predicate into one shared method so the copies cannot drift again.

written by claude

@rkarabut

Copy link
Copy Markdown
Contributor Author

I think it would probably be better to make recodding the rejection as evidence a follow-up pr, otherwise fixing

rkarabut and others added 2 commits October 5, 2026 07:29
… block in the slot

handleCheckpointAttestation slashed every checkpoint attester at any slot where
hasInvalidProposals was true. That flag is set for invalid BLOCK proposals too, so a
single dishonest proposer could broadcast one extra invalid block in its own slot and
make every honest node record and vote a full-penalty ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL
offense against every honest committee member who correctly attested that slots valid
checkpoint - an F1/F2 break, up to committee-size honest validators for a one-block
attacker cost.

The #137 fix added per-payload evidence (invalidCheckpointProposalHashesBySlot) and fixed
AttestedInvalidProposalWatcher, but validator.ts kept the coarse rule; being an
unconditional emitter, its coarse offense overrode the watchers restraint. Gate
handleCheckpointAttestation on the same per-payload evidence: slash only an attester whose
signed payload is one this node rejected as an invalid checkpoint proposal.

Fixes A-2163
…ng on the watcher

Per review: the checkpoint-attestation slashing rule lived in two places (the
validator-client callback and AttestedInvalidProposalWatcher), and their drift caused
A-2163. Remove the validator-client callback path (handleCheckpointAttestation,
slashAttestedToInvalidCheckpointProposal, badAttestationOffenseKeys, its registration)
and the now-unused p2p checkpoint-attestation callback plumbing across service.ts,
interface.ts, p2p_client.ts, dummy_service.ts, libp2p_service.ts and the txe dummy
client, keeping all attestation-pool processing. The watcher is now the single path.
Move the unit tests to the watcher and add the different-payload negative case.
Rewrite the attested_invalid_proposal e2e so the signed checkpoint is itself invalid,
since a bad block in the slot alone no longer slashes the attester.

The blind-signer-on-a-rejected-block gap is tracked as a follow-up (A-2243).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rkarabut
rkarabut force-pushed the rk/fix-a2163-attester-slash-payload-hash branch from d0e7d5b to 3e4f702 Compare October 5, 2026 07:34
…evidence only when bound to the signed payload

Consolidating attester-invalid-checkpoint slashing on the watcher left a gap: an attester who blindly signs a
checkpoint built on a block this node rejected is not slashed, because the rejection records no invalid-checkpoint
payload hash (the checkpoint fails with the non-slashable last_block_not_found once the bad block is kept out of the
local chain).

Record the rejection as evidence, but only when it is bound to the payload the attester signed. A per-proposal
verdict captures each block this node rejected for a header or archive mismatch. When checkpoint validation returns
last_block_not_found, the checkpoint's committed block-header sequence is reconstructed from the slot's retained
block proposals and trusted only when computeBlockHeadersHash over it equals the signed blockHeadersHash; a rejected
block then binds only when its header is in that verified sequence (a header the payload commits to) or its claimed
archive equals the checkpoint's signed archive. A wrong claim for an archive the checkpoint does not sign, a block
outside the committed sequence, and a sequence that cannot be reconstructed all record nothing: missing evidence is
never guilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rkarabut

rkarabut commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

@spalladino decided to address it in this one after all

This branch has not been deployed

No deployments
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.

2 participants