Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions internal/blocksync/applier.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ func (e *blockApplier) Apply(ctx context.Context, block *types.Block, commit *ty
return err
}
verifyTime := time.Since(start)
// The validator set that just verified this commit, kept for the memo below:
// ApplyBlock reassigns e.state.
verifiedAgainst := e.state

start = time.Now()
e.store.SaveBlock(block, blockParts, commit)
Expand All @@ -92,6 +95,13 @@ func (e *blockApplier) Apply(ctx context.Context, block *types.Block, commit *ty
}
execTime := time.Since(start)

// Record only now that the block is applied. This commit is block N+1's
// LastCommit, and both places that would verify it again — validateBlock and
// ValidateBlockWithRoundState — run while the *next* block is applied. Storing
// it earlier would overwrite the entry those two are still reading for this
// block, and neither skip would ever fire.
e.blockExec.NoteVerifiedCommit(verifiedAgainst, blockID, commit)

e.stats.add(partSetTime, verifyTime, saveTime, execTime)
// ByteSize is the size of the serialized block we just built, so the metric
// costs nothing extra here
Expand Down
3 changes: 3 additions & 0 deletions internal/blocksync/applier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ func TestBlockApplierApply(t *testing.T) {
On("ValidateBlock", mock.Anything, initialState, blockH1).
Once().
Return(nil)
mockBlockExec.
On("NoteVerifiedCommit", initialState, blockH1ID, commitH1).
Once()
mockBlockExec.
On("ApplyBlock", mock.Anything, initialState, blockH1ID, blockH1, commitH1).
Once().
Expand Down
15 changes: 15 additions & 0 deletions internal/blocksync/synchronizer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ func (suite *SynchronizerTestSuite) TestBasic() {
On("ValidateBlock", mock.Anything, mock.Anything, mock.Anything).
Maybe().
Return(nil)
suite.blockExec.
On("NoteVerifiedCommit", mock.Anything, mock.Anything, mock.Anything).
Maybe()
suite.blockExec.
On("ApplyBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Maybe().
Expand Down Expand Up @@ -203,6 +206,9 @@ func (suite *SynchronizerTestSuite) TestConsumeJobResult() {
On("ValidateBlock", mock.Anything, mock.Anything, respH1.Block).
Once().
Return(nil)
suite.blockExec.
On("NoteVerifiedCommit", mock.Anything, mock.Anything, mock.Anything).
Maybe()
suite.blockExec.
On("ApplyBlock", mock.Anything, mock.Anything, mock.Anything, respH1.Block, respH1.Commit).
Once().
Expand Down Expand Up @@ -419,6 +425,9 @@ func (suite *SynchronizerTestSuite) TestConsumeDuplicateThenDrain() {
On("ValidateBlock", mock.Anything, mock.Anything, mock.Anything).
Twice().
Return(nil)
suite.blockExec.
On("NoteVerifiedCommit", mock.Anything, mock.Anything, mock.Anything).
Maybe()
suite.blockExec.
On("ApplyBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Twice().
Expand Down Expand Up @@ -1209,6 +1218,9 @@ func (suite *SynchronizerTestSuite) newBacklogHarness() *backlogHarness {
On("ValidateBlock", mock.Anything, mock.Anything, mock.Anything).
Maybe().
Return(nil)
suite.blockExec.
On("NoteVerifiedCommit", mock.Anything, mock.Anything, mock.Anything).
Maybe()
suite.blockExec.
On("ApplyBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Maybe().
Expand Down Expand Up @@ -1659,6 +1671,9 @@ func (suite *SynchronizerTestSuite) TestClientTimeoutUnwedgesAFullWindow() {
On("ValidateBlock", mock.Anything, mock.Anything, mock.Anything).
Maybe().
Return(nil)
suite.blockExec.
On("NoteVerifiedCommit", mock.Anything, mock.Anything, mock.Anything).
Maybe()
suite.blockExec.
On("ApplyBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Maybe().
Expand Down
88 changes: 86 additions & 2 deletions internal/state/execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import (
"encoding/hex"
"errors"
"fmt"
"sync/atomic"
"time"

"github.com/dashpay/dashd-go/btcjson"
abciclient "github.com/dashpay/tenderdash/abci/client"
abci "github.com/dashpay/tenderdash/abci/types"
"github.com/dashpay/tenderdash/crypto"
Expand Down Expand Up @@ -51,6 +53,11 @@ type Executor interface {

ValidateBlock(ctx context.Context, state State, block *types.Block) error

// NoteVerifiedCommit records a commit the caller has just verified against
// state.Validators, so the executor can skip verifying it a second time when
// it reappears as the next block's LastCommit.
NoteVerifiedCommit(state State, blockID types.BlockID, commit *types.Commit)
Comment thread
PastaPastaPasta marked this conversation as resolved.
Outdated

ValidateBlockWithRoundState(
ctx context.Context,
state State,
Expand Down Expand Up @@ -108,6 +115,22 @@ type BlockExecutor struct {
// detect non-deterministic prepare proposal responses
lastRequestPrepareProposalHash []byte
lastResponsePrepareProposalHash []byte

// the commit most recently verified by a caller, so validateBlock can skip
// re-verifying it when it comes back as the next block's LastCommit
verifiedCommit atomic.Pointer[verifiedCommit]
Comment thread
PastaPastaPasta marked this conversation as resolved.
Outdated
}

// verifiedCommit pins every input ValidatorSet.verifyCommit reads, so a match
// means a repeat verification would be handed identical arguments.
type verifiedCommit struct {
chainID string
height int64
blockID types.BlockID
quorumType btcjson.LLMQType
quorumHash crypto.QuorumHash
Comment thread
PastaPastaPasta marked this conversation as resolved.
Outdated
thresholdKey crypto.PubKey
commit []byte
}

// BlockExecWithLogger is an option function to set a logger to BlockExecutor
Expand Down Expand Up @@ -408,7 +431,7 @@ func (blockExec *BlockExecutor) ValidateBlock(ctx context.Context, state State,
return nil
}

err := validateBlock(state, block)
err := validateBlock(state, block, blockExec.lastCommitAlreadyVerified(state, block))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

if err != nil {
return err
}
Expand All @@ -422,6 +445,60 @@ func (blockExec *BlockExecutor) ValidateBlock(ctx context.Context, state State,
return nil
}

// NoteVerifiedCommit records a successful VerifyCommit so the identical
// verification can be skipped when that commit reappears as the next block's
// LastCommit. Only the most recent one is kept: block sync applies blocks in
// order, so it is the only one that can match.
func (blockExec *BlockExecutor) NoteVerifiedCommit(state State, blockID types.BlockID, commit *types.Commit) {
if commit == nil || state.Validators == nil {
return
}
encoded, err := commit.ToProto().Marshal()
if err != nil {
blockExec.verifiedCommit.Store(nil)
Comment thread
PastaPastaPasta marked this conversation as resolved.
Outdated
return
}
blockExec.verifiedCommit.Store(&verifiedCommit{
chainID: state.ChainID,
height: commit.Height,
blockID: blockID,
quorumType: state.Validators.QuorumType,
quorumHash: state.Validators.QuorumHash,
thresholdKey: state.Validators.ThresholdPublicKey,
commit: encoded,
})
}

// lastCommitAlreadyVerified reports whether block.LastCommit was already
// verified against exactly the inputs validateBlock would use: same chain,
// height, block ID, quorum and threshold key, and a byte-identical commit.
// Anything short of a full match falls through to a real verification.
func (blockExec *BlockExecutor) lastCommitAlreadyVerified(state State, block *types.Block) bool {
vc := blockExec.verifiedCommit.Load()
if vc == nil || block.LastCommit == nil || state.LastValidators == nil {
return false
}
if vc.chainID != state.ChainID || vc.height != block.Height-1 {
return false
}
if !vc.blockID.Equals(state.LastBlockID) {
return false
}
if vc.quorumType != state.LastValidators.QuorumType ||
!vc.quorumHash.Equal(state.LastValidators.QuorumHash) {
return false
}
if vc.thresholdKey == nil || state.LastValidators.ThresholdPublicKey == nil ||
!vc.thresholdKey.Equals(state.LastValidators.ThresholdPublicKey) {
return false
}
got, err := block.LastCommit.ToProto().Marshal()
if err != nil {
return false
}
return bytes.Equal(vc.commit, got)
Comment thread
PastaPastaPasta marked this conversation as resolved.
Outdated
}

func (blockExec *BlockExecutor) ValidateBlockWithRoundState(
ctx context.Context,
state State,
Expand Down Expand Up @@ -450,7 +527,14 @@ func (blockExec *BlockExecutor) ValidateBlockWithRoundState(
)
}

if block.Height > state.InitialHeight {
// ValidateBlock above normally verifies block.LastCommit, but it short-circuits
// on its per-height cache, which is keyed on the block hash alone and so says
// nothing about the state the earlier validation ran against. That is why this
// second verification exists. Skip it only when the memo proves this exact
// commit was already verified against these exact inputs — same chain, height,
// block ID, quorum, threshold key and byte-identical commit — which is a
// property of the data rather than of the cache.
if block.Height > state.InitialHeight && !blockExec.lastCommitAlreadyVerified(state, block) {
if err := state.LastValidators.VerifyCommit(
state.ChainID, state.LastBlockID, block.Height-1, block.LastCommit); err != nil {
return fmt.Errorf("error validating block: %w", err)
Expand Down
52 changes: 52 additions & 0 deletions internal/state/mocks/executor.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 7 additions & 4 deletions internal/state/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import (
//-----------------------------------------------------
// Validate block

func validateBlock(state State, block *types.Block) error {
// lastCommitVerified tells validateBlock that block.LastCommit has already been
// verified against state.LastValidators by the caller. Block sync verifies each
// commit when it applies the block that commit belongs to, and the same commit
// comes back one height later as the next block's LastCommit; re-verifying it
// costs a second BLS threshold verification on every block.
func validateBlock(state State, block *types.Block, lastCommitVerified bool) error {
// Validate internal consistency.
if err := block.ValidateBasic(); err != nil {
Comment thread
PastaPastaPasta marked this conversation as resolved.
return err
Expand Down Expand Up @@ -79,9 +84,7 @@ func validateBlock(state State, block *types.Block) error {
if len(block.LastCommit.ThresholdBlockSignature) != 0 {
return errors.New("initial block can't have ThresholdBlockSignature set")
}
} else {
// fmt.Printf("validating against state with lastBlockId %s lastStateId %s\n", state.LastBlockID.String(),
// state.LastStateID.String())
} else if !lastCommitVerified {
// LastPrecommits.Signatures length is checked in VerifyCommit.
if err := state.LastValidators.VerifyCommit(
state.ChainID, state.LastBlockID, block.Height-1, block.LastCommit); err != nil {
Expand Down
Loading