perf(blocksync): verify a commit once, not again as the next block's LastCommit - #1427
Conversation
…LastCommit Block sync threshold-verifies the commit for block N in blockApplier.verify, and one height later the same commit comes back as block N+1's LastCommit and is verified a second time inside validateBlock. Every commit was verified twice, a block apart, at roughly 1.9 ms each. The applier now records what it verified through a new Executor.NoteVerifiedCommit and validateBlock takes a lastCommitVerified hint. The skip fires only on an exact match of every input ValidatorSet.verifyCommit reads: chain ID, height, block ID, quorum type, quorum hash, threshold public key, and a byte-identical marshalled commit. Marshalling rather than using Commit.Hash() matters, because that hash only covers ThresholdBlockSignature and would miss Round and the vote extensions. Anything short of a full match falls through to a real verification. The note is stored after ApplyBlock returns, not before: both places that would re-verify this commit run while the *next* block is applied, so writing it earlier would overwrite the entry they are still reading for the current block and the skip would never fire. The validator set is captured before ApplyBlock, which reassigns e.state. The consensus path never calls NoteVerifiedCommit, so the memo stays nil there and nothing changes. It is an atomic.Pointer written and read whole, never read-modify-write, and blockApplier.Apply is serialised under its own mutex. Replaying mainnet, the applier's verify stage goes from 3.61 ms to 1.84 ms a block.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
🕓 Queued for automated review — 12th in line, estimated start in ~1.6 h (commit c3c078f)
|
thepastaclaw
left a comment
There was a problem hiding this comment.
Final validation — Sol-only technical fallback
The focused state and blocksync test suites pass, and the memo comparison pins the inputs used by commit verification. However, the central verify-once invariant is not achieved because the round-state path still performs an unconditional threshold verification, and the new memo behavior has no direct tests.
Source: reviewer 1: gpt-5.6-sol (agent: sol-fallback-reviewer, role: general); reviewer 2: gpt-5.6-sol (agent: sol-fallback-reviewer, role: tenderdash-consensus-security); final verifier: gpt-5.6-sol (agent: sol-verifier, role: final-verifier)
One or more required Phase-1 GLM Flash lanes remained technically unusable after the bounded exact-model retry. Their evidence was discarded as authoritative, and the complete selected role cohort was rerun fresh on exact gpt-5.6-sol before this fresh Sol verifier produced the final decision. No additional Phase-2 reviewer pass ran.
Review provenance
- Phase 1 GLM evidence: technically unusable after bounded retry; discarded from the decision
- GLM failure attempts:
codex-general-b08d2fd95a904ff9acd5767ef234613b(failed),codex-general-2d5f72ff201a4dd38564c3248fe2a37a(failed),codex-tenderdash-consensus-security-8cbd3a5f5ded45549161c9033cd7ce02(failed),codex-tenderdash-consensus-security-631883b3da9a49b6a32f7ec6e45fb4cb(failed) - Sol-only fallback reasons:
launch_transport_or_nonzero_exit,launch_transport_or_nonzero_exit - Sol-only fallback reviewers:
gpt-5.6-sol— general (completed); agentsol-fallback-reviewer,gpt-5.6-sol— tenderdash-consensus-security (completed); agentsol-fallback-reviewer - Fresh verifier (Sol):
gpt-5.6-sol— final-verifier; agentsol-verifier - Additional Phase 2 pass: not run; the Sol-only fallback is final
🔴 1 blocking | 🟡 1 suggestion(s)
1 additional finding(s) omitted (not in diff).
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `internal/state/execution.go`:
- [BLOCKING] internal/state/execution.go:530-534: Round-state validation still repeats the threshold verification
This unconditional `VerifyCommit` prevents the stated verify-once behavior. When block N is applied, `blockApplier.verify` verifies commit N. While applying block N+1, the memo skips commit N in the first `ValidateBlock` call, but `ApplyBlock` then reaches `FinalizeBlock` and `ValidateBlockWithRoundState`; its `ValidateBlock` call returns from the block cache, after which these lines threshold-verify commit N again. Each commit is therefore still verified twice over its lifecycle. Make this check honor the same full-match memo while preserving the real verification when no memo exists, so the consensus path remains unchanged.
- [SUGGESTION] internal/state/execution.go:448-499: Add behavioral coverage for the verified-commit memo
No changed test directly exercises `NoteVerifiedCommit` or `lastCommitAlreadyVerified`; the blocksync changes only assert that the new method is called. Those mock expectations still pass while the round-state path performs the redundant verification, so they do not establish the PR's central performance invariant. Add tests for an exact memo match, mismatches in each pinned verification input, and an executor/applier flow proving that an already verified commit is not threshold-verified again while any mismatch falls back to real verification and rejects an invalid signature.
This seems not blocking to me; and I believe there was a reason; and that handling this case was harder for some reason; or potentially unsafe. |
|
You're right about the underlying reason. I traced both paths and the stacked follow-up:
My recommendation is to downgrade this finding on #1427 to a scoped follow-up, keep the second check unchanged here, and fix the snapshot timing plus direct exact-match/mismatch tests in #1428. We should also reword #1427's title/body or explicitly link #1428, because “verify once” currently overstates what #1427 alone delivers (it reduces three verifications per block to two; the pair targets one). |
…oves it ran (#1428) ValidateBlockWithRoundState verifies block.LastCommit even though the ValidateBlock call at the top of the same function normally does it. That second verification is load-bearing rather than redundant: ValidateBlock short-circuits on blockExec.cache, which is keyed on the block hash alone and carries no promise about the state the earlier validation ran against. Removing it outright breaks TestStateProposalTime. So gate it on the memo added in the previous change instead of on the cache. The skip happens only when this exact commit was already verified against identical inputs — same chain ID, height, block ID, quorum type, quorum hash, threshold public key and byte-identical marshalled commit. That is a property of the data, not of the cache, so it holds no matter which path populated the cache. Replaying mainnet, this stage goes from 1.81 ms a block to 0.007 ms. Together with the previous change block sync performs one threshold verification per block instead of three.
Claudius-Maginificent
left a comment
There was a problem hiding this comment.
Six findings from a full grumpy-review pass, posted individually — see summary comment for the complete report and the two still-open items not posted here (test coverage gap, and an unresolved cross-PR disagreement on ABCI buffer aliasing with an external reviewer's finding on #1428).
🤖 Co-authored by Claudius the Magnificent AI Agent
| } | ||
|
|
||
| err := validateBlock(state, block) | ||
| err := validateBlock(state, block, blockExec.lastCommitAlreadyVerified(state, block)) |
There was a problem hiding this comment.
PR's "consensus is unaffected" claim is false — the memo is read by the consensus path at every blocksync-to-consensus handover.
The PR states "Consensus path never calls NoteVerifiedCommit, so this only affects blocksync." That's true for the write side only. The read side is reachable from consensus: internal/consensus/block_executor.go:103 -> ValidateBlockWithRoundState -> ValidateBlock -> this line. node/node.go constructs exactly one BlockExecutor and hands the same pointer to both consensus.NewState and blocksync.NewReactor, so a memo written by the block-sync applier survives the block-sync -> consensus handover and is read by the consensus path at height H+1, where H is the last block-synced height. This fires on the happy path at every handover, not a corner case.
No signature-verification bypass exists today — every input ValidatorSet.verifyCommit reads is pinned by the memo, so a match genuinely implies an identical prior verification. But the documented security boundary doesn't match the real one, and reviewers/maintainers/the stacked #1428 will reason from the wrong one.
Recommendation: narrow it structurally rather than just re-documenting it — clear the memo in blocksync.Reactor.poolRoutine immediately before SwitchToConsensus (internal/blocksync/reactor.go:296-301), so the memo cannot outlive the reactor that produced it. Smaller and more auditable than accepting the wider scope and documenting around it.
🤖 Co-authored by Claudius the Magnificent AI Agent
There was a problem hiding this comment.
You are right that the read side is reachable from consensus for one height after the handover, and the PR description overstated it. I have rewritten that section rather than adding the clear-before-switch.
I pushed back on the structural fix because I do not think it buys anything. The skip fires only on a full match: same chain ID, height, block ID, quorum type, quorum hash, threshold key and a byte-identical marshalled commit, all captured as owned copies at the moment VerifyCommit succeeded. When consensus validates the first post-handover block and the entry matches, that is exactly the verification block sync already did, on exactly the same inputs. A skip that fires in consensus is no less safe than one that fires in block sync, so clearing the memo would cost one real verification per handover for no change in what is trusted. The description now says plainly that consensus can read the memo for that one height, and why that is fine.
Clearing it would also need a new interface method or a type assertion in poolRoutine, which is more surface than the property it protects.
🤖 Posted autonomously by Claude on behalf of pasta.
|
Not ready for review yet:
Please address and re-request review when ready. |
TestLastCommitAlreadyVerified checks the memo matches only on a full match of every input VerifyCommit reads, and falls through when the chain ID, height, block ID, quorum type, quorum hash, threshold key or any commit field differs. TestApplyBlockSkipsVerifiedLastCommit drives a real BlockExecutor with genuine BLS commits through ApplyBlock: a noted commit is not threshold-verified again, a forged commit that was never noted is rejected, and a memo for a different commit does not cover a forged one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replace the blockAndID closure with a fixture method so each subtest reads as note-then-assert, use slices.Clone for the vote extensions, and move the cache comment above the call it explains. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ot be fed unverified ones NoteVerifiedCommit took the caller's word that a commit had been verified. Replace it with Executor.VerifyCommit, which runs the threshold verification itself and remembers the result only on success. The block sync applier now verifies through it; nothing else can write the memo. The memo keeps the two most recent commits instead of one: VerifyCommit for commit N runs before block N is validated, and block N's LastCommit is commit N-1, so both must be present. This also removes the ordering constraint that forced the applier to record the note only after ApplyBlock returned. Every slice-backed field in the memo is now an owned copy, so mutation of the validator set, block ID or commit after verification cannot change what it matches. validateBlock takes the hint as a closure, so the memo is consulted only after ValidateBasic and the cheaper header checks. A new state_last_commit_verification_skipped counter makes the skip observable, and a marshal failure on the memo path is logged instead of swallowed.
|
Pushed 1deb0da, which reworks the memo in response to the reviews above:
Each inline thread has a reply. Six are resolved; the handover thread stays open with my reasoning for not clearing the memo before 🤖 Posted autonomously by Claude on behalf of pasta. |
thepastaclaw
left a comment
There was a problem hiding this comment.
Final validation — Phase 2 only (queue backlog)
Verified the supplied Phase-2 claims against the exact head and found no actionable in-scope defects: the memo owns its verification inputs, is populated only after successful verification, and both LastCommit checks retain real verification on a mismatch, including after consensus handover. Independently reran state and blocksync tests with the race detector and deadlock tag, plus consensus, node, and types tests with the deadlock tag; all passed using a revision-compatible local BLS build. The worktree remains unchanged; lint was not independently validated.
Source: reviewer 1: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 2: gpt-6-astra (agent: phase2-reviewer, role: tenderdash-consensus-security); final verifier: gpt-6-astra (agent: astra-verifier, role: final-verifier)
Review provenance
- Triage:
criticalbygpt-6-astra(effort low) — This change skips BLS commit signature verification in block sync and consensus validation based on a shared memo, so errors in input matching, ownership, concurrency, or validator-set transitions could allow invalid commits to bypass consensus-critical checks. - Phase 1 reviewers: not run (skipped for throughput: 39 PRs queued, above the 10 limit)
- Fresh verifier:
gpt-6-astra— final-verifier; agentastra-verifier - Phase 2 reviewers:
gpt-6-astra— general (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— tenderdash-consensus-security (completed, effort xhigh); agentphase2-reviewer
Preserve executor commit verification and memoization alongside the new stage timings. Combine and regenerate state metrics, and adapt stage metric tests to the executor verification interface. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
thepastaclaw
left a comment
There was a problem hiding this comment.
Final validation — Phase 2 only (queue backlog)
The verified-commit memo records only successful verification, owns all slice-backed inputs, and requires a complete match before skipping either LastCommit verification; its reuse at consensus handover preserves that invariant. No in-scope defects were confirmed. State and blocksync tests passed with and without the race detector, and types, consensus, and node tests passed with the deadlock tag, using native BLS artifacts matching the pinned dependency.
Source: reviewer 1: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 2: gpt-6-astra (agent: phase2-reviewer, role: tenderdash-consensus-security); final verifier: gpt-6-astra (agent: astra-verifier, role: final-verifier)
Review provenance
- Triage:
criticalbygpt-6-astra(effort low) — This change skips cryptographic commit verification in block sync and consensus validation based on a shared memo, so errors in input matching, ownership, concurrency, or cache interactions could admit invalid commits and compromise consensus safety. - Phase 1 reviewers: not run (skipped for throughput: 18 PRs queued, above the 10 limit)
- Fresh verifier:
gpt-6-astra— final-verifier; agentastra-verifier - Phase 2 reviewers:
gpt-6-astra— general (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— tenderdash-consensus-security (completed, effort xhigh); agentphase2-reviewer
…-sync handover (#1416) * fix(blocksync): don't persist a block the application refused The applier saved a block to the block store and only then handed it to the application. An application that refuses the block - and one holding a stale execution context for that height does refuse it - left the store one height ahead of a block it never processed, and every later start had the handshake re-process that block through the same application, so the node could not restart its way out (#1413). ApplyBlock is now run as its two halves with the store advanced between them: ProcessProposal, where an application refuses a block and nothing has been persisted yet, then SaveBlock, then FinalizeBlock, where the application commits. Saving before the commit is what keeps the store from falling behind the application or the state, a case the handshake rejects outright, while a store one ahead is one it recovers from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(blocksync): keep syncing when the tip is out of consensus catch-up's reach The wall-clock backstop handed a node over to consensus after ten minutes without progress however far behind it was. What follows that handover is consensus catch-up, which moves about one block per gossip cycle: it closes a gap of a few blocks in seconds and a gap of thousands never, and in the meantime the node sits in the validator set at heights the network committed long ago (#1413). The backstop now applies only within maxCatchupGap of the highest height any peer claims. Further back, block sync keeps retrying and logs the distance every interval - the only route to the tip that exists. A stall on a block no peer can serve still ends block sync as before, so a node that genuinely has nowhere to fetch from is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(consensus): don't propose after a block-sync handover while still behind Block sync hands over to consensus even when it never reached the tip, and nothing told consensus about it. A long-lived validator handed a historical height is very likely the genuine proposer of the next height too, so it proposed a block built from present-day application state; the real block for that height then collided with it, and the node could not restart its way out (#1413). A handover that leaves the node provably behind - block sync stopped while a peer claimed a height above ours - now holds back proposals until something says the node reached the network: a peer reporting a height no higher than its own, or a block committed through consensus while no peer reports one. Voting and following consensus are untouched, and a suppressed round times out and rolls on like any other silent proposer. Only a handover can start the window, and it closes for good, so a peer lying about its height can neither open one nor reopen one - it can only hold a window the node already entered open while it stays connected. A node no peer claims to be ahead of, a solo validator or a fresh network, never enters it at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(blocksync): preserve handover evidence and validate before persistence Carry the observed target into consensus independently of the sync verdict, read state after stopping block sync, and bound proposal suppression based on unverified peer heights. Validate application responses before saving. Keep commit verification intact; its memoization is handled in PR #1427. Co-Authored-By: Codex <noreply@openai.com> <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(blocksync): use servable peer heights for stall fallback Keep the highest observed peer height as the handover target, but calculate the stall gap only from peers able to serve the required block. Cover range eligibility, receive-rate filtering, busy peers, and concurrent snapshots. Co-Authored-By: Codex <noreply@openai.com> <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…Commit token (#1456) * feat(types): add CommitVerification, a typed proof of commit verification VerifyCommitSignatures verifies a commit exactly as ValidatorSet.VerifyCommit does and, on success, returns a CommitVerification recording the chain, height, block ID, quorum, threshold key and the signed digests and signatures it checked. ValidatorSet.VerifyCommitUnlessVerified skips the threshold verification only when a verification covers exactly those inputs. Anything else, including the zero value, falls through to the unchanged verifyCommit, so its typed errors (ErrInvalidCommitSignature versus the untyped vote-extension count mismatch and budget exhaustion) are preserved. verifyCommit is split into commitSignData and verifyCommitReportingSigns with every error path unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(state): replace commit-verification memo with typed CommitVerification token BlockExecutor.VerifyCommit returns a types.CommitVerification instead of recording it in a private two-slot memo. ValidateBlock, ValidateBlockWithRoundState, FinalizeBlock and ApplyBlock take the verification of block.LastCommit explicitly. Block sync's applier threads the verification of each commit one block forward; consensus and the replayer pass the zero value and verify in full as before. The state_last_commit_verification_skipped metric keeps its meaning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(consensus): deflake TestWALRoundsSkipper The WAL generator node is the only validator, so it proposes every round, and replayPrevoter makes heights 3 to 5 commit only at round 10. The test genesis gives round 10 a 30.5ms propose timeout. On a loaded runner (-race, -coverprofile, several packages at once) that timer fires before the node's own proposal is complete, so it prevotes nil and moves to round 11: - at height 5, stopConsensusAtHeight(5, 11) halts consensus and the test times out after 60s ("waited too long for tendermint to produce 5 blocks"), which is the failure seen on #1391, #1393 and #1410; - at height 3 or 4, the commit lands past maxRound and the round assertion fails. Give the generator a 10s propose timeout. With a single proposer, a round moves on as soon as the proposal is complete, so a longer timeout only removes the race. UnsafeProposeTimeoutOverride is node-local and not a consensus parameter, so the blocks are unchanged. TestWALRoundsSkipperSlowProposer pins this. Its app holds PrepareProposal at round 10 for three times the genesis propose timeout. Without the override it fails every run; with it, it passes every run. Once the test survives load, a second pre-existing flake surfaces. The replaying State and its WAL keep running after Stop/Wait: receiveRoutine exits only when it sees its context cancelled, and the services' ctx watchers log asynchronously. Their logs through NewTestingLogger then land after the test completes and panic the binary ("Log in goroutine after TestWALRoundsSkipper has completed"). The test now logs through a writer that is muted, under a lock, by its first-registered cleanup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(types): turn CommitVerification into VerifiedCommit types.CommitVerification only carried proof metadata, checked against a commit passed alongside it. It becomes types.VerifiedCommit: the commit itself plus an optional proof. type VerifiedCommit struct { commit *Commit // the caller's pointer, the subject only proof *commitProof // nil: no proof } commitProof keeps the nine fields CommitVerification recorded, with their doc comments. - VerifyCommitSignatures keeps its signature, checks and errors. It returns the commit with a proof on success, and the zero VerifiedCommit on failure. - NewUnverifiedCommit(commit) builds the no-proof variant. The zero value is NewUnverifiedCommit(nil). - Commit() returns the embedded pointer. Its doc spells out that it is not evidence and must never be validated in place of the commit actually received. - checkMatches moves to *commitProof and treats a nil receiver as "nothing was verified". It compares commit content only, never pointer identity. - errCommitVerificationMismatch becomes errCommitProofMismatch. Only the parameter types change at the call sites. block.LastCommit is still read from the block, and the current-height commit parameter of ApplyBlock/FinalizeBlock is untouched. Consensus (finalize, validate), ProcessProposal and the replayer pass NewUnverifiedCommit(block.LastCommit); the block-sync applier keeps the zero value where it holds no commit. Mocks are regenerated with mockery v3.7.4. Tests: - the types tests are renamed to VerifiedCommit; - the proof-mutation test pins with require.Same that the embedded commit aliases the caller's pointer; - a new case shows that a VerifiedCommit without proof, even one holding the very commit being verified, never skips verification; - the new TestBlockApplierSkipsTheLastCommitItVerified drives three blocks through a real executor and asserts LastCommitVerificationSkipped reaches 0, 2 and 4. It fails if the applier hands the next block the commit without its proof; - metricspy gains a Counter for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: retrigger CI GitHub Actions never picked up 0eccc06 (no check-suite created for it after 45+ minutes, unlike every prior push on this branch) — pushing an empty commit to force a fresh push event. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(types): drop NewUnverifiedCommit, the only way to fake a VerifiedCommit NewUnverifiedCommit let any caller wrap an arbitrary commit into a value of a type named VerifiedCommit while attaching no proof at all -- a naming trap, and unused by anything that reads Commit(): nothing outside tests ever called it. The four call sites that used it to signal "no proof held" never read the commit back off the value either; they only pass it into VerifyCommitUnlessVerified, which already falls through to a full verification on any proof-less value, zero value included. Remove the constructor. The zero value is now the only proof-less VerifiedCommit that code outside types can produce, and it is the only one that matters in practice: consensus, the replayer and block sync's ProcessProposal path all switch to it. The still-exported Commit() and the commit field stay -- VerifyCommitSignatures is still the only way to end up with a VerifiedCommit that names a real commit, and it always pairs one with its proof. TestUnverifiedCommitMatchesNothing keeps its "holds the exact commit but no proof" case as an in-package struct literal: a white-box test proving the mismatch check would still refuse to skip verification even in that stronger, no-longer-reachable-from-outside scenario. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Resolve the conflicts with v1.8-dev's block-sync handover and quorum-hash work: - blocksync applier: keep v1.8-dev's ProcessProposal -> SaveBlock -> FinalizeBlock pipeline and its exec-stage metric, handing the verified LastCommit to FinalizeBlock and carrying the commit forward. - validator set: commitSignData already builds the sign data, so verifyCommitReportingSigns only propagates its error; commitSignData returns the typed ErrInvalidCommitQuorumHash consensus classifies peers by, pinned in TestVerifiedCommitKeepsTheVerifyCommitErrors. - blocksync tests: move ApplyBlock expectations onto ProcessProposal/FinalizeBlock and add the VerifyCommit expectations the applier now needs. ProcessProposal(verify=true) still receives no LastCommit proof here, so block sync re-verifies each LastCommit once more; fixed in the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Since block sync validates the app response through ProcessProposal(verify=true), ValidateBlockWithRoundState runs there with a hard-coded zero proof, so every synced block threshold-verified its LastCommit once more even though the applier already held the proof. ProcessProposal takes lastCommit and forwards it to the verify branch. The block applier hands it the proof it carries forward; consensus and the replayer hold none and pass the zero value. ApplyBlock passes its own lastCommit through for uniformity; it is unread with verify unset. TestBlockApplierSkipsTheLastCommitItVerified now expects three skips per height after the first, and fails against the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Count skipped verifications with metricspy.Counter instead of a local duplicate of it. - Drop the subtest applying a forged commit with the verification VerifyCommit returned on rejecting it: that verification is the zero value, already pinned by "rejected commit yields no verification" and TestVerifiedCommitKeepsTheVerifyCommitErrors, and a forged LastCommit without proof is pinned by "unverified forged commit is rejected". - TestApplyBlockSkipsVerifiedLastCommit covers ApplyBlock only; block sync no longer calls it, so point to the block sync pin instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Block sync threshold-verifies each commit three times.
blockApplier.verifyverifies the commit for block N againststate.Validators. One height later the identical commit arrives again as block N+1'sLastCommit, and while that block is applied it is verified twice more againststate.LastValidators: once invalidateBlock, and once inValidateBlockWithRoundState. A BLS threshold verification costs about 1.9 ms, so every block pays for two it has already done.The applier now verifies through
Executor.VerifyCommit, which runs the threshold verification itself and, only on success, returns a typed proof —types.VerifiedCommit— of exactly what it verified.ValidateBlock,ValidateBlockWithRoundState,FinalizeBlock,ApplyBlockandProcessProposaleach take the caller'sVerifiedCommitas an explicit parameter and skip their own verification only when it matches by content. Block sync performs one threshold verification per block; consensus and the replayer, holding no proof, keep verifying every commit in full.This PR includes #1456, which was stacked on it and has been merged into this branch, and has since been merged with
v1.8-devto resolve conflicts with #1415, #1416 and #1426.Why the skip is safe
ValidatorSet.verifyCommitreads exactly:chainID,height(againstcommit.Height),blockID(againstcommit.BlockID),vals.QuorumType,vals.QuorumHash,vals.ThresholdPublicKey, and the commit itself — the canonical vote and the quorum signs are both derived from it.VerifiedCommit's unexported proof pins every one of those, and(*ValidatorSet).VerifyCommitUnlessVerifiedskips only on a full match: same chain ID, height, block ID, quorum type, quorum hash, threshold public key, and byte-identical signed digests/signatures — checked by content ((*commitProof).checkMatches), never by pointer identity, since block sync's next-height commit is a distinct decoded object from the one that was verified. Anything short of a full match, including a proof-lessVerifiedCommit, falls through to a real verification and returns exactly the errorVerifyCommitwould —internal/statesyncdepends on that typed-error distinction to decide whether to disconnect a peer.Marshalling/hashing what the proof covers rather than relying on
Commit.Hash()is deliberate: that hash only coversThresholdBlockSignature, so it would not notice a differentRoundor different vote extensions.A
VerifiedCommitcannot be forged from outsidetypes. OnlyVerifyCommitSignaturesattaches a proof, and only after the signatures verified; the zero value — the only proof-lessVerifiedCommitany other package can construct — always falls through.VerifiedCommit.Commit()returns the commit it was minted with but is documented as not evidence: callers must never validate it in place of the commit they actually received.Why the round-state check is gated rather than removed
ValidateBlockWithRoundStatecallsValidateBlockfirst, which normally verifiesLastCommit. ButValidateBlockshort-circuits onblockExec.cache, which is keyed on the block hash alone and carries no promise about the state the earlier validation ran against. Deleting the second verification outright breaksTestStateProposalTime. Gating it on the proof instead makes the skip a property of the data rather than of the cache, so it holds regardless of which path populated the cache. The same reasoning now also coversProcessProposal's ownValidateBlockWithRoundStatecall, added while resolving thev1.8-devmerge (see below).Scope of the proof
The consensus path and the replayer never hold a proof — they always pass the zero
VerifiedCommit, so every commit they see is fully re-verified. Only the block applier carries a real proof, one block forward, guarded by its own mutex and reset onUpdateState.BlockExecutor.Copydoes not carry any proof either, so the replayer always verifies fully.Observability
The
state_last_commit_verification_skippedcounter is incremented on every skip. During block sync it tracks three skips per block after the first (ValidateBlock,ProcessProposal's check of the app response, andFinalizeBlock); in consensus it stays flat.Detailed discussion of the merge with v1.8-dev
v1.8-devmoved forward with #1415, #1416 and #1426 while this branch was stacked, conflicting ontypes/validator_set.goand three files ininternal/blocksync. Resolving it mechanically would have reintroduced two regressions that no conflict marker flagged:ProcessProposal(verify=true)→SaveBlock→FinalizeBlock. Taking either side of the conflict as-is leavesProcessProposal's verify branch callingValidateBlockWithRoundStatewith a hard-coded zeroVerifiedCommit, so every synced block would threshold-verify itsLastCommitonce more even though the applier already held the proof — defeating the point of this PR.TestBlockApplierSkipsTheLastCommitItVerifiedcould not have caught this: it counts skips, not full verifications, and the skip count it asserted (2, 4) stayed correct either way. Fixed by adding alastCommit types.VerifiedCommitparameter toExecutor.ProcessProposalitself, forwarded from the applier; the skip count is now 3 per block after the first, and the regression is pinned by first widening that test's expectation and watching it fail against the merge-only commit, then implementing the fix.ErrInvalidCommitQuorumHash, returned from inside the very function the conflict marker straddled. Taking this branch's side of the marked hunk as-is silently drops it — consensus classifies peers by that error's type (state_try_add_commit.go,msg_handlers.go). Restored incommitSignData, outside the conflict marker, and pinned with anErrorAscheck inTestVerifiedCommitKeepsTheVerifyCommitErrors.The merge is committed in three steps for reviewability: the textual merge resolution alone (still carrying the extra-verification regression, on purpose, for the next commit's test to catch), the
ProcessProposalproof-threading fix, and a small trim of tests made redundant by this branch's ownVerifiedCommittype (a duplicate skip-counter helper, and a subtest pinning "rejected commit yields no verification" that the type's zero value already guarantees by construction).Diff-simplification was assessed deliberately (per request) rather than assumed: no leftover memo,
CommitVerificationorNewUnverifiedCommitscaffolding remains anywhere in the branch (confirmed by grep across the merged tree). The trims above are the only redundancy found; test coverage that looks like duplication on the surface (e.g.types/verified_commit_test.gopinning proof semantics input-by-input vs.internal/state/execution_test.gopinning the wiring) was deliberately kept, since each pins something the other doesn't.Measurements
Replaying mainnet history from a local peer, 3,000-block window at height 190k,
v1.7-devagainst this branch, back to back:verifystagefb_validatev1.7-dev−34.7% off the block: one threshold verification per block instead of three.
Numbers taken with tenderdash's per-block fsync removed, because Go's
File.Sync()on macOS issuesF_FULLFSYNC(6.78 ms here versus 0.23 ms for a plain fsync) and would otherwise swamp everything else. Linux nodes do not pay that. They predate the memo being replaced by the typedVerifiedCommitand thev1.8-devmerge; the verification work per block is unchanged by either.Tests
types/verified_commit_test.go: the proof matches only an exact commit/chain/height/blockID/quorum-type/quorum-hash/key, rejects/falls through on distinct kinds of mismatch (now including a dedicated wrong-quorum-hash case assertingErrInvalidCommitQuorumHashviaErrorAs), survives mutation of its inputs after construction, a proof-lessVerifiedCommitnever skips even when it holds the exact commit being verified, and the fall-through path returns exactly the same errorVerifyCommitwould.internal/blocksync/applier_test.go:TestBlockApplierSkipsTheLastCommitItVerifieddrives three real blocks throughsm.BlockExecutorand assertsLastCommitVerificationSkippedreaches 0/3/6 after heights 1/2/3 (three sites per block after the first:ValidateBlock,ProcessProposal's app-response check,FinalizeBlock) — mutation-tested by temporarily widening the expectation and watching it fail against the merge-only commit before theProcessProposalfix landed, then pass after.TestBlockApplierOffersTheVerifiedCommitForwardpins that the forwarded proof actually reachesProcessProposal(noneon the first height, the real proof on the next).internal/state/execution_test.go:TestApplyBlockSkipsVerifiedLastCommitcovers theApplyBlockflow specifically (stays at 2 skips —ApplyBlockalways callsProcessProposal(verify=false)), documented as such now that block sync no longer callsApplyBlockat all.internal/state/mocks/executor.go) regenerated viamockery(v3.7.4,go:generate), not hand-edited.go build ./...,gofmt -landgolangci-lint run --new-from-rev=origin/v1.8-dev: clean.go test -race -count=1 ./types/... ./internal/state/... ./internal/blocksync/... ./dash/quorum/... ./node/...: all green.go test -count=1 ./internal/consensus/...(full, unfiltered): green, 67.4s.Breaking Changes
None externally.
state.Executor(internal interface):ProcessProposalgains alastCommit types.VerifiedCommitparameter — internal only, all implementations/mocks updated in this PR.Checklist:
For repository code-owners and collaborators only
🤖 Generated with Claude Code