diff --git a/sei-tendermint/internal/state/validation.go b/sei-tendermint/internal/state/validation.go index e04ac712dd..2b3155a3c3 100644 --- a/sei-tendermint/internal/state/validation.go +++ b/sei-tendermint/internal/state/validation.go @@ -11,6 +11,10 @@ import ( //----------------------------------------------------- // Validate block +// Swallow-eligible failure sites in this function consult +// types.ConsensusPolicy via ShouldSwallow(kind, err); the audit-row +// ErrorKind passed at each site is the cross-reference (see +// types.ErrorKind*). func validateBlock(state State, block *types.Block, policy types.ConsensusPolicy) error { // Validate internal consistency. if err := block.ValidateBasic(policy); err != nil { @@ -42,43 +46,57 @@ func validateBlock(state State, block *types.Block, policy types.ConsensusPolicy } // Validate prev block info. if !block.LastBlockID.Equals(state.LastBlockID) { - return fmt.Errorf("wrong Block.Header.LastBlockID. Expected %v, got %v", - state.LastBlockID, - block.LastBlockID, - ) + if err := policy.ShouldSwallow(types.ErrorKindLastBlockID, + fmt.Errorf("wrong Block.Header.LastBlockID. Expected %v, got %v", + state.LastBlockID, block.LastBlockID)); err != nil { + return err + } } - // Validate app info - if !policy.SkipAppHashValidation() && !bytes.Equal(block.AppHash, state.AppHash) { - return fmt.Errorf("wrong Block.Header.AppHash. Expected %X, got %v", - state.AppHash, - block.AppHash, - ) + // Validate app info. + if !bytes.Equal(block.AppHash, state.AppHash) { + if err := policy.ShouldSwallow(types.ErrorKindAppHash, + fmt.Errorf("wrong Block.Header.AppHash. Expected %X, got %v", + state.AppHash, block.AppHash)); err != nil { + return err + } } hashCP := state.ConsensusParams.HashConsensusParams() if !bytes.Equal(block.ConsensusHash, hashCP) { - return fmt.Errorf("wrong Block.Header.ConsensusHash. Expected %X, got %v", - hashCP, - block.ConsensusHash, - ) + if err := policy.ShouldSwallow(types.ErrorKindConsensusHash, + fmt.Errorf("wrong Block.Header.ConsensusHash. Expected %X, got %v", + hashCP, block.ConsensusHash)); err != nil { + return err + } } - if !types.SkipLastResultsHashValidation.Load() && !bytes.Equal(block.LastResultsHash, state.LastResultsHash) { - return fmt.Errorf("wrong Block.Header.LastResultsHash. Expected %X, got %v", - state.LastResultsHash, - block.LastResultsHash, - ) + // Giga production escape hatch: tmtypes.SkipLastResultsHashValidation + // is set unconditionally by Giga at app init (app.go:749) and is + // load-bearing for Giga's production halt-resistance on + // LastResultsHash. This is the only Skip*-style early-return preserved + // in the codebase; migrating Giga onto a build-tagged ConsensusPolicy + // variant is its own future workstream. + if !types.SkipLastResultsHashValidation.Load() { + if !bytes.Equal(block.LastResultsHash, state.LastResultsHash) { + if err := policy.ShouldSwallow(types.ErrorKindLastResultsHash, + fmt.Errorf("wrong Block.Header.LastResultsHash. Expected %X, got %v", + state.LastResultsHash, block.LastResultsHash)); err != nil { + return err + } + } } if !bytes.Equal(block.ValidatorsHash, state.Validators.Hash()) { - return fmt.Errorf("wrong Block.Header.ValidatorsHash. Expected %X, got %v", - state.Validators.Hash(), - block.ValidatorsHash, - ) + if err := policy.ShouldSwallow(types.ErrorKindValidatorsHash, + fmt.Errorf("wrong Block.Header.ValidatorsHash. Expected %X, got %v", + state.Validators.Hash(), block.ValidatorsHash)); err != nil { + return err + } } if !bytes.Equal(block.NextValidatorsHash, state.NextValidators.Hash()) { - return fmt.Errorf("wrong Block.Header.NextValidatorsHash. Expected %X, got %v", - state.NextValidators.Hash(), - block.NextValidatorsHash, - ) + if err := policy.ShouldSwallow(types.ErrorKindNextValidatorsHash, + fmt.Errorf("wrong Block.Header.NextValidatorsHash. Expected %X, got %v", + state.NextValidators.Hash(), block.NextValidatorsHash)); err != nil { + return err + } } // Validate block LastCommit. @@ -90,7 +108,10 @@ func validateBlock(state State, block *types.Block, policy types.ConsensusPolicy // LastCommit.Signatures length is checked in VerifyCommit. if err := state.LastValidators.VerifyCommit( state.ChainID, state.LastBlockID, block.Height-1, block.LastCommit); err != nil { - return fmt.Errorf("VerifyCommit(): %w", err) + if swErr := policy.ShouldSwallow(types.ErrorKindLastCommitVerify, + fmt.Errorf("VerifyCommit(): %w", err)); swErr != nil { + return swErr + } } } @@ -99,9 +120,11 @@ func validateBlock(state State, block *types.Block, policy types.ConsensusPolicy // a legit address and a known validator. // The length is checked in ValidateBasic above. if !state.Validators.HasAddress(block.ProposerAddress) { - return fmt.Errorf("block.Header.ProposerAddress %X is not a validator", - block.ProposerAddress, - ) + if err := policy.ShouldSwallow(types.ErrorKindProposerNotInValidatorSet, + fmt.Errorf("block.Header.ProposerAddress %X is not a validator", + block.ProposerAddress)); err != nil { + return err + } } // Validate block Time @@ -130,7 +153,10 @@ func validateBlock(state State, block *types.Block, policy types.ConsensusPolicy // Check evidence doesn't exceed the limit amount of bytes. if max, got := state.ConsensusParams.Evidence.MaxBytes, block.Evidence.ByteSize(); got > max { - return types.NewErrEvidenceOverflow(max, got) + if err := policy.ShouldSwallow(types.ErrorKindEvidenceOverflow, + types.NewErrEvidenceOverflow(max, got)); err != nil { + return err + } } return nil diff --git a/sei-tendermint/types/block.go b/sei-tendermint/types/block.go index 836abaea31..1f55bb83d5 100644 --- a/sei-tendermint/types/block.go +++ b/sei-tendermint/types/block.go @@ -91,26 +91,36 @@ func (b *Block) ValidateBasic(policy ConsensusPolicy) error { if w, g := b.LastCommit.Hash(), b.LastCommitHash; !bytes.Equal(w, g) { // Fall back to legacy hash calculation pre-6.4. if wLegacy := b.LastCommit.legacyHash(); !bytes.Equal(wLegacy, g) { - return fmt.Errorf("wrong Header.LastCommitHash. Expected %X, got %X", w, g) + if err := policy.ShouldSwallow(ErrorKindLastCommitHash, + fmt.Errorf("wrong Header.LastCommitHash. Expected %X, got %X", w, g)); err != nil { + return err + } } } - if !policy.SkipDataHashValidation() { - // NOTE: b.Data.Txs may be nil, but b.Data.Hash() still works fine. - if w, g := b.Data.Hash(false), b.DataHash; !bytes.Equal(w, g) { - return fmt.Errorf("wrong Header.DataHash. Expected %X, got %X. Len of txs %d", w, g, len(b.Txs)) + // NOTE: b.Data.Txs may be nil, but b.Data.Hash() still works fine. + if w, g := b.Data.Hash(false), b.DataHash; !bytes.Equal(w, g) { + if err := policy.ShouldSwallow(ErrorKindDataHash, + fmt.Errorf("wrong Header.DataHash. Expected %X, got %X. Len of txs %d", w, g, len(b.Txs))); err != nil { + return err } } // NOTE: b.Evidence may be nil, but we're just looping. for i, ev := range b.Evidence { if err := ev.ValidateBasic(); err != nil { - return fmt.Errorf("invalid evidence (#%d): %v", i, err) + if swErr := policy.ShouldSwallow(ErrorKindPerEvidenceValidateBasic, + fmt.Errorf("invalid evidence (#%d): %v", i, err)); swErr != nil { + return swErr + } } } if w, g := b.Evidence.Hash(), b.EvidenceHash; !bytes.Equal(w, g) { - return fmt.Errorf("wrong Header.EvidenceHash. Expected %X, got %X", w, g) + if err := policy.ShouldSwallow(ErrorKindEvidenceHash, + fmt.Errorf("wrong Header.EvidenceHash. Expected %X, got %X", w, g)); err != nil { + return err + } } return nil diff --git a/sei-tendermint/types/consensus_policy.go b/sei-tendermint/types/consensus_policy.go index c7c848ecb6..4eef560114 100644 --- a/sei-tendermint/types/consensus_policy.go +++ b/sei-tendermint/types/consensus_policy.go @@ -1,7 +1,62 @@ +// Package types — ConsensusPolicy is a zero-sized, build-tag-selected gate +// that decides, per ErrorKind, whether a halting validation failure halts +// (default) or is swallowed (counter incremented, then continued). The +// single method ShouldSwallow(kind, err) is declared in exactly one of three +// per-tag files, so each binary compiles in one fixed policy with no runtime +// branch: +// +// default (production) → returns err for every kind; production halting +// semantics are unchanged +// mock_block_validation → returns nil for AppHash and DataHash; preserves +// the long-standing behavior of that tag +// mock_chain_validation → returns nil for every swallow-eligible audit-row +// kind (M2 deliverable) +// +// One Skip*-style early-return is preserved alongside the policy: +// tmtypes.SkipLastResultsHashValidation; see validation.go for context. package types -// DefaultConsensusPolicy returns the zero-value policy. Behavior is -// build-tag-dependent: production builds enforce every check; mock_block_validation -// builds bypass every gated check (see consensus_policy_default.go and -// consensus_policy_mock_block_validation.go). +// DefaultConsensusPolicy returns the zero-value policy for the current build. func DefaultConsensusPolicy() ConsensusPolicy { return ConsensusPolicy{} } + +// ErrorKind names a swallow-eligible validation failure. Constants +// correspond to rows in the M1.0 audit (docs/designs/ +// mock-chain-validation-m1-audit.md); the string value is the metric label +// emitted on sei_unsafe_validation_skipped_total{kind=...}. +type ErrorKind string + +const ( + ErrorKindAppHash ErrorKind = "app_hash" + ErrorKindDataHash ErrorKind = "data_hash" + ErrorKindLastResultsHash ErrorKind = "last_results_hash" + ErrorKindLastBlockID ErrorKind = "last_block_id" + ErrorKindConsensusHash ErrorKind = "consensus_hash" + ErrorKindValidatorsHash ErrorKind = "validators_hash" + ErrorKindNextValidatorsHash ErrorKind = "next_validators_hash" + ErrorKindLastCommitVerify ErrorKind = "last_commit_verify" + ErrorKindProposerNotInValidatorSet ErrorKind = "proposer_not_in_validator_set" + ErrorKindEvidenceOverflow ErrorKind = "evidence_overflow" + ErrorKindLastCommitHash ErrorKind = "last_commit_hash" + ErrorKindEvidenceHash ErrorKind = "evidence_hash" + ErrorKindPerEvidenceValidateBasic ErrorKind = "per_evidence_validate_basic" +) + +// AllSwallowEligibleErrorKinds returns the audit's swallow-eligible set. +// Tests iterate this list to assert the per-variant matrix. +func AllSwallowEligibleErrorKinds() []ErrorKind { + return []ErrorKind{ + ErrorKindAppHash, + ErrorKindDataHash, + ErrorKindLastResultsHash, + ErrorKindLastBlockID, + ErrorKindConsensusHash, + ErrorKindValidatorsHash, + ErrorKindNextValidatorsHash, + ErrorKindLastCommitVerify, + ErrorKindProposerNotInValidatorSet, + ErrorKindEvidenceOverflow, + ErrorKindLastCommitHash, + ErrorKindEvidenceHash, + ErrorKindPerEvidenceValidateBasic, + } +} diff --git a/sei-tendermint/types/consensus_policy_default.go b/sei-tendermint/types/consensus_policy_default.go index 783892743d..b809f9e6a3 100644 --- a/sei-tendermint/types/consensus_policy_default.go +++ b/sei-tendermint/types/consensus_policy_default.go @@ -1,10 +1,7 @@ -//go:build !mock_block_validation +//go:build !mock_block_validation && !mock_chain_validation package types -// ConsensusPolicy is empty in production builds. Its methods return -// constant false so the compiler DCEs the bypass branches. type ConsensusPolicy struct{} -func (ConsensusPolicy) SkipAppHashValidation() bool { return false } -func (ConsensusPolicy) SkipDataHashValidation() bool { return false } +func (ConsensusPolicy) ShouldSwallow(_ ErrorKind, err error) error { return err } diff --git a/sei-tendermint/types/consensus_policy_mock_block_validation.go b/sei-tendermint/types/consensus_policy_mock_block_validation.go index 65f8d7fdf9..2eeddc3d83 100644 --- a/sei-tendermint/types/consensus_policy_mock_block_validation.go +++ b/sei-tendermint/types/consensus_policy_mock_block_validation.go @@ -2,9 +2,18 @@ package types -// ConsensusPolicy is empty in mock_block_validation builds. Each Skip* -// method returns true unconditionally — running this binary IS the bypass. +// Swallow set is AppHash + DataHash only — these are the two checks the +// mock_block_validation tag has always relaxed; preserving that exact set +// keeps user-visible outcomes under this tag unchanged across the refactor. +// All other audit-row kinds halt as in production. type ConsensusPolicy struct{} -func (ConsensusPolicy) SkipAppHashValidation() bool { return true } -func (ConsensusPolicy) SkipDataHashValidation() bool { return true } +func (ConsensusPolicy) ShouldSwallow(kind ErrorKind, err error) error { + switch kind { + case ErrorKindAppHash, ErrorKindDataHash: + unsafeValidationSkippedTotal.WithLabelValues(string(kind)).Inc() + return nil + default: + return err + } +} diff --git a/sei-tendermint/types/consensus_policy_mock_block_validation_test.go b/sei-tendermint/types/consensus_policy_mock_block_validation_test.go new file mode 100644 index 0000000000..d2ef99d208 --- /dev/null +++ b/sei-tendermint/types/consensus_policy_mock_block_validation_test.go @@ -0,0 +1,53 @@ +//go:build mock_block_validation + +package types + +import ( + "errors" + "testing" +) + +func TestConsensusPolicy_MockBlockValidation_Matrix(t *testing.T) { + policy := DefaultConsensusPolicy() + testErr := errors.New("sentinel") + swallowExpected := map[ErrorKind]bool{ + ErrorKindAppHash: true, + ErrorKindDataHash: true, + ErrorKindLastResultsHash: false, + ErrorKindLastBlockID: false, + ErrorKindConsensusHash: false, + ErrorKindValidatorsHash: false, + ErrorKindNextValidatorsHash: false, + ErrorKindLastCommitVerify: false, + ErrorKindProposerNotInValidatorSet: false, + ErrorKindEvidenceOverflow: false, + ErrorKindLastCommitHash: false, + ErrorKindEvidenceHash: false, + ErrorKindPerEvidenceValidateBasic: false, + } + for _, kind := range AllSwallowEligibleErrorKinds() { + swallow, ok := swallowExpected[kind] + if !ok { + t.Errorf("test matrix missing entry for ErrorKind %q — audit added a new row?", kind) + continue + } + got := policy.ShouldSwallow(kind, testErr) + if swallow { + if got != nil { + t.Errorf("mock_block_validation ConsensusPolicy.ShouldSwallow(%q, testErr) = %v, want nil", kind, got) + } + } else { + if got != testErr { + t.Errorf("mock_block_validation ConsensusPolicy.ShouldSwallow(%q, testErr) = %v, want testErr", kind, got) + } + } + } +} + +func TestConsensusPolicy_MockBlockValidation_UnknownKindReturnsErr(t *testing.T) { + policy := DefaultConsensusPolicy() + testErr := errors.New("sentinel") + if got := policy.ShouldSwallow(ErrorKind("not_a_real_kind"), testErr); got != testErr { + t.Errorf("mock_block_validation ConsensusPolicy.ShouldSwallow(unknown, testErr) = %v, want testErr", got) + } +} diff --git a/sei-tendermint/types/consensus_policy_test.go b/sei-tendermint/types/consensus_policy_test.go new file mode 100644 index 0000000000..e627ec43a4 --- /dev/null +++ b/sei-tendermint/types/consensus_policy_test.go @@ -0,0 +1,35 @@ +//go:build !mock_block_validation && !mock_chain_validation + +package types + +import ( + "errors" + "testing" +) + +func TestConsensusPolicy_Default_AllKindsReturnErr(t *testing.T) { + policy := DefaultConsensusPolicy() + testErr := errors.New("sentinel") + for _, kind := range AllSwallowEligibleErrorKinds() { + if got := policy.ShouldSwallow(kind, testErr); got != testErr { + t.Errorf("default ConsensusPolicy.ShouldSwallow(%q, testErr) = %v, want testErr", kind, got) + } + } +} + +func TestConsensusPolicy_Default_UnknownKindReturnsErr(t *testing.T) { + policy := DefaultConsensusPolicy() + testErr := errors.New("sentinel") + if got := policy.ShouldSwallow(ErrorKind("not_a_real_kind"), testErr); got != testErr { + t.Errorf("default ConsensusPolicy.ShouldSwallow(unknown, testErr) = %v, want testErr", got) + } +} + +// Guards the M1.0 audit's 13-row invariant — a change here means the audit +// (docs/designs/mock-chain-validation-m1-audit.md) needs to be revisited. +func TestAllSwallowEligibleErrorKinds_Count(t *testing.T) { + got := len(AllSwallowEligibleErrorKinds()) + if got != 13 { + t.Errorf("AllSwallowEligibleErrorKinds() returned %d kinds, want 13 (per M1.0 audit)", got) + } +} diff --git a/sei-tendermint/types/swallow.go b/sei-tendermint/types/swallow.go new file mode 100644 index 0000000000..fe61d565c0 --- /dev/null +++ b/sei-tendermint/types/swallow.go @@ -0,0 +1,16 @@ +package types + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// unsafeValidationSkippedTotal counts halting validation failures that were +// swallowed by a non-default ConsensusPolicy. Always zero in production builds. +var unsafeValidationSkippedTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "sei_unsafe_validation_skipped_total", + Help: "Halting validation failures swallowed by a non-default ConsensusPolicy (mock_block_validation, mock_chain_validation). Always zero in production builds.", + }, + []string{"kind"}, +)