diff --git a/app/lep6_module_order_test.go b/app/lep6_module_order_test.go new file mode 100644 index 00000000..1d70fc9d --- /dev/null +++ b/app/lep6_module_order_test.go @@ -0,0 +1,38 @@ +package app + +import ( + "testing" + + actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + supernodetypes "github.com/LumeraProtocol/lumera/x/supernode/v1/types" + "github.com/stretchr/testify/require" +) + +func TestLEP6ModuleOrderingPinsSupernodeAuditAction(t *testing.T) { + assertOrdered := func(t *testing.T, name string, modules []string) { + t.Helper() + supernodeIdx := indexOfModule(modules, supernodetypes.ModuleName) + auditIdx := indexOfModule(modules, audittypes.ModuleName) + actionIdx := indexOfModule(modules, actiontypes.ModuleName) + + require.NotEqual(t, -1, supernodeIdx, "%s missing supernode module", name) + require.NotEqual(t, -1, auditIdx, "%s missing audit module", name) + require.NotEqual(t, -1, actionIdx, "%s missing action module", name) + require.Less(t, supernodeIdx, auditIdx, "%s must run supernode before audit for LEP-6 dependency ordering", name) + require.Less(t, auditIdx, actionIdx, "%s must run audit before action so action finalization can anchor LEP-6 artifact counts", name) + } + + assertOrdered(t, "genesisModuleOrder", genesisModuleOrder) + assertOrdered(t, "beginBlockers", beginBlockers) + assertOrdered(t, "endBlockers", endBlockers) +} + +func indexOfModule(modules []string, target string) int { + for i, module := range modules { + if module == target { + return i + } + } + return -1 +} diff --git a/docs/leps/LEP-6-implementation-guide.md b/docs/leps/LEP-6-implementation-guide.md index 8f1b5be3..6faab30c 100644 --- a/docs/leps/LEP-6-implementation-guide.md +++ b/docs/leps/LEP-6-implementation-guide.md @@ -4,7 +4,7 @@ This guide documents the `lumera` implementation of LEP-6 storage-truth enforcem Priority design source: `/home/openclaw/workspace/docs/LEP6.md` -Branch: `LEP-6-consensus-gap-fixes-rebase` @ `5df4206` (rebased onto post-#118 `LEP-6-foundation`) +Branch: `LEP-6-foundation-review-r3` (R3 hardening branch; 20/20 Zee R3 findings addressed locally, pending push) ## Reviewer Summary @@ -117,7 +117,7 @@ Business rules: - Non-empty proof results require `INDEX` or `SYMBOL`. - `NO_ELIGIBLE_TICKET` requires `UNSPECIFIED`. -- Index failures are treated as Class A faults and also satisfy strong-postpone and heal-eligibility predicates. +- Class A is result-class driven: `HASH_MISMATCH` (with INDEX vs SYMBOL magnitude) and `RECHECK_CONFIRMED_FAIL`. `ArtifactClass == INDEX` alone does **not** make `TIMEOUT_OR_NO_RESPONSE` a Class A fault. ### Result Enum Values @@ -1383,7 +1383,7 @@ Behavior deltas applied on top of `LEP-6-foundation` tip `868cbc7c` to close 24 production-gate findings. Branch `LEP-6-foundation-review-fixes` (squashed single commit `0c6f5f0`). Test pyramid green at `0c6f5f0`: unit + module simulation + `tests/integration/` + `tests/system/` + `tests/systemtests/` -(`-tags=system_test`, 25/25 PASS). Compare: +(`-tags=system_test`, 25/25 PASS). Final pushed SHA was `df15913` after docs-only amend. Compare: [`LEP-6-foundation...LEP-6-foundation-review-fixes`](https://github.com/LumeraProtocol/lumera/compare/LEP-6-foundation...LEP-6-foundation-review-fixes). ### HIGH (consensus / state-correctness / money-flow) @@ -1523,6 +1523,98 @@ below) and codified as Skill Pitfall #31 in the --- +## LEP-6 Round-3 Review Hardening (Zee R3 — PR #117 review 4188900358) + +Behavior deltas applied on top of the post-R2 `LEP-6-foundation` tip `8748065` +to close 20 additional production-gate findings (3 HIGH / 3 MEDIUM / 14 LOW). +Branch `LEP-6-foundation-review-r3` is a single-commit delivery branch. Test +pyramid green before push: targeted R3 tests, `go build ./...`, audit/action/app +unit packages, `go test ./x/...`, action+audit integration tests, +`go test -tags=system ./tests/system/...`, and full e2e systemtests +`go test -tags=system_test -timeout=1800s -v .` with +`ok github.com/LumeraProtocol/lumera/tests/systemtests 1031.464s`. +Compare after push: +[`LEP-6-foundation...LEP-6-foundation-review-r3`](https://github.com/LumeraProtocol/lumera/compare/LEP-6-foundation...LEP-6-foundation-review-r3). + +### HIGH (state-correctness / production safety) + +- **B-F1 — reporter clean-pass reward scans all result classes.** + `storageTruthReporterEpochPassStats` now counts PASS results while detecting + overturned failure-class records across the epoch, so a reporter does not earn + the per-epoch `-4` reliability reward when any failure was overturned by + recheck. +- **C-F1 — strong-recovery clean-pass param is validated.** + `Params.Validate()` rejects `StorageTruthStrongRecoveryCleanPassCount <= 0` + and values below `StorageTruthRecoveryCleanPassCount`, preventing impossible + strong-postpone recovery semantics. +- **C-F3 — retention migration covers every LEP-6 lookback window.** + `requiredHistory` and the v1→v2 migration include + `StorageTruthDivergenceWindowEpochs` and `StorageTruthHealDeadlineEpochs`, so + pruning cannot erase evidence still needed by divergence or heal-deadline + logic. + +### MEDIUM (semantic correctness / sibling symmetry) + +- **C-F2 — heal verifier count bounded.** + `StorageTruthHealVerifierCount` is constrained to `1..32`, preventing + unbounded verifier loops or invalid zero-quorum behavior. +- **B-F2 — Class-A predicate tightened to result class.** + `TIMEOUT_OR_NO_RESPONSE` on an INDEX artifact remains Class B and unscaled; + only `HASH_MISMATCH` / `RECHECK_CONFIRMED_FAIL` enter Class-A scoring paths. + This corrected the prior R2 approximation that treated `ArtifactClass==INDEX` + alone as Class A. +- **C-F4 — action test fixture exercises reward routing as a no-op.** + `ActionKeeperWithAddress` now uses `MockRewardDistributionKeeper{Bps:0}` + rather than nil, so tests do not bypass the production fee-routing branch. + +### LOW (defensive hardening / genesis closure / review guardrails) + +- **C-F5 — postpone thresholds are strictly ordered.** Equality between + postpone and strong-postpone thresholds is rejected. +- **A-F1 — heal-op ID counter recovery matches evidence ID recovery.** + Malformed/missing/zero counters no longer panic or risk reuse; the next ID is + derived from existing heal ops. +- **A-F2 / B-F3 — MaxUint64-safe epoch scans.** Named range helpers avoid + `endEpoch+1` overflow for reporter-result and transcript scans. +- **A-F4 — genesis validates `TicketArtifactCountState`.** Empty ticket IDs and + all-zero counts are rejected. +- **A-F5 — transcript genesis import rejects unknown/trailing JSON fields.** + Prevents silent import drift. +- **A-F6 — genesis round-trip now seeds and verifies non-empty node-failure and + reporter-result facts.** +- **B-F4 — failed-heal marker setter errors on empty healer/ticket and callers + propagate the error.** No more silent no-op state holes. +- **B-F5 — clean-epoch recovery creates state for fresh reporters with PASS + facts.** Dashboards/reliability state no longer undercount new reporters. +- **B-F6 — cross-holder PASS bonus coverage spans non-hash prior failures.** + TIMEOUT, OBSERVER_QUORUM_FAIL, and INVALID_TRANSCRIPT prior classes are now + covered, not just HASH_MISMATCH. +- **C-F6 — strict artifact-count fallback.** Explicit metadata counts win; + legacy metadata falls back to `len(RqIdsIds)`; if both are absent the path + returns an error instead of silently anchoring `(0,0)`. +- **C-F7 — module order pinned by app-level test.** Genesis/begin/end ordering + must remain `supernode → audit → action`. +- **C-F9 — migration-position closure.** Audit consensus version remains `2`; + R3 backfills are folded into `NewMigrateV1ToV2` because v2 has not shipped. + +### Why R2 missed these (process retrospective) + +R3 contained no outright reviewer flip-flops. The misses fell into three buckets: +(1) **refinements** of R2 approximations (Class-A predicate and heal-op counter +recovery), (2) **incomplete R2 implementation** where new params or promoted +rules lacked a validation bound, and (3) **latent sibling-symmetry/genesis +closure items** discovered by a deeper production-gate pass. Going forward, +review closure requires not only the R2 sweeps but also MaxUint64 boundary tests, +genesis unknown-field rejection, strict fallback behavior for legacy metadata, +and app-level module-order pinning. + +### No new params introduced this round + +R3 added validation and migration coverage for existing R2/R3 state surfaces but +introduced no additional governance params or protobuf fields. + +--- + ## Pre-Release Checklist This section is the canonical aggregator of every operational, follow-up, @@ -1577,7 +1669,8 @@ Source-of-truth references: `ACTIVE_WORK.md` (in-flight tracking), ### C. Review-process sweeps (mandatory before each release-gate PR merge) These three sweeps were missed in round 1 and produced the 24 R2 -findings. They are now mandatory pre-master gates per Skill Pitfall #31. +findings; R3 added sibling-boundary checks for MaxUint64 ranges, strict +fallbacks, and genesis import hardening. They are mandatory pre-master gates. - [ ] **Out-of-scope diff sweep** — `git diff ..HEAD --stat` filtered to files outside the announced scope; flag any deletion or @@ -1616,9 +1709,11 @@ findings. They are now mandatory pre-master gates per Skill Pitfall #31. 7. Cycle to recovery (clean passes); verify postponement→active transition and (for strong postpone) the new `StorageTruthStrongRecoveryCleanPassCount` gate. - 8. Verify R2 deltas land as designed: a cross-holder PASS produces + 8. Verify R2/R3 deltas land as designed: a cross-holder PASS produces `D -= 3` extra; per-epoch PASS reward is single `-4` (not - per-result); EXPIRED heal-op advances probation and bumps `D`. + per-result); EXPIRED heal-op advances probation and bumps `D`; + `TIMEOUT_OR_NO_RESPONSE` on INDEX remains Class B; legacy action metadata + with neither explicit counts nor `RqIdsIds` errors instead of anchoring `(0,0)`. ### E. Test pyramid re-validation at activation tag @@ -1628,7 +1723,7 @@ findings. They are now mandatory pre-master gates per Skill Pitfall #31. - [ ] `./tests/integration/...` green. - [ ] `./tests/system/...` (`-tags=system`) green. - [ ] `./tests/systemtests/...` (`-tags=system_test`) green - (25/25 last verified at `0c6f5f0`). + (last verified for R3 at `LEP-6-foundation-review-r3`: `ok .../tests/systemtests 1031.464s`). - [ ] Determinism scan clean — `grep -rE 'float|math\.Pow|time\.Now|rand\.|sort\.Float|FormatFloat'` on `x/audit/v1/keeper` returns zero hits in consensus paths; no `range map[]` in scoring/divergence/enforcement consensus paths. @@ -1644,9 +1739,10 @@ findings. They are now mandatory pre-master gates per Skill Pitfall #31. gas-requirements note (above) in operator docs. Include in the SOFT/FULL activation proposal body so all participants see it before voting. -- [ ] **Operator changelog** — publish the R2 behavior deltas (new params, +- [ ] **Operator changelog** — publish the R2/R3 behavior deltas (new params, per-epoch PASS reward semantics, EXPIRED heal-op cooldown, strong-band - recovery threshold, cross-holder PASS bonus) in the release notes so + recovery threshold, cross-holder PASS bonus, Class-A predicate tightening, + strict artifact-count fallback) in the release notes so operators understand observable score-evolution changes. ### G. Documentation queue close-out @@ -1654,8 +1750,8 @@ findings. They are now mandatory pre-master gates per Skill Pitfall #31. - [ ] `.lep6-review-pending-doc-updates/CP1_TRIAGE.md` and `CP2_SPEC_ALIGNMENT.md` items resolved or explicitly deferred with rationale. -- [ ] `.lep6-review-pending-doc-updates/r2/` items reflected in this - guide (this section) and in `workspace/docs/LEP6.md` where the spec - text needed correction (NF7 done; sweep for any further drift). -- [ ] `docs/agent-context/02_lumera.md` updated with R2 behavior deltas - and new params for cross-session continuity. +- [ ] `.lep6-review-pending-doc-updates/r2/` and R3 review items reflected in this + guide and in `workspace/docs/LEP6.md` where the spec text needed correction + (NF7 done; sweep for any further drift). +- [ ] `docs/agent-context/02_lumera.md` updated with R2/R3 behavior deltas + and new params/validation hardening for cross-session continuity. diff --git a/testutil/keeper/action.go b/testutil/keeper/action.go index bbb77d29..afe92720 100644 --- a/testutil/keeper/action.go +++ b/testutil/keeper/action.go @@ -365,7 +365,7 @@ func ActionKeeperWithAddress(t testing.TB, ctrl *gomock.Controller, accounts []A func() *ibckeeper.Keeper { return ibckeeper.NewKeeper(encCfg.Codec, storeService, newMockIbcParams(), mockUpgradeKeeper, authority.String()) }, - nil, + &MockRewardDistributionKeeper{Bps: 0}, // Per CP-R3 C-F4 — exercise reward-routing branch as no-op, not nil short-circuit. ) // Initialize params diff --git a/x/action/v1/keeper/action.go b/x/action/v1/keeper/action.go index a27804d1..423f379c 100644 --- a/x/action/v1/keeper/action.go +++ b/x/action/v1/keeper/action.go @@ -250,7 +250,10 @@ func (k *Keeper) FinalizeAction(ctx sdk.Context, actionID string, superNodeAccou if err := gogoproto.Unmarshal(existingAction.Metadata, &cascadeMeta); err != nil { return errors.Wrap(actiontypes.ErrInvalidMetadata, fmt.Sprintf("failed to unmarshal finalized cascade metadata: %v", err)) } - indexCount, symbolCount := actiontypes.CascadeArtifactCountsWithFallback(&cascadeMeta) + indexCount, symbolCount, err := actiontypes.CascadeArtifactCountsWithFallbackStrict(&cascadeMeta) + if err != nil { + return errors.Wrap(actiontypes.ErrInvalidMetadata, err.Error()) + } if err := k.auditKeeper.SetStorageTruthTicketArtifactCounts( ctx, existingAction.ActionID, diff --git a/x/action/v1/keeper/action_cascade.go b/x/action/v1/keeper/action_cascade.go index cdaded45..5f8740cc 100644 --- a/x/action/v1/keeper/action_cascade.go +++ b/x/action/v1/keeper/action_cascade.go @@ -147,8 +147,11 @@ func (h CascadeActionHandler) Process(metadataBytes []byte, msgType common.Messa // Backward-compatible fallback for finalize payloads that do not yet // provide explicit LEP-6 artifact counts (single-source-of-truth via // CascadeArtifactCountsWithFallback per CP-NEW-C-2 / 122-F2). - metadata.IndexArtifactCount, metadata.SymbolArtifactCount = - actiontypes.CascadeArtifactCountsWithFallback(&metadata) + indexCount, symbolCount, err := actiontypes.CascadeArtifactCountsWithFallbackStrict(&metadata) + if err != nil { + return nil, err + } + metadata.IndexArtifactCount, metadata.SymbolArtifactCount = indexCount, symbolCount default: return nil, fmt.Errorf("unsupported message type: %s", msgType) } @@ -323,8 +326,11 @@ func (h CascadeActionHandler) GetUpdatedMetadata(ctx sdk.Context, existingMetada IndexArtifactCount: newMetadata.GetIndexArtifactCount(), SymbolArtifactCount: newMetadata.GetSymbolArtifactCount(), } - updatedMetadata.IndexArtifactCount, updatedMetadata.SymbolArtifactCount = - actiontypes.CascadeArtifactCountsWithFallback(updatedMetadata) + indexCount, symbolCount, err := actiontypes.CascadeArtifactCountsWithFallbackStrict(updatedMetadata) + if err != nil { + return nil, errors.Wrap(actiontypes.ErrInvalidMetadata, err.Error()) + } + updatedMetadata.IndexArtifactCount, updatedMetadata.SymbolArtifactCount = indexCount, symbolCount return gogoproto.Marshal(updatedMetadata) } diff --git a/x/action/v1/types/metadata.go b/x/action/v1/types/metadata.go index dfb1c886..ac6dce48 100644 --- a/x/action/v1/types/metadata.go +++ b/x/action/v1/types/metadata.go @@ -1,15 +1,27 @@ package types +import "fmt" + // CascadeArtifactCountsWithFallback returns the (index, symbol) artifact // counts from a CascadeMetadata, falling back to len(RqIdsIds) when either // field is zero. This is the single-source-of-truth helper enforcing the // 122-F2 fallback rule across all sites that consume cascade artifact // counts (Process, GetUpdatedMetadata, FinalizeAction → audit hook). // -// If meta is nil, returns (0, 0). +// If meta is nil, returns (0, 0). Callers that make consensus/state decisions +// must use CascadeArtifactCountsWithFallbackStrict so malformed metadata cannot +// silently resolve to a zero-count artifact universe. func CascadeArtifactCountsWithFallback(meta *CascadeMetadata) (uint32, uint32) { + idx, sym, _ := CascadeArtifactCountsWithFallbackStrict(meta) + return idx, sym +} + +// CascadeArtifactCountsWithFallbackStrict is the consensus-safe variant of +// CascadeArtifactCountsWithFallback. It rejects metadata where explicit counts +// are missing and RqIdsIds cannot provide the backward-compatible fallback. +func CascadeArtifactCountsWithFallbackStrict(meta *CascadeMetadata) (uint32, uint32, error) { if meta == nil { - return 0, 0 + return 0, 0, fmt.Errorf("cascade metadata is required") } idx := meta.GetIndexArtifactCount() sym := meta.GetSymbolArtifactCount() @@ -20,5 +32,8 @@ func CascadeArtifactCountsWithFallback(meta *CascadeMetadata) (uint32, uint32) { if sym == 0 { sym = fallback } - return idx, sym + if idx == 0 || sym == 0 { + return 0, 0, fmt.Errorf("cascade artifact counts unavailable: explicit index/symbol counts missing and rq_ids_ids empty") + } + return idx, sym, nil } diff --git a/x/action/v1/types/metadata_proto_test.go b/x/action/v1/types/metadata_proto_test.go index 3ad6b0e0..60896fbd 100644 --- a/x/action/v1/types/metadata_proto_test.go +++ b/x/action/v1/types/metadata_proto_test.go @@ -106,3 +106,21 @@ func TestCascadeMetadataRoundTripWithNewFields(t *testing.T) { require.NoError(t, proto.Unmarshal(bz, &decoded)) require.Equal(t, extended, &decoded) } + +func TestCascadeArtifactCountsWithFallbackStrictRejectsEmptyFallbackUniverse(t *testing.T) { + idx, sym, err := CascadeArtifactCountsWithFallbackStrict(&CascadeMetadata{}) + require.Error(t, err) + require.Zero(t, idx) + require.Zero(t, sym) + require.Contains(t, err.Error(), "rq_ids_ids empty") + + idx, sym, err = CascadeArtifactCountsWithFallbackStrict(&CascadeMetadata{RqIdsIds: []string{"rq-1", "rq-2"}}) + require.NoError(t, err) + require.Equal(t, uint32(2), idx) + require.Equal(t, uint32(2), sym) + + idx, sym, err = CascadeArtifactCountsWithFallbackStrict(&CascadeMetadata{IndexArtifactCount: 4, SymbolArtifactCount: 9}) + require.NoError(t, err) + require.Equal(t, uint32(4), idx) + require.Equal(t, uint32(9), sym) +} diff --git a/x/audit/v1/keeper/export_test.go b/x/audit/v1/keeper/export_test.go index 7344346d..fdbfd23a 100644 --- a/x/audit/v1/keeper/export_test.go +++ b/x/audit/v1/keeper/export_test.go @@ -1,6 +1,8 @@ package keeper import ( + "encoding/json" + sdk "github.com/cosmos/cosmos-sdk/types" "github.com/LumeraProtocol/lumera/x/audit/v1/types" @@ -30,3 +32,40 @@ var ApplyTicketDeteriorationDeltaForTest = func(k Keeper, ctx sdk.Context, epoch var WriteRawNextHealOpIDForTest = func(k Keeper, ctx sdk.Context, raw []byte) { k.kvStore(ctx).Set(types.NextHealOpIDKey(), raw) } + +// SetReporterResultOverturnFlagForTest writes a reporter-result record with the +// OverturnedByRecheck flag explicitly set. Used to verify CP-R3 B-F1 — the +// "no overturned fails" gate must inspect failure-class records, not PASS. +var SetReporterResultOverturnFlagForTest = func(k Keeper, ctx sdk.Context, epochID uint64, reporterAccount string, result *types.StorageProofResult, overturned bool) error { + if err := k.setStorageTruthReporterResult(ctx, epochID, reporterAccount, result); err != nil { + return err + } + store := k.kvStore(ctx) + primary := types.ReporterStorageTruthResultKey(reporterAccount, epochID, result.TicketId, result.TargetSupernodeAccount) + bz := store.Get(primary) + if bz == nil { + return nil + } + var rec storageTruthReporterResultRecord + if err := json.Unmarshal(bz, &rec); err != nil { + return err + } + rec.OverturnedByRecheck = overturned + updated, err := json.Marshal(rec) + if err != nil { + return err + } + store.Set(primary, updated) + store.Set(types.ReporterStorageTruthResultByTargetKey(result.TargetSupernodeAccount, epochID, result.TicketId, reporterAccount), updated) + return nil +} + +// HasIndependentReporterPassInWindowForTest exposes the indexed lookup for CP-R3 B-F3 overflow coverage. +var HasIndependentReporterPassInWindowForTest = func(k Keeper, ctx sdk.Context, ticketID string, targetAccount string, excludeReporter string, startEpoch uint64, endEpoch uint64) (bool, error) { + return k.hasIndependentReporterPassInWindow(ctx, ticketID, targetAccount, excludeReporter, startEpoch, endEpoch) +} + +// HasCleanRecheckInWindowForTest exposes the indexed lookup for CP-R3 B-F3 overflow coverage. +var HasCleanRecheckInWindowForTest = func(k Keeper, ctx sdk.Context, ticketID string, targetAccount string, startEpoch uint64, endEpoch uint64) (bool, error) { + return k.hasCleanRecheckInWindow(ctx, ticketID, targetAccount, startEpoch, endEpoch) +} diff --git a/x/audit/v1/keeper/genesis.go b/x/audit/v1/keeper/genesis.go index d30a42f5..82eda1c6 100644 --- a/x/audit/v1/keeper/genesis.go +++ b/x/audit/v1/keeper/genesis.go @@ -130,7 +130,9 @@ func (k Keeper) InitGenesis(ctx context.Context, genState types.GenesisState) er k.importReporterResultFactForGenesis(sdkCtx, f) } for _, m := range genState.FailedHealMarkers { - k.setStorageTruthFailedHeal(sdkCtx, m.SupernodeAccount, m.EpochId, m.TicketId) + if err := k.setStorageTruthFailedHeal(sdkCtx, m.SupernodeAccount, m.EpochId, m.TicketId); err != nil { + return err + } } for _, r := range genState.EpochReports { if err := k.SetReportRaw(sdkCtx, r); err != nil { diff --git a/x/audit/v1/keeper/genesis_test.go b/x/audit/v1/keeper/genesis_test.go index e9f0a2e8..b29a9e8e 100644 --- a/x/audit/v1/keeper/genesis_test.go +++ b/x/audit/v1/keeper/genesis_test.go @@ -3,6 +3,7 @@ package keeper_test import ( "testing" + keeper "github.com/LumeraProtocol/lumera/x/audit/v1/keeper" "github.com/LumeraProtocol/lumera/x/audit/v1/types" sntypes "github.com/LumeraProtocol/lumera/x/supernode/v1/types" "github.com/stretchr/testify/require" @@ -71,13 +72,17 @@ func TestGenesis_RoundTripsAllStPrefixes(t *testing.T) { f.keeper.SetRecheckEvidence(f.ctx, 5, "ticket-rce", "lumera1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa5xm4ep") // Storage proof transcript via public IndexStorageProofTranscripts. - require.NoError(t, f.keeper.IndexStorageProofTranscripts(f.ctx, 5, "lumera1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbadc7mh", []*types.StorageProofResult{{ + failureResult := &types.StorageProofResult{ TranscriptHash: "h-abcd", TargetSupernodeAccount: "lumera1cccccccccccccccccccccccccccccccccc7gqs5y", TicketId: "ticket-spt", - ResultClass: types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS, + ResultClass: types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH, BucketType: types.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, - }})) + ArtifactClass: types.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, + } + require.NoError(t, f.keeper.IndexStorageProofTranscripts(f.ctx, 5, "lumera1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbadc7mh", []*types.StorageProofResult{failureResult})) + require.NoError(t, keeper.SetStorageTruthNodeFailureForTest(f.keeper, f.ctx, 5, "lumera1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbadc7mh", failureResult)) + require.NoError(t, keeper.SetStorageTruthReporterResultForTest(f.keeper, f.ctx, 5, "lumera1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbadc7mh", failureResult)) // Failed-heal marker. require.NoError(t, f.keeper.SetParams(f.ctx, types.DefaultParams())) @@ -171,6 +176,34 @@ func TestGenesisStorageTruthPostponementRoundTrip(t *testing.T) { require.Equal(t, uint64(7), byAccount["lumera1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbadc7mh"]) } +func TestGenesisRejectsStorageProofTranscriptUnknownFields(t *testing.T) { + f := initFixture(t) + + genesisState := types.GenesisState{ + Params: types.DefaultParams(), + StorageProofTranscripts: []types.GenesisStorageProofTranscript{ + { + TranscriptHash: "h-unknown-field", + RecordJson: []byte(`{ + "epoch_id": 1, + "reporter_account": "lumera1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbadc7mh", + "target_account": "lumera1cccccccccccccccccccccccccccccccccc7gqs5y", + "ticket_id": "ticket-unknown", + "result_class": 1, + "bucket_type": 1, + "artifact_class": 1, + "recheck_eligible": true, + "unexpected_future_field": "must-not-be-silently-dropped" + }`), + }, + }, + } + + err := f.keeper.InitGenesis(f.ctx, genesisState) + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected_future_field") +} + func TestGenesisRoundTripWithTicketArtifactCountStates(t *testing.T) { f := initFixture(t) diff --git a/x/audit/v1/keeper/msg_storage_truth.go b/x/audit/v1/keeper/msg_storage_truth.go index 298eb389..fea69568 100644 --- a/x/audit/v1/keeper/msg_storage_truth.go +++ b/x/audit/v1/keeper/msg_storage_truth.go @@ -415,7 +415,9 @@ func (m msgServer) finalizeHealOp( if ticketState.ProbationUntilEpoch < cooldownUntil { ticketState.ProbationUntilEpoch = cooldownUntil } - m.setStorageTruthFailedHeal(ctx, healOp.HealerSupernodeAccount, currentEpoch.EpochID, healOp.TicketId) + if err := m.setStorageTruthFailedHeal(ctx, healOp.HealerSupernodeAccount, currentEpoch.EpochID, healOp.TicketId); err != nil { + return err + } } return m.SetTicketDeteriorationState(ctx, ticketState) } diff --git a/x/audit/v1/keeper/msg_submit_epoch_report_storage_truth_scores_test.go b/x/audit/v1/keeper/msg_submit_epoch_report_storage_truth_scores_test.go index 48c95075..20a28c16 100644 --- a/x/audit/v1/keeper/msg_submit_epoch_report_storage_truth_scores_test.go +++ b/x/audit/v1/keeper/msg_submit_epoch_report_storage_truth_scores_test.go @@ -289,6 +289,68 @@ func TestSubmitEpochReport_StorageTruthScoresApplyDecay(t *testing.T) { require.Equal(t, uint64(1), ticketState.LastHealEpoch) } +func TestSubmitEpochReport_TimeoutOnIndexArtifactIsClassBAndUnscaled(t *testing.T) { + // Per CP-R3 B-F2 — INDEX artifact status alone is not Class A. A timeout + // against an INDEX artifact is a liveness/Class-B failure: it uses the +7/+3 + // deltas without reporter trust scaling and must not increment ClassACountWindow + // or reset Class-A recovery gates. + f := initFixture(t) + f.ctx = f.ctx.WithBlockHeight(1).WithEventManager(sdk.NewEventManager()) + ms := keeper.NewMsgServerImpl(f.keeper) + + reporter := "sn-aaa-reporter" + target := "sn-bbb-target" + ticketID := "ticket-timeout-index" + + // If the old INDEX=>Class-A predicate survived, this reporter score would + // scale node +7 to floor(7*90/100)=6 and ticket +3 to floor(3*90/100)=2. + require.NoError(t, f.keeper.SetReporterReliabilityState(f.ctx, types.ReporterReliabilityState{ + ReporterSupernodeAccount: reporter, + ReliabilityScore: 10, + LastUpdatedEpoch: 0, + })) + + f.supernodeKeeper.EXPECT(). + GetSuperNodeByAccount(gomock.Any(), reporter). + Return(sntypes.SuperNode{}, true, nil). + AnyTimes() + + seedEpochAnchorForReportTest(t, f, 0, []string{reporter, target}, []string{reporter, target}) + result := baseStorageProofResult(types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE) + result.TicketId = ticketID + result.ArtifactClass = types.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX + seedTicketArtifactCountsForResults(t, f, result) + + _, err := ms.SubmitEpochReport(f.ctx, &types.MsgSubmitEpochReport{ + Creator: reporter, + EpochId: 0, + HostReport: types.HostReport{ + InboundPortStates: fullOpenPortStates(), + }, + StorageChallengeObservations: []*types.StorageChallengeObservation{ + { + TargetSupernodeAccount: target, + PortStates: fullOpenPortStates(), + }, + }, + StorageProofResults: []*types.StorageProofResult{result}, + }) + require.NoError(t, err) + + nodeState, found := f.keeper.GetNodeSuspicionState(f.ctx, target) + require.True(t, found) + require.Equal(t, int64(7), nodeState.SuspicionScore) + require.Equal(t, uint32(0), nodeState.ClassACountWindow) + require.Equal(t, uint32(1), nodeState.ClassBCountWindow) + require.Equal(t, uint64(0), nodeState.LastClassAEpoch) + require.Equal(t, uint64(0), nodeState.LastClassBEpoch) + require.Equal(t, uint64(0), nodeState.LastIndexFailEpoch, "epoch-0 index failure records as zero-value; Class-B counter is the observable predicate") + + ticketState, found := f.keeper.GetTicketDeteriorationState(f.ctx, ticketID) + require.True(t, found) + require.Equal(t, int64(3), ticketState.DeteriorationScore) +} + func TestSubmitEpochReport_StorageTruthScoreEventsAreEmitted(t *testing.T) { f := initFixture(t) f.ctx = f.ctx.WithBlockHeight(1).WithEventManager(sdk.NewEventManager()) diff --git a/x/audit/v1/keeper/storage_truth_clean_recovery_test.go b/x/audit/v1/keeper/storage_truth_clean_recovery_test.go index e6bbc586..e05fdbf4 100644 --- a/x/audit/v1/keeper/storage_truth_clean_recovery_test.go +++ b/x/audit/v1/keeper/storage_truth_clean_recovery_test.go @@ -39,8 +39,8 @@ func TestApplyReporterCleanEpochRecovery_AppliesMinusFourOnFivePasses(t *testing // Seed prior reliability score 10 so the −4 delta is observable above the 0 floor. require.NoError(t, f.keeper.SetReporterReliabilityState(f.ctx, types.ReporterReliabilityState{ ReporterSupernodeAccount: reporter, - ReliabilityScore: 10, - LastUpdatedEpoch: epochID, // No decay step — isolate the -4 delta + ReliabilityScore: 10, + LastUpdatedEpoch: epochID, // No decay step — isolate the -4 delta })) // 5 PASS in epoch 7, no overturned-fails. @@ -63,8 +63,8 @@ func TestApplyReporterCleanEpochRecovery_NoEffectOnFourPasses(t *testing.T) { require.NoError(t, f.keeper.SetReporterReliabilityState(f.ctx, types.ReporterReliabilityState{ ReporterSupernodeAccount: reporter, - ReliabilityScore: 10, - LastUpdatedEpoch: epochID, // No decay step — isolate the -4 delta + ReliabilityScore: 10, + LastUpdatedEpoch: epochID, // No decay step — isolate the -4 delta })) seedReporterPassResultsForEpoch(t, f, reporter, epochID, 4) // below threshold of 5 @@ -87,8 +87,8 @@ func TestApplyReporterCleanEpochRecovery_ScoresFloorAtZero(t *testing.T) { // Score already at 0 — recovery should not push it negative. require.NoError(t, f.keeper.SetReporterReliabilityState(f.ctx, types.ReporterReliabilityState{ ReporterSupernodeAccount: reporter, - ReliabilityScore: 0, - LastUpdatedEpoch: epochID, // No decay step — isolate the -4 delta + ReliabilityScore: 0, + LastUpdatedEpoch: epochID, // No decay step — isolate the -4 delta })) seedReporterPassResultsForEpoch(t, f, reporter, epochID, 5) @@ -100,3 +100,24 @@ func TestApplyReporterCleanEpochRecovery_ScoresFloorAtZero(t *testing.T) { require.True(t, found) require.GreaterOrEqual(t, final.ReliabilityScore, int64(0), "score must not go negative") } + +func TestApplyReporterCleanEpochRecovery_CreatesStateForFreshReporter(t *testing.T) { + f := initFixture(t) + f.ctx = f.ctx.WithBlockHeight(1).WithEventManager(sdk.NewEventManager()) + + const epochID = uint64(7) + const reporter = "reporter-clean-fresh" + + _, found := f.keeper.GetReporterReliabilityState(f.ctx, reporter) + require.False(t, found) + seedReporterPassResultsForEpoch(t, f, reporter, epochID, 5) + + params := f.keeper.GetParams(f.ctx).WithDefaults() + require.NoError(t, f.keeper.ApplyReporterCleanEpochRecoveryAtEpochEnd(f.ctx, epochID, params)) + + final, found := f.keeper.GetReporterReliabilityState(f.ctx, reporter) + require.True(t, found, "fresh reporter with clean epoch should get an explicit dashboard state row") + require.Equal(t, int64(0), final.ReliabilityScore) + require.Equal(t, epochID, final.LastUpdatedEpoch) + require.Equal(t, uint32(1), final.WindowPositiveCount) +} diff --git a/x/audit/v1/keeper/storage_truth_cross_holder_pass_test.go b/x/audit/v1/keeper/storage_truth_cross_holder_pass_test.go index a136b8f7..27e5b031 100644 --- a/x/audit/v1/keeper/storage_truth_cross_holder_pass_test.go +++ b/x/audit/v1/keeper/storage_truth_cross_holder_pass_test.go @@ -108,6 +108,47 @@ func TestApplyTicketDeteriorationDelta_CrossHolderPassBonus(t *testing.T) { "fresh ticket PASS clamps at 0; no cross-holder bonus without prior state") }) + t.Run("PASS by different holder after non-hash failure classes applies extra -3 bonus", func(t *testing.T) { + failureClasses := []types.StorageProofResultClass{ + types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_TIMEOUT_OR_NO_RESPONSE, + types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_OBSERVER_QUORUM_FAIL, + types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_INVALID_TRANSCRIPT, + } + for _, priorClass := range failureClasses { + t.Run(priorClass.String(), func(t *testing.T) { + f := initFixture(t) + const epochID = uint64(10) + ticketID := "ticket-cross-holder-" + priorClass.String() + + require.NoError(t, f.keeper.SetTicketDeteriorationState(f.ctx, types.TicketDeteriorationState{ + TicketId: ticketID, + DeteriorationScore: 50, + LastUpdatedEpoch: epochID, + LastTargetSupernodeAccount: "holder-A", + LastResultClass: priorClass, + LastResultEpoch: epochID - 1, + LastFailureEpoch: epochID - 1, + })) + + passResult := &types.StorageProofResult{ + TicketId: ticketID, + TargetSupernodeAccount: "holder-B", + ResultClass: types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS, + BucketType: types.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, + } + _, _, err := keeper.ApplyTicketDeteriorationDeltaForTest( + f.keeper, f.ctx, epochID, "reporter-1", passResult, ticketID, -2, 0, false, + ) + require.NoError(t, err) + + final, found := f.keeper.GetTicketDeteriorationState(f.ctx, ticketID) + require.True(t, found) + require.Equal(t, int64(45), final.DeteriorationScore, + "prior failure class %s should receive base PASS delta plus cross-holder recovery bonus", priorClass) + }) + } + }) + t.Run("PASS by different holder but prior was PASS (not failure) — no bonus", func(t *testing.T) { f := initFixture(t) const ticketID = "ticket-prior-pass" diff --git a/x/audit/v1/keeper/storage_truth_divergence.go b/x/audit/v1/keeper/storage_truth_divergence.go index c9906bc3..d3e92df7 100644 --- a/x/audit/v1/keeper/storage_truth_divergence.go +++ b/x/audit/v1/keeper/storage_truth_divergence.go @@ -6,6 +6,7 @@ import ( "sort" "strconv" + storetypes "cosmossdk.io/store/types" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/LumeraProtocol/lumera/x/audit/v1/types" @@ -153,12 +154,30 @@ func (k Keeper) ApplyReporterCleanEpochRecoveryAtEpochEnd(ctx sdk.Context, epoch if err != nil { return err } - if len(states) == 0 { + reporterSet := make(map[string]struct{}, len(states)) + for _, state := range states { + if state.ReporterSupernodeAccount != "" { + reporterSet[state.ReporterSupernodeAccount] = struct{}{} + } + } + epochReporters, err := k.storageTruthReporterAccountsForEpoch(ctx, epochID) + if err != nil { + return err + } + for _, reporter := range epochReporters { + reporterSet[reporter] = struct{}{} + } + if len(reporterSet) == 0 { return nil } + reporters := make([]string, 0, len(reporterSet)) + for reporter := range reporterSet { + reporters = append(reporters, reporter) + } + sort.Strings(reporters) - for _, state := range states { - passes, overturned, err := k.storageTruthReporterEpochPassStats(ctx, state.ReporterSupernodeAccount, epochID) + for _, reporterAccount := range reporters { + passes, overturned, err := k.storageTruthReporterEpochPassStats(ctx, reporterAccount, epochID) if err != nil { return err } @@ -169,7 +188,7 @@ func (k Keeper) ApplyReporterCleanEpochRecoveryAtEpochEnd(ctx sdk.Context, epoch if _, _, err := k.applyReporterReliabilityDelta( ctx, epochID, - state.ReporterSupernodeAccount, + reporterAccount, -4, params.StorageTruthReporterReliabilityDecayPerEpoch, 0, @@ -182,7 +201,7 @@ func (k Keeper) ApplyReporterCleanEpochRecoveryAtEpochEnd(ctx sdk.Context, epoch types.EventTypeStorageTruthScoreUpdated, sdk.NewAttribute(sdk.AttributeKeyModule, types.ModuleName), sdk.NewAttribute(types.AttributeKeyEpochID, strconv.FormatUint(epochID, 10)), - sdk.NewAttribute(types.AttributeKeyReporterSupernodeAccount, state.ReporterSupernodeAccount), + sdk.NewAttribute(types.AttributeKeyReporterSupernodeAccount, reporterAccount), sdk.NewAttribute("clean_epoch_recovery_delta", "-4"), sdk.NewAttribute("epoch_pass_count", strconv.FormatUint(passes, 10)), )) @@ -190,6 +209,32 @@ func (k Keeper) ApplyReporterCleanEpochRecoveryAtEpochEnd(ctx sdk.Context, epoch return nil } +// storageTruthReporterAccountsForEpoch returns all reporters that have at +// least one reporter-result fact in epochID, including fresh reporters that do +// not yet have a ReporterReliabilityState row. +func (k Keeper) storageTruthReporterAccountsForEpoch(ctx sdk.Context, epochID uint64) ([]string, error) { + prefix := types.ReporterStorageTruthResultRootPrefix() + it := k.kvStore(ctx).Iterator(prefix, storetypes.PrefixEndBytes(prefix)) + defer it.Close() + + reporterSet := make(map[string]struct{}) + for ; it.Valid(); it.Next() { + var record storageTruthReporterResultRecord + if err := json.Unmarshal(it.Value(), &record); err != nil { + return nil, err + } + if record.EpochID == epochID && record.Reporter != "" { + reporterSet[record.Reporter] = struct{}{} + } + } + reporters := make([]string, 0, len(reporterSet)) + for reporter := range reporterSet { + reporters = append(reporters, reporter) + } + sort.Strings(reporters) + return reporters, nil +} + // storageTruthReporterEpochPassStats counts PASS results for a reporter in a // single epoch and reports whether any of them was overturned by recheck. func (k Keeper) storageTruthReporterEpochPassStats(ctx sdk.Context, reporterAccount string, epochID uint64) (uint64, bool, error) { @@ -198,18 +243,26 @@ func (k Keeper) storageTruthReporterEpochPassStats(ctx sdk.Context, reporterAcco defer it.Close() var passes uint64 var overturned bool + // Per CP-R3 B-F1 — `OverturnedByRecheck` is written by + // markStorageTruthReporterResultRecheck onto the *failure-class* record + // (the challenged transcript), never onto a PASS record. The previous + // `continue` skipping non-PASS records made the gate structurally + // unreachable. Spec §15.3 requires "no overturned fails" to suppress + // the −4 reward, so we now scan all classes and check the overturn + // flag on failure-class records, while still counting PASSes for the + // ≥5 PASS gate. for ; it.Valid(); it.Next() { var record storageTruthReporterResultRecord if err := json.Unmarshal(it.Value(), &record); err != nil { return 0, false, err } - if types.StorageProofResultClass(record.ResultClass) != types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS { - continue - } - passes++ - if record.OverturnedByRecheck { + class := types.StorageProofResultClass(record.ResultClass) + if record.OverturnedByRecheck && isStorageTruthFailureClass(class) { overturned = true } + if class == types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS { + passes++ + } } return passes, overturned, nil } diff --git a/x/audit/v1/keeper/storage_truth_fact_indexes.go b/x/audit/v1/keeper/storage_truth_fact_indexes.go index 7b9ffcbb..3cf0234e 100644 --- a/x/audit/v1/keeper/storage_truth_fact_indexes.go +++ b/x/audit/v1/keeper/storage_truth_fact_indexes.go @@ -1,8 +1,11 @@ package keeper import ( + "bytes" "encoding/binary" "encoding/json" + "fmt" + "io" errorsmod "cosmossdk.io/errors" storetypes "cosmossdk.io/store/types" @@ -296,8 +299,7 @@ func (k Keeper) hasIndependentReporterPassInWindow( // Per 122-Copilot-3 + 122-F1 — indexed lookup avoids DeliverTx full-table scan. // Scan secondary index: "st/rrs-tt/" + target + "/" + u64be(epoch) + "/" // for each epoch in [startEpoch, endEpoch]. - startKey := types.ReporterStorageTruthResultByTargetEpochPrefix(targetAccount, startEpoch) - endKey := types.ReporterStorageTruthResultByTargetEpochPrefix(targetAccount, endEpoch+1) + startKey, endKey := types.ReporterStorageTruthResultByTargetEpochScanRange(targetAccount, startEpoch, endEpoch) it := k.kvStore(ctx).Iterator(startKey, endKey) defer it.Close() @@ -330,9 +332,7 @@ func (k Keeper) hasCleanRecheckInWindow( // Per 122-Copilot-4 + 122-F1 — indexed lookup avoids DeliverTx full-table scan. // Scan secondary index: "st/spt-tbe/" + target + "/" + u32be(RECHECK) + "/" epoch range. recheckBucket := uint32(types.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECHECK) - bucketPfx := types.TranscriptByTargetBucketEpochScanPrefix(targetAccount, recheckBucket) - startKey := binary.BigEndian.AppendUint64(append([]byte(nil), bucketPfx...), startEpoch) - endKey := binary.BigEndian.AppendUint64(append([]byte(nil), bucketPfx...), endEpoch+1) + startKey, endKey := types.TranscriptByTargetBucketEpochScanRange(targetAccount, recheckBucket, startEpoch, endEpoch) it := k.kvStore(ctx).Iterator(startKey, endKey) defer it.Close() @@ -352,11 +352,15 @@ func (k Keeper) hasCleanRecheckInWindow( return false, nil } -func (k Keeper) setStorageTruthFailedHeal(ctx sdk.Context, supernodeAccount string, epochID uint64, ticketID string) { - if supernodeAccount == "" || ticketID == "" { - return +func (k Keeper) setStorageTruthFailedHeal(ctx sdk.Context, supernodeAccount string, epochID uint64, ticketID string) error { + if supernodeAccount == "" { + return fmt.Errorf("storage truth failed heal marker missing healer account for ticket %q at epoch %d", ticketID, epochID) + } + if ticketID == "" { + return fmt.Errorf("storage truth failed heal marker missing ticket id for healer %q at epoch %d", supernodeAccount, epochID) } k.kvStore(ctx).Set(types.StorageTruthFailedHealKey(supernodeAccount, epochID, ticketID), []byte{1}) + return nil } func (k Keeper) hasStorageTruthFailedHeal(ctx sdk.Context, supernodeAccount string, startEpoch uint64, endEpoch uint64) bool { @@ -401,9 +405,15 @@ func (k Keeper) GetAllStorageProofTranscriptsForGenesis(ctx sdk.Context) []types // st/spt-tbe/ secondary index alongside) so genesis-imported state matches runtime. func (k Keeper) importStorageProofTranscriptForGenesis(ctx sdk.Context, hash string, recordJSON []byte) error { var rec storageProofTranscriptRecord - if err := json.Unmarshal(recordJSON, &rec); err != nil { + dec := json.NewDecoder(bytes.NewReader(recordJSON)) + dec.DisallowUnknownFields() + if err := dec.Decode(&rec); err != nil { return err } + var extra struct{} + if err := dec.Decode(&extra); err != io.EOF { + return errorsmod.Wrap(types.ErrInvalidStorageProofs, "storage proof transcript genesis record contains trailing JSON data") + } return k.setStorageProofTranscriptRecord(ctx, hash, rec) } diff --git a/x/audit/v1/keeper/storage_truth_heal_ops.go b/x/audit/v1/keeper/storage_truth_heal_ops.go index cc229f9f..2f86a2e0 100644 --- a/x/audit/v1/keeper/storage_truth_heal_ops.go +++ b/x/audit/v1/keeper/storage_truth_heal_ops.go @@ -53,7 +53,9 @@ func (k Keeper) expireStorageTruthHealOpsAtEpochEnd(ctx sdk.Context, epochID uin if err := k.SetTicketDeteriorationState(ctx, ticketState); err != nil { return err } - k.setStorageTruthFailedHeal(ctx, healOp.HealerSupernodeAccount, epochID, healOp.TicketId) + if err := k.setStorageTruthFailedHeal(ctx, healOp.HealerSupernodeAccount, epochID, healOp.TicketId); err != nil { + return err + } } ctx.EventManager().EmitEvent( diff --git a/x/audit/v1/keeper/storage_truth_heal_ops_test.go b/x/audit/v1/keeper/storage_truth_heal_ops_test.go index 82c3c692..9b68f59f 100644 --- a/x/audit/v1/keeper/storage_truth_heal_ops_test.go +++ b/x/audit/v1/keeper/storage_truth_heal_ops_test.go @@ -166,3 +166,37 @@ func TestExpireStorageTruthHealOps_AdvancesProbationAndCooldown(t *testing.T) { "probation must be advanced by ProbationEpochs") require.Equal(t, uint64(0), state.ActiveHealOpId) } + +func TestProcessStorageTruthHealOpsAtEpochEnd_RejectsExpiredHealWithEmptyHealer(t *testing.T) { + f := initFixture(t) + f.ctx = f.ctx.WithBlockHeight(1600).WithEventManager(sdk.NewEventManager()) + + params := f.keeper.GetParams(f.ctx).WithDefaults() + params.StorageTruthMaxSelfHealOpsPerEpoch = 0 // expire-only + require.NoError(t, f.keeper.SetParams(f.ctx, params)) + + const ( + ticketID = "ticket-empty-healer" + healOpID = uint64(801) + epochID = uint64(3) + ) + require.NoError(t, f.keeper.SetHealOp(f.ctx, types.HealOp{ + HealOpId: healOpID, + TicketId: ticketID, + ScheduledEpochId: 1, + // Empty healer account is corrupt scheduler state; do not silently skip st/fh/. + HealerSupernodeAccount: "", + VerifierSupernodeAccounts: []string{"lumera1eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeennf6kk"}, + Status: types.HealOpStatus_HEAL_OP_STATUS_HEALER_REPORTED, + DeadlineEpochId: epochID, + })) + require.NoError(t, f.keeper.SetTicketDeteriorationState(f.ctx, types.TicketDeteriorationState{ + TicketId: ticketID, + DeteriorationScore: 50, + ActiveHealOpId: healOpID, + })) + + err := f.keeper.ProcessStorageTruthHealOpsAtEpochEnd(f.ctx, epochID, params) + require.Error(t, err) + require.Contains(t, err.Error(), "missing healer account") +} diff --git a/x/audit/v1/keeper/storage_truth_low_findings_test.go b/x/audit/v1/keeper/storage_truth_low_findings_test.go index de54714e..89916e0c 100644 --- a/x/audit/v1/keeper/storage_truth_low_findings_test.go +++ b/x/audit/v1/keeper/storage_truth_low_findings_test.go @@ -13,27 +13,47 @@ import ( sntypes "github.com/LumeraProtocol/lumera/x/supernode/v1/types" ) -// TestGetNextHealOpID_PanicsOnMalformedState verifies NEW-B-7 sibling-symmetry -// with GetNextEvidenceID — malformed counter bytes panic instead of silently -// returning a corrupt value. -func TestGetNextHealOpID_PanicsOnMalformedState(t *testing.T) { - t.Run("malformed length", func(t *testing.T) { +// TestGetNextHealOpID_RecoversFromMalformedState verifies CP-R3 A-F1 — +// GetNextHealOpID now mirrors GetNextEvidenceID by deriving a safe next ID +// from stored heal ops instead of panicking on malformed/zero counter state. +func TestGetNextHealOpID_RecoversFromMalformedState(t *testing.T) { + seedHealOp := func(t *testing.T, f *fixture, id uint64) { + t.Helper() + require.NoError(t, f.keeper.SetHealOp(f.ctx, types.HealOp{ + HealOpId: id, + TicketId: "ticket-heal-next-id", + ScheduledEpochId: 11, + HealerSupernodeAccount: "lumera1healer00000000000000000000000004qyrj", + VerifierSupernodeAccounts: []string{ + "lumera1verifier1000000000000000000000005tzzg", + }, + Status: types.HealOpStatus_HEAL_OP_STATUS_SCHEDULED, + CreatedHeight: 100, + UpdatedHeight: 100, + DeadlineEpochId: 14, + })) + } + + t.Run("malformed length derives max plus one", func(t *testing.T) { f := initFixture(t) + seedHealOp(t, f, 9) // Write 7 bytes (not 8) to the next-id counter key. keeper.WriteRawNextHealOpIDForTest(f.keeper, f.ctx, []byte{0, 0, 0, 0, 0, 0, 1}) - require.PanicsWithError(t, - "audit: malformed next heal-op id (len=7, want 8)", - func() { _ = f.keeper.GetNextHealOpID(f.ctx) }) + require.Equal(t, uint64(10), f.keeper.GetNextHealOpID(f.ctx)) }) - t.Run("zero id sentinel collision", func(t *testing.T) { + t.Run("zero id sentinel derives max plus one", func(t *testing.T) { f := initFixture(t) + seedHealOp(t, f, 41) bz := make([]byte, 8) binary.BigEndian.PutUint64(bz, 0) keeper.WriteRawNextHealOpIDForTest(f.keeper, f.ctx, bz) - require.PanicsWithError(t, - "audit: invalid next heal-op id (id=0 collides with not-found sentinel)", - func() { _ = f.keeper.GetNextHealOpID(f.ctx) }) + require.Equal(t, uint64(42), f.keeper.GetNextHealOpID(f.ctx)) + }) + + t.Run("missing counter with no heal ops starts at one", func(t *testing.T) { + f := initFixture(t) + require.Equal(t, uint64(1), f.keeper.GetNextHealOpID(f.ctx)) }) t.Run("valid id works", func(t *testing.T) { @@ -43,6 +63,47 @@ func TestGetNextHealOpID_PanicsOnMalformedState(t *testing.T) { }) } +// TestMaxUint64EpochWindowScansAreWrapSafe verifies CP-R3 A-F2/B-F3 — +// ad-hoc secondary-index scans use named MaxUint64-safe range helpers rather +// than building endEpoch+1 directly. +func TestMaxUint64EpochWindowScansAreWrapSafe(t *testing.T) { + maxEpoch := ^uint64(0) + ticketID := "ticket-max-epoch" + target := "lumera1targetmax0000000000000000000000e0n0n" + reporter := "lumera1reportermax00000000000000000000y5kfe" + excluded := "lumera1excludedmax0000000000000000000hpu43" + + t.Run("independent reporter PASS at MaxUint64 is included", func(t *testing.T) { + f := initFixture(t) + result := &types.StorageProofResult{ + TargetSupernodeAccount: target, + TicketId: ticketID, + ResultClass: types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS, + } + require.NoError(t, keeper.SetStorageTruthReporterResultForTest(f.keeper, f.ctx, maxEpoch, reporter, result)) + + found, err := keeper.HasIndependentReporterPassInWindowForTest(f.keeper, f.ctx, ticketID, target, excluded, maxEpoch-1, maxEpoch) + require.NoError(t, err) + require.True(t, found) + }) + + t.Run("clean recheck PASS at MaxUint64 is included", func(t *testing.T) { + f := initFixture(t) + result := &types.StorageProofResult{ + TargetSupernodeAccount: target, + TicketId: ticketID, + BucketType: types.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECHECK, + ResultClass: types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS, + TranscriptHash: "transcript-max-epoch-pass", + } + require.NoError(t, f.keeper.IndexStorageProofTranscripts(f.ctx, maxEpoch, reporter, []*types.StorageProofResult{result})) + + found, err := keeper.HasCleanRecheckInWindowForTest(f.keeper, f.ctx, ticketID, target, maxEpoch-1, maxEpoch) + require.NoError(t, err) + require.True(t, found) + }) +} + // TestHealVerifierCountParam_FallbackToDefault verifies NEW-B-3 — the new // StorageTruthHealVerifierCount param defaults to 2 when unset and is // honored when configured. diff --git a/x/audit/v1/keeper/storage_truth_overturn_gate_test.go b/x/audit/v1/keeper/storage_truth_overturn_gate_test.go new file mode 100644 index 00000000..12c51934 --- /dev/null +++ b/x/audit/v1/keeper/storage_truth_overturn_gate_test.go @@ -0,0 +1,106 @@ +package keeper_test + +import ( + "fmt" + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" + + "github.com/LumeraProtocol/lumera/x/audit/v1/keeper" + "github.com/LumeraProtocol/lumera/x/audit/v1/types" +) + +// Per CP-R3 B-F1 — the "no overturned fails" gate must look at failure-class +// records (where OverturnedByRecheck is actually written), not at PASS-class +// records. The previous implementation skipped non-PASS in the same iterator +// that checked the overturn flag, making the gate structurally always false: +// a fraudulent reporter with 5 PASS + 1 overturned FAIL still received -4. + +func TestApplyReporterCleanEpochRecovery_OverturnedFailSuppressesMinusFour(t *testing.T) { + f := initFixture(t) + f.ctx = f.ctx.WithBlockHeight(1).WithEventManager(sdk.NewEventManager()) + + const epochID = uint64(11) + const reporter = "reporter-overturn-fail" + + // Prior reliability score 10 — observable above the floor. + require.NoError(t, f.keeper.SetReporterReliabilityState(f.ctx, types.ReporterReliabilityState{ + ReporterSupernodeAccount: reporter, + ReliabilityScore: 10, + LastUpdatedEpoch: epochID, + })) + + // 5 PASS results in the epoch. + for i := 0; i < 5; i++ { + passResult := &types.StorageProofResult{ + TicketId: fmt.Sprintf("ticket-pass-%d", i), + TargetSupernodeAccount: fmt.Sprintf("target-pass-%d", i), + ResultClass: types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS, + BucketType: types.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, + } + require.NoError(t, keeper.SetStorageTruthReporterResultForTest(f.keeper, f.ctx, epochID, reporter, passResult)) + } + + // 1 HASH_MISMATCH (Class A failure) flagged as overturned by recheck. + failResult := &types.StorageProofResult{ + TicketId: "ticket-overturned-fail", + TargetSupernodeAccount: "target-overturned", + ResultClass: types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH, + BucketType: types.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, + } + require.NoError(t, keeper.SetReporterResultOverturnFlagForTest(f.keeper, f.ctx, epochID, reporter, failResult, true)) + + params := f.keeper.GetParams(f.ctx).WithDefaults() + require.NoError(t, f.keeper.ApplyReporterCleanEpochRecoveryAtEpochEnd(f.ctx, epochID, params)) + + final, found := f.keeper.GetReporterReliabilityState(f.ctx, reporter) + require.True(t, found) + require.Equal(t, int64(10), final.ReliabilityScore, + "reporter with overturned FAIL must NOT receive -4 §15.3 reward (B-F1)") +} + +func TestApplyReporterCleanEpochRecovery_NonOverturnedFailDoesNotBlockReward(t *testing.T) { + // Sibling case to confirm the gate is not over-restrictive: a confirmed + // failure (or any non-overturned fail) does NOT block the reward — only + // an overturned-by-recheck fail does. Spec §15.3 wording: "no overturned + // fails" means specifically those overturned via recheck. + f := initFixture(t) + f.ctx = f.ctx.WithBlockHeight(1).WithEventManager(sdk.NewEventManager()) + + const epochID = uint64(12) + const reporter = "reporter-confirmed-fail" + + require.NoError(t, f.keeper.SetReporterReliabilityState(f.ctx, types.ReporterReliabilityState{ + ReporterSupernodeAccount: reporter, + ReliabilityScore: 10, + LastUpdatedEpoch: epochID, + })) + + for i := 0; i < 5; i++ { + passResult := &types.StorageProofResult{ + TicketId: fmt.Sprintf("ticket-pass-cf-%d", i), + TargetSupernodeAccount: fmt.Sprintf("target-pass-cf-%d", i), + ResultClass: types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_PASS, + BucketType: types.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, + } + require.NoError(t, keeper.SetStorageTruthReporterResultForTest(f.keeper, f.ctx, epochID, reporter, passResult)) + } + + // Confirmed failure (overturned=false) — should NOT block the -4 reward. + failResult := &types.StorageProofResult{ + TicketId: "ticket-confirmed-fail", + TargetSupernodeAccount: "target-confirmed", + ResultClass: types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH, + BucketType: types.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, + } + require.NoError(t, keeper.SetReporterResultOverturnFlagForTest(f.keeper, f.ctx, epochID, reporter, failResult, false)) + + params := f.keeper.GetParams(f.ctx).WithDefaults() + require.NoError(t, f.keeper.ApplyReporterCleanEpochRecoveryAtEpochEnd(f.ctx, epochID, params)) + + final, found := f.keeper.GetReporterReliabilityState(f.ctx, reporter) + require.True(t, found) + require.Equal(t, int64(6), final.ReliabilityScore, + "reporter with non-overturned FAIL should still earn -4 reward (B-F1 sibling check)") +} diff --git a/x/audit/v1/keeper/storage_truth_scoring.go b/x/audit/v1/keeper/storage_truth_scoring.go index 0bcdb292..a009834b 100644 --- a/x/audit/v1/keeper/storage_truth_scoring.go +++ b/x/audit/v1/keeper/storage_truth_scoring.go @@ -269,10 +269,13 @@ func (k Keeper) updateNodeSuspicionHistoryFields(state *types.NodeSuspicionState // Track distinct ticket fail (simplified: increment per failure in window). state.DistinctTicketFailWindow++ - // Class A: HASH_MISMATCH, RECHECK_CONFIRMED_FAIL, and any INDEX artifact failure. + // Per CP-R3 B-F2 — Class A is failure-class driven, not artifact-class + // driven. HASH_MISMATCH against an INDEX artifact still carries the +26 + // score delta via storageTruthScoreDeltasForResult, but TIMEOUT-on-INDEX + // remains a liveness/Class-B failure and must not reset Class-A recovery + // gates or increment ClassACountWindow. isClassA := result.ResultClass == types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH || - result.ResultClass == types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL || - result.ArtifactClass == types.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX + result.ResultClass == types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL if isClassA { state.ClassACountWindow++ state.LastClassAEpoch = epochID @@ -566,13 +569,13 @@ func (k Keeper) storageTruthBookkeepingForResult( } bookkeeping.reporterTrustBand = reporterTrustBandForScore(reliabilityScore, params) bookkeeping.reporterTrustMultiplier = reporterTrustMultiplierNumerator(reliabilityScore) - // Per CP-NEW-A-14 / spec §15.4 — trust multiplier applies to Class A - // failures only (HASH_MISMATCH or INDEX-class). Class B/C failures - // (TIMEOUT, OBSERVER_QUORUM_FAIL, INVALID_TRANSCRIPT, etc.) emit - // non-scaled deltas. Re-confirmed (RECHECK_CONFIRMED_FAIL) and - // recheck-bucket results bypass scaling regardless of class. - isClassA := result.ResultClass == types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH || - result.ArtifactClass == types.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX + // Per CP-R3 B-F2 / spec §15.4 — trust multiplier applies to Class A + // failures only. INDEX artifact status affects HASH_MISMATCH delta magnitude + // (+26 for index vs +18 for symbol), but does not by itself make a liveness + // failure Class A. TIMEOUT/OBSERVER_QUORUM_FAIL/INVALID_TRANSCRIPT remain + // non-scaled Class B/C failures. Recheck-confirmed failures and recheck-bucket + // results bypass scaling regardless of class. + isClassA := result.ResultClass == types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH bookkeeping.applyTrustScaling = isClassA && isStorageTruthFailureClass(result.ResultClass) && result.ResultClass != types.StorageProofResultClass_STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL && diff --git a/x/audit/v1/keeper/storage_truth_state.go b/x/audit/v1/keeper/storage_truth_state.go index a4d6d8bd..8cf88dac 100644 --- a/x/audit/v1/keeper/storage_truth_state.go +++ b/x/audit/v1/keeper/storage_truth_state.go @@ -2,7 +2,6 @@ package keeper import ( "encoding/binary" - "fmt" storetypes "cosmossdk.io/store/types" sdk "github.com/cosmos/cosmos-sdk/types" @@ -174,22 +173,40 @@ func (k Keeper) GetNextHealOpID(ctx sdk.Context) uint64 { store := k.kvStore(ctx) bz := store.Get(types.NextHealOpIDKey()) if bz == nil { - return 1 + // Match GetNextEvidenceID: missing counter must not risk ID reuse. + return k.deriveNextHealOpID(ctx) } - // Per NEW-B-7 — sibling-symmetry with GetNextEvidenceID: panic on malformed - // counter rather than silently returning a corrupt value (heal-op IDs gate - // SetHealOp/GetHealOp; collisions cause structural confusion downstream). if len(bz) != 8 { - panic(fmt.Errorf("audit: malformed next heal-op id (len=%d, want 8)", len(bz))) + // Per CP-R3 A-F1 — recover from malformed state the same way evidence IDs do. + return k.deriveNextHealOpID(ctx) } id := binary.BigEndian.Uint64(bz) if id == 0 { - // Heal-op IDs start at 1; treat 0 as sentinel collision with "not found". - panic(fmt.Errorf("audit: invalid next heal-op id (id=0 collides with not-found sentinel)")) + // Heal-op IDs start at 1; treat 0 as corrupt and derive a safe next ID. + return k.deriveNextHealOpID(ctx) } return id } +func (k Keeper) deriveNextHealOpID(ctx sdk.Context) uint64 { + prefix := types.HealOpPrefix() + it := k.kvStore(ctx).Iterator(prefix, storetypes.PrefixEndBytes(prefix)) + defer it.Close() + + var maxID uint64 + for ; it.Valid(); it.Next() { + key := it.Key() + if len(key) != len(prefix)+8 { + continue + } + id := binary.BigEndian.Uint64(key[len(prefix):]) + if id > maxID { + maxID = id + } + } + return maxID + 1 +} + func (k Keeper) SetNextHealOpID(ctx sdk.Context, id uint64) { store := k.kvStore(ctx) bz := make([]byte, 8) diff --git a/x/audit/v1/module/migrations.go b/x/audit/v1/module/migrations.go index 016c217a..ed4a52ad 100644 --- a/x/audit/v1/module/migrations.go +++ b/x/audit/v1/module/migrations.go @@ -8,6 +8,9 @@ import ( // NewMigrateV1ToV2 returns the v1→v2 module migration handler. // Per 122-F4 — bump KeepLastEpochEntries to cover OldClassAFaultWindow for safe pruning. +// Per CP-R3 C-F3 — also covers StorageTruthDivergenceWindowEpochs and +// StorageTruthHealDeadlineEpochs. v2 has not shipped to mainnet, so we extend +// the same handler in place rather than introducing a v2→v3. func NewMigrateV1ToV2(k keeper.Keeper) func(ctx sdk.Context) error { return func(ctx sdk.Context) error { params := k.GetParams(ctx) @@ -15,8 +18,23 @@ func NewMigrateV1ToV2(k keeper.Keeper) func(ctx sdk.Context) error { if oldClassAFaultWindow == 0 { oldClassAFaultWindow = 21 } - if params.KeepLastEpochEntries < oldClassAFaultWindow { - params.KeepLastEpochEntries = oldClassAFaultWindow + divergenceWindow := uint64(params.StorageTruthDivergenceWindowEpochs) + if divergenceWindow == 0 { + divergenceWindow = 14 + } + healDeadline := uint64(params.StorageTruthHealDeadlineEpochs) + if healDeadline == 0 { + healDeadline = 3 + } + required := oldClassAFaultWindow + if divergenceWindow > required { + required = divergenceWindow + } + if healDeadline > required { + required = healDeadline + } + if params.KeepLastEpochEntries < required { + params.KeepLastEpochEntries = required } return k.SetParams(ctx, params) } diff --git a/x/audit/v1/module/migrations_test.go b/x/audit/v1/module/migrations_test.go new file mode 100644 index 00000000..b4feba54 --- /dev/null +++ b/x/audit/v1/module/migrations_test.go @@ -0,0 +1,15 @@ +package audit + +import ( + "testing" + + "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "github.com/stretchr/testify/require" +) + +func TestAuditMigrationStaysV1ToV2UntilV2Ships(t *testing.T) { + // Per R3 C-F9: there is no separate v2→v3 handler because audit consensus + // version v2 has not shipped to mainnet. The C-F1/C-F3 KeepLastEpochEntries + // backfill is intentionally folded into NewMigrateV1ToV2 before v2 release. + require.Equal(t, 2, types.ConsensusVersion) +} diff --git a/x/audit/v1/types/genesis_validate.go b/x/audit/v1/types/genesis_validate.go index 6095ddc5..5b3972a4 100644 --- a/x/audit/v1/types/genesis_validate.go +++ b/x/audit/v1/types/genesis_validate.go @@ -34,5 +34,13 @@ func ValidateScoreStatesGenesis(g GenesisState, currentEpoch uint64) error { s.TicketId, s.LastUpdatedEpoch, currentEpoch) } } + for _, s := range g.TicketArtifactCountStates { + if s.TicketId == "" { + return fmt.Errorf("ticket artifact count state has empty ticket id") + } + if s.IndexArtifactCount == 0 && s.SymbolArtifactCount == 0 { + return fmt.Errorf("ticket artifact count %q has zero index and symbol counts", s.TicketId) + } + } return nil } diff --git a/x/audit/v1/types/genesis_validate_test.go b/x/audit/v1/types/genesis_validate_test.go index 25c47906..ca75f786 100644 --- a/x/audit/v1/types/genesis_validate_test.go +++ b/x/audit/v1/types/genesis_validate_test.go @@ -53,3 +53,38 @@ func TestValidateScoreStatesGenesis_WindowStartEpochInFuture(t *testing.T) { require.NoError(t, types.ValidateScoreStatesGenesis(g, currentEpoch)) }) } + +func TestValidateScoreStatesGenesis_TicketArtifactCountStates(t *testing.T) { + const currentEpoch uint64 = 10 + + t.Run("empty ticket id rejected", func(t *testing.T) { + g := types.GenesisState{ + TicketArtifactCountStates: []types.TicketArtifactCountState{ + {IndexArtifactCount: 1, SymbolArtifactCount: 2}, + }, + } + err := types.ValidateScoreStatesGenesis(g, currentEpoch) + require.Error(t, err) + require.Contains(t, err.Error(), "empty ticket id") + }) + + t.Run("zero counts rejected", func(t *testing.T) { + g := types.GenesisState{ + TicketArtifactCountStates: []types.TicketArtifactCountState{ + {TicketId: "ticket-zero"}, + }, + } + err := types.ValidateScoreStatesGenesis(g, currentEpoch) + require.Error(t, err) + require.Contains(t, err.Error(), "zero index and symbol counts") + }) + + t.Run("nonzero count accepted", func(t *testing.T) { + g := types.GenesisState{ + TicketArtifactCountStates: []types.TicketArtifactCountState{ + {TicketId: "ticket-ok", IndexArtifactCount: 1}, + }, + } + require.NoError(t, types.ValidateScoreStatesGenesis(g, currentEpoch)) + }) +} diff --git a/x/audit/v1/types/keys.go b/x/audit/v1/types/keys.go index e2a1af0f..9f08eb7f 100644 --- a/x/audit/v1/types/keys.go +++ b/x/audit/v1/types/keys.go @@ -669,6 +669,28 @@ func TranscriptByTargetBucketEpochKey(targetAccount string, bucketType uint32, e return key } +// ReporterStorageTruthResultByTargetEpochScanRange returns [start, end) +// iterator bounds for scanning a target's reporter-result secondary index in +// the inclusive epoch range [startEpoch, endEpoch]. It is MaxUint64-safe. +func ReporterStorageTruthResultByTargetEpochScanRange(targetAccount string, startEpoch, endEpoch uint64) ([]byte, []byte) { + base := make([]byte, 0, len(reporterResultByTargetPrefix)+len(targetAccount)+1) + base = append(base, reporterResultByTargetPrefix...) + base = append(base, targetAccount...) + base = append(base, '/') + + start := make([]byte, 0, len(base)+8) + start = append(start, base...) + start = binary.BigEndian.AppendUint64(start, startEpoch) + + if endEpoch == ^uint64(0) { + return start, prefixEnd(base) + } + end := make([]byte, 0, len(base)+8) + end = append(end, base...) + end = binary.BigEndian.AppendUint64(end, endEpoch+1) + return start, end +} + // TranscriptByTargetBucketEpochScanPrefix returns the prefix for epoch-range scanning of // transcripts for a given (target, bucket). Iterator start/end are derived by callers using // the u64be-encoded epoch bounds. @@ -681,3 +703,21 @@ func TranscriptByTargetBucketEpochScanPrefix(targetAccount string, bucketType ui key = append(key, '/') return key } + +// TranscriptByTargetBucketEpochScanRange returns [start, end) iterator bounds +// for scanning transcript secondary-index records for a target/bucket in the +// inclusive epoch range [startEpoch, endEpoch]. It is MaxUint64-safe. +func TranscriptByTargetBucketEpochScanRange(targetAccount string, bucketType uint32, startEpoch, endEpoch uint64) ([]byte, []byte) { + base := TranscriptByTargetBucketEpochScanPrefix(targetAccount, bucketType) + start := make([]byte, 0, len(base)+8) + start = append(start, base...) + start = binary.BigEndian.AppendUint64(start, startEpoch) + + if endEpoch == ^uint64(0) { + return start, prefixEnd(base) + } + end := make([]byte, 0, len(base)+8) + end = append(end, base...) + end = binary.BigEndian.AppendUint64(end, endEpoch+1) + return start, end +} diff --git a/x/audit/v1/types/params.go b/x/audit/v1/types/params.go index aad2717a..45162cfd 100644 --- a/x/audit/v1/types/params.go +++ b/x/audit/v1/types/params.go @@ -650,6 +650,16 @@ func (p Params) Validate() error { if v := uint64(p.StorageTruthContradictionWindowEpochs); v > requiredHistory { requiredHistory = v } + // Per CP-R3 C-F3 — divergence window scan reads pruned state if + // KeepLastEpochEntries < DivergenceWindowEpochs, silently zeroing + // stats and skipping chronic-divergence penalties. Heal-deadline + // is also looked back over multi-epoch in fact-index queries. + if v := uint64(p.StorageTruthDivergenceWindowEpochs); v > requiredHistory { + requiredHistory = v + } + if v := uint64(p.StorageTruthHealDeadlineEpochs); v > requiredHistory { + requiredHistory = v + } if requiredHistory > 0 && p.KeepLastEpochEntries < requiredHistory { return fmt.Errorf("keep_last_epoch_entries must be >= max epoch lookback windows (need >= %d)", requiredHistory) } @@ -734,8 +744,12 @@ func (p Params) Validate() error { if p.StorageTruthTicketDeteriorationHealThreshold <= 0 { return fmt.Errorf("storage_truth_ticket_deterioration_heal_threshold must be > 0") } - if p.StorageTruthNodeSuspicionThresholdPostpone > p.StorageTruthNodeSuspicionThresholdStrongPostpone { - return fmt.Errorf("storage_truth_node_suspicion_threshold_postpone must be <= storage_truth_node_suspicion_threshold_strong_postpone") + if p.StorageTruthNodeSuspicionThresholdPostpone >= p.StorageTruthNodeSuspicionThresholdStrongPostpone { + // Per CP-R3 C-F5 — strict < ; equality collapses the strong band + // into a no-op (any score crossing Postpone instantly satisfies + // StrongPostpone too, so the distinct strong-band predicates and + // recovery clean-pass count never apply). + return fmt.Errorf("storage_truth_node_suspicion_threshold_postpone must be < storage_truth_node_suspicion_threshold_strong_postpone") } if p.StorageTruthOldClassAFaultWindow == 0 { return fmt.Errorf("storage_truth_old_class_a_fault_window must be > 0") @@ -758,8 +772,24 @@ func (p Params) Validate() error { if p.StorageTruthRecoveryCleanPassCount == 0 { return fmt.Errorf("storage_truth_recovery_clean_pass_count must be > 0") } - if p.StorageTruthHealVerifierCount == 0 { - return fmt.Errorf("storage_truth_heal_verifier_count must be > 0") + // Per CP-R3 C-F1 — StorageTruthStrongRecoveryCleanPassCount was missing + // entirely from Validate(). Without this, governance could pass + // MsgUpdateParams with StrongRecovery=1 and Recovery=10, inverting the + // strong-band semantics (a strong-postponed node would recover faster + // than a normal-postponed one). Strong-band recovery must be at least + // as strict as normal-band recovery (spec §17). + if p.StorageTruthStrongRecoveryCleanPassCount == 0 { + return fmt.Errorf("storage_truth_strong_recovery_clean_pass_count must be > 0") + } + if p.StorageTruthStrongRecoveryCleanPassCount < p.StorageTruthRecoveryCleanPassCount { + return fmt.Errorf("storage_truth_strong_recovery_clean_pass_count must be >= storage_truth_recovery_clean_pass_count") + } + // Per CP-R3 C-F2 — gov DoS surface: HealVerifierCount with no upper bound + // would let a proposal force every heal op to drain the eligible-supernode + // pool. 32 is conservative (assignStorageTruthHealParticipants picks N + // distinct active accounts; testnets/mainnet active sets are O(10²) at most). + if p.StorageTruthHealVerifierCount == 0 || p.StorageTruthHealVerifierCount > 32 { + return fmt.Errorf("storage_truth_heal_verifier_count must be within 1..32") } if p.StorageTruthClassBFaultWindow == 0 { return fmt.Errorf("storage_truth_class_b_fault_window must be > 0") diff --git a/x/audit/v1/types/params_r3_validate_test.go b/x/audit/v1/types/params_r3_validate_test.go new file mode 100644 index 00000000..e2ae4f83 --- /dev/null +++ b/x/audit/v1/types/params_r3_validate_test.go @@ -0,0 +1,95 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// CP-R3 C-F1 — StrongRecoveryCleanPassCount must be validated. +func TestParamsValidate_StrongRecoveryCleanPassCount(t *testing.T) { + base := DefaultParams() + + t.Run("zero rejected", func(t *testing.T) { + p := base + p.StorageTruthStrongRecoveryCleanPassCount = 0 + require.ErrorContains(t, p.Validate(), "storage_truth_strong_recovery_clean_pass_count must be > 0") + }) + + t.Run("less than normal recovery rejected", func(t *testing.T) { + p := base + p.StorageTruthRecoveryCleanPassCount = 5 + p.StorageTruthStrongRecoveryCleanPassCount = 3 + require.ErrorContains(t, p.Validate(), "must be >= storage_truth_recovery_clean_pass_count") + }) + + t.Run("equal to normal recovery accepted", func(t *testing.T) { + p := base + p.StorageTruthRecoveryCleanPassCount = 3 + p.StorageTruthStrongRecoveryCleanPassCount = 3 + require.NoError(t, p.Validate()) + }) +} + +// CP-R3 C-F2 — HealVerifierCount must have an upper bound (gov DoS surface). +func TestParamsValidate_HealVerifierCountUpperBound(t *testing.T) { + base := DefaultParams() + + t.Run("zero rejected", func(t *testing.T) { + p := base + p.StorageTruthHealVerifierCount = 0 + require.ErrorContains(t, p.Validate(), "must be within 1..32") + }) + + t.Run("over cap rejected", func(t *testing.T) { + p := base + p.StorageTruthHealVerifierCount = 33 + require.ErrorContains(t, p.Validate(), "must be within 1..32") + }) + + t.Run("at cap accepted", func(t *testing.T) { + p := base + p.StorageTruthHealVerifierCount = 32 + require.NoError(t, p.Validate()) + }) +} + +// CP-R3 C-F3 — KeepLastEpochEntries must be >= DivergenceWindowEpochs and HealDeadlineEpochs. +func TestParamsValidate_KeepLastEpochEntriesCoversDivergenceAndHealWindows(t *testing.T) { + t.Run("KeepLast < DivergenceWindow rejected", func(t *testing.T) { + p := DefaultParams() + p.StorageTruthDivergenceWindowEpochs = 30 + p.KeepLastEpochEntries = 7 + require.ErrorContains(t, p.Validate(), "keep_last_epoch_entries must be >= max epoch lookback windows") + }) + + t.Run("KeepLast < HealDeadlineEpochs rejected", func(t *testing.T) { + p := DefaultParams() + p.StorageTruthHealDeadlineEpochs = 50 + p.KeepLastEpochEntries = 21 // covers OldClassA(21) but not HealDeadline(50) + require.ErrorContains(t, p.Validate(), "keep_last_epoch_entries must be >= max epoch lookback windows") + }) + + t.Run("defaults satisfy invariant", func(t *testing.T) { + require.NoError(t, DefaultParams().Validate()) + }) +} + +// CP-R3 C-F5 — Postpone < StrongPostpone strict (equality collapses the band). +func TestParamsValidate_PostponeStrictlyLessThanStrongPostpone(t *testing.T) { + base := DefaultParams() + + t.Run("equality rejected", func(t *testing.T) { + p := base + p.StorageTruthNodeSuspicionThresholdPostpone = 100 + p.StorageTruthNodeSuspicionThresholdStrongPostpone = 100 + require.ErrorContains(t, p.Validate(), "must be < storage_truth_node_suspicion_threshold_strong_postpone") + }) + + t.Run("strict ordering accepted", func(t *testing.T) { + p := base + p.StorageTruthNodeSuspicionThresholdPostpone = 90 + p.StorageTruthNodeSuspicionThresholdStrongPostpone = 140 + require.NoError(t, p.Validate()) + }) +}