fix: reject attestation source epochs below the min-span lookback - #9999
Conversation
Min-span entries are only written within 4096 epochs below each recorded source epoch, so min-max surround cannot detect a surround vote with an older source epoch. Reject any source epoch below the lookback window of the latest recorded attestation, read with a single reverse-range query that also replaces the same-target point read in the common case. Validate an interchange against existing spans before storing its attestations so a rejected import leaves nothing behind.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 090158476d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Performance Report✔️ no performance regression detected Full benchmark results
|
…e beyond the lookback The source epoch check relies on the highest target attestation having the highest source epoch. Min-max surround does not detect a surround vote beyond its lookback on import, so validate that explicitly before storing the interchange. Restore the original import write order, partial state left behind by a rejected interchange is tracked separately.
| * A surround vote with an older source epoch, as a malicious or buggy beacon node could serve, is undetectable | ||
| * by min-max surround and must be rejected by the lookback window of the latest recorded attestation. | ||
| */ | ||
| describe("SlashingProtection attestation min-span lookback", () => { |
There was a problem hiding this comment.
@lodekeeper can you run these tests against latest unstable branch and tell me which ones are failing
There was a problem hiding this comment.
Ran this against latest origin/unstable (858e7b16e9) by overlaying only this PR test file onto a fresh unstable worktree.
Command after build:
pnpm vitest run --project unit packages/validator/test/unit/slashingProtection/attestation.test.ts
Result: 5 of 9 tests fail on unstable. The failing cases are:
rejects a surrounding attestation whose source is older than the min-max span lookbackrejects a surrounding attestation inside an offline gap larger than the lookbackaccounts for attestations added by an interchange importrejects an interchange import whose highest target attestation surrounds one beyond the lookbackrejects a source epoch just outside the min-max span lookback window
All 5 fail the same way: the promise resolves instead of rejecting. The other 4 tests pass on unstable.
I also ran the same file on this PR head (ad3ad0fbc3) after build, and all 9 pass there.
| gte: this.encodeKey(pubkey, 0), | ||
| lt: this.encodeKey(pubkey, Number.MAX_SAFE_INTEGER), |
There was a problem hiding this comment.
this covers the whole db range? I don't think this is a perf issue but slashing db can be quite large for really old validators
ah but it also should return on first fin, and does reverse search, so I think this is fine, @lodekeeper double check please
There was a problem hiding this comment.
Double checked. This does cover the full target-epoch range for that validator pubkey, but not the whole slashing DB/bucket.
The keys are encoded as bucket + pubkey + targetEpoch, and getLatest() uses:
gte: encodeKey(pubkey, 0)lt: encodeKey(pubkey, Number.MAX_SAFE_INTEGER)reverse: truelimit: 1
LevelDbController.values() passes those options directly to the ClassicLevel value iterator and then .all()s it, so the iterator seeks into that bounded pubkey range in reverse order and stops after one returned value. There is an existing DB controller test for reverse: true, limit: 1 behavior too.
So I agree with your read: for really old validators it is still a range over all of that validator’s target epochs conceptually, but the reverse iterator + limit: 1 means it should fetch only the highest-target entry rather than scanning old history.
nflaig
left a comment
There was a problem hiding this comment.
looks good, leaving up for @lodekeeper to give a final approval 😁
lodekeeper
left a comment
There was a problem hiding this comment.
Final pass looks good to me.
Rechecked the current head ad3ad0fbc3: the new slashing-protection tests pass locally, the unstable baseline fails the intended lookback cases, and the getLatest() reverse lookup is bounded to one validator pubkey range with limit: 1. The remaining import atomicity point is pre-existing and tracked separately in #10001, so I do not think it should block this PR.
Motivation
MinMaxSurroundonly writes min-span entries for epochs within 4096 of each recorded source epoch (#5454), so a surround vote with an older source epoch is not detected. The comment there points to the minimal strategy as the backstop, but its lower bound is only written by interchange import.Description
SOURCE_BELOW_MIN_SPAN_LOOKBACK). Inside the window min-max spans are complete, so behaviour there is unchanged and the EIP-3076 vectors pass as before.AI-assisted with Claude Code, reviewed and tested locally.