diff --git a/supernode/cmd/evmigration_test.go b/supernode/cmd/evmigration_test.go index e8431197..a1e0f55b 100644 --- a/supernode/cmd/evmigration_test.go +++ b/supernode/cmd/evmigration_test.go @@ -249,8 +249,7 @@ func (f *fakeMigrationClient) BroadcastMigrationTx(_ context.Context, msg sdk.Ms // newMigrationCfg creates a config with tmpDir for tests that need config persistence. func newMigrationCfg(t *testing.T, keyName, evmKeyName string) *snConfig.Config { t.Helper() - cfg := &snConfig.Config{} - cfg.SupernodeConfig.KeyName = keyName + cfg := snConfig.CreateDefaultConfig(keyName, "", "testing", "test", "keys", "", "", "") cfg.SupernodeConfig.EVMKeyName = evmKeyName cfg.BaseDir = t.TempDir() return cfg @@ -793,13 +792,8 @@ func TestKeyDeleteAfterMigration(t *testing.T) { func TestConfigUpdateAfterMigration(t *testing.T) { tmpDir := t.TempDir() - cfg := &snConfig.Config{ - SupernodeConfig: snConfig.SupernodeConfig{ - KeyName: "mykey", - Identity: "lumera1oldaddr", - EVMKeyName: "evm-key", - }, - } + cfg := snConfig.CreateDefaultConfig("mykey", "lumera1oldaddr", "testing", "test", "keys", "", "", "") + cfg.SupernodeConfig.EVMKeyName = "evm-key" cfg.BaseDir = tmpDir newAddr := "lumera1newaddr" diff --git a/supernode/config/config.go b/supernode/config/config.go index a88a324d..db9f5f2f 100644 --- a/supernode/config/config.go +++ b/supernode/config/config.go @@ -72,6 +72,8 @@ type LogConfig struct { } type StorageChallengeConfig struct { + enabledSet bool `yaml:"-"` + Enabled bool `yaml:"enabled"` PollIntervalMs uint64 `yaml:"poll_interval_ms,omitempty"` SubmitEvidence bool `yaml:"submit_evidence,omitempty"` diff --git a/supernode/config/config_lep6_test.go b/supernode/config/config_lep6_test.go index 6e580371..a7fa1c2e 100644 --- a/supernode/config/config_lep6_test.go +++ b/supernode/config/config_lep6_test.go @@ -8,17 +8,14 @@ import ( "time" ) -func TestLoadConfig_LEP6SafeDefaults(t *testing.T) { +func TestLoadConfig_LEP6DefaultEnabled(t *testing.T) { t.Parallel() - // LEP-6 review C1 (Matee, 2026-05-06): with the missing-block default - // flipped to FALSE, an operator who upgrades without adding the LEP-6 - // toggles MUST stay opted out. This test pins that contract: even - // though storage_challenge.enabled=true, the missing lep6 / recheck / - // self_healing blocks default to disabled. Operators must opt in - // explicitly. Runtime knobs (timeouts, concurrency) still receive - // their defaults so that flipping a toggle on later requires no - // further config edits. + // Testnet rollout default: an operator who upgrades without adding the + // LEP-6 blocks should run storage challenge + LEP-6 while the chain + // StorageTruthEnforcementMode remains the protocol gate. Explicit + // enabled:false remains the emergency-disable path. Runtime knobs + // still receive defaults so no extra config edits are required. cfg := loadConfigFromBody(t, ` supernode: key_name: test-key @@ -40,8 +37,11 @@ storage_challenge: enabled: true `) - if cfg.StorageChallengeConfig.LEP6.Enabled { - t.Fatalf("storage_challenge.lep6.enabled default = true, want false (C1: opt-in not opt-out)") + if !cfg.StorageChallengeConfig.Enabled { + t.Fatalf("storage_challenge.enabled default = false, want true") + } + if !cfg.StorageChallengeConfig.LEP6.Enabled { + t.Fatalf("storage_challenge.lep6.enabled default = false, want true") } if cfg.StorageChallengeConfig.LEP6.MaxConcurrentTargets != DefaultLEP6MaxConcurrentTargets { t.Fatalf("max_concurrent_targets = %d, want %d", cfg.StorageChallengeConfig.LEP6.MaxConcurrentTargets, DefaultLEP6MaxConcurrentTargets) @@ -49,8 +49,8 @@ storage_challenge: if cfg.StorageChallengeConfig.LEP6.RecipientReadTimeout != DefaultLEP6RecipientReadTimeout { t.Fatalf("recipient_read_timeout = %s, want %s", cfg.StorageChallengeConfig.LEP6.RecipientReadTimeout, DefaultLEP6RecipientReadTimeout) } - if cfg.StorageChallengeConfig.LEP6.Recheck.Enabled { - t.Fatalf("storage_challenge.lep6.recheck.enabled default = true, want false (C1)") + if !cfg.StorageChallengeConfig.LEP6.Recheck.Enabled { + t.Fatalf("storage_challenge.lep6.recheck.enabled default = false, want true") } if cfg.StorageChallengeConfig.LEP6.Recheck.LookbackEpochs != DefaultLEP6RecheckLookbackEpochs { t.Fatalf("recheck.lookback_epochs = %d, want %d", cfg.StorageChallengeConfig.LEP6.Recheck.LookbackEpochs, DefaultLEP6RecheckLookbackEpochs) @@ -68,8 +68,8 @@ storage_challenge: t.Fatalf("recheck.failure_backoff_ttl_ms = %d, want %d", cfg.StorageChallengeConfig.LEP6.Recheck.FailureBackoffTTLms, int(DefaultLEP6RecheckFailureBackoffTTL/time.Millisecond)) } - if cfg.SelfHealingConfig.Enabled { - t.Fatalf("self_healing.enabled default = true, want false (C1)") + if !cfg.SelfHealingConfig.Enabled { + t.Fatalf("self_healing.enabled default = false, want true") } if cfg.SelfHealingConfig.PollIntervalMs != int(DefaultSelfHealingPollInterval/time.Millisecond) { t.Fatalf("self_healing.poll_interval_ms = %d, want %d", cfg.SelfHealingConfig.PollIntervalMs, int(DefaultSelfHealingPollInterval/time.Millisecond)) @@ -118,7 +118,7 @@ lumera: raptorq: files_dir: raptorq_files storage_challenge: - enabled: true + enabled: false lep6: enabled: false recheck: @@ -127,6 +127,9 @@ self_healing: enabled: false `) + if cfg.StorageChallengeConfig.Enabled { + t.Fatalf("storage_challenge.enabled = true, want explicit false emergency disable preserved") + } if cfg.StorageChallengeConfig.LEP6.Enabled { t.Fatalf("storage_challenge.lep6.enabled = true, want explicit false emergency disable preserved") } diff --git a/supernode/config/lep6.go b/supernode/config/lep6.go index a005122e..cf9f168b 100644 --- a/supernode/config/lep6.go +++ b/supernode/config/lep6.go @@ -32,6 +32,17 @@ func (c *Config) UnmarshalYAML(value *yaml.Node) error { return nil } +func (c *StorageChallengeConfig) UnmarshalYAML(value *yaml.Node) error { + type raw StorageChallengeConfig + var out raw + if err := value.Decode(&out); err != nil { + return err + } + *c = StorageChallengeConfig(out) + c.enabledSet = hasYAMLKey(value, "enabled") + return nil +} + func (c *StorageChallengeLEP6Config) UnmarshalYAML(value *yaml.Node) error { type raw StorageChallengeLEP6Config var out raw @@ -77,30 +88,22 @@ func hasYAMLKey(value *yaml.Node, key string) bool { return false } -// applyLEP6DefaultsAndValidate applies safe defaults to LEP-6 toggles and -// runtime knobs, then runs validation. +// applyLEP6DefaultsAndValidate applies testnet-ready defaults to LEP-6 +// toggles and runtime knobs, then runs validation. // -// LEP-6 review C1 (Matee, 2026-05-06): the missing-block default for the -// three LEP-6 toggles (storage_challenge.lep6.enabled, -// storage_challenge.lep6.recheck.enabled, self_healing.enabled) is FALSE. -// Pre-Wave-4 the missing-block default was TRUE, which silently auto-opted -// every operator into LEP-6 on upgrade. Now an operator must explicitly -// opt in via either an explicit `enabled: true` in their YAML or by relying -// on `CreateDefaultConfig`, which writes the explicit toggles into the -// generated supernode.yml. Operators who want their existing config to -// pick up LEP-6 must add the toggles explicitly. -// -// Chain enforcement remains the protocol source of truth: even when these -// toggles are TRUE, every LEP-6 service no-ops while -// StorageTruthEnforcementMode is UNSPECIFIED (see e.g. -// LEP6Dispatcher.DispatchEpoch and self_healing.Service.Run). The toggles -// are only an operator-side opt-in switch. +// Storage truth remains chain-gated: even when local toggles default TRUE, +// every LEP-6 service no-ops while StorageTruthEnforcementMode is +// UNSPECIFIED. Operators can still emergency-disable any local runtime by +// setting the relevant `enabled: false` explicitly in YAML. func (c *Config) applyLEP6DefaultsAndValidate() error { - // LEP-6 toggles: missing-block defaults to FALSE (C1). - // enabledSet=true means the YAML had an explicit `enabled:` key — keep - // the operator's value verbatim. + // Local storage-truth runtimes default ON for testnet operators who update + // without adding new config blocks. enabledSet=true means the YAML had an + // explicit `enabled:` key — keep the operator's value verbatim. + if !c.StorageChallengeConfig.enabledSet { + c.StorageChallengeConfig.Enabled = true + } if !c.StorageChallengeConfig.LEP6.enabledSet { - c.StorageChallengeConfig.LEP6.Enabled = false + c.StorageChallengeConfig.LEP6.Enabled = true } if c.StorageChallengeConfig.LEP6.MaxConcurrentTargets == 0 { c.StorageChallengeConfig.LEP6.MaxConcurrentTargets = DefaultLEP6MaxConcurrentTargets @@ -111,7 +114,7 @@ func (c *Config) applyLEP6DefaultsAndValidate() error { recheck := &c.StorageChallengeConfig.LEP6.Recheck if !recheck.enabledSet { - recheck.Enabled = false + recheck.Enabled = true } if recheck.LookbackEpochs == 0 { recheck.LookbackEpochs = DefaultLEP6RecheckLookbackEpochs @@ -130,7 +133,7 @@ func (c *Config) applyLEP6DefaultsAndValidate() error { } if !c.SelfHealingConfig.enabledSet { - c.SelfHealingConfig.Enabled = false + c.SelfHealingConfig.Enabled = true } if c.SelfHealingConfig.PollIntervalMs == 0 { c.SelfHealingConfig.PollIntervalMs = int(DefaultSelfHealingPollInterval / time.Millisecond) diff --git a/supernode/config/lep6_config_regression_test.go b/supernode/config/lep6_config_regression_test.go index 955c4c54..89ccdc8b 100644 --- a/supernode/config/lep6_config_regression_test.go +++ b/supernode/config/lep6_config_regression_test.go @@ -7,36 +7,35 @@ import ( "testing" ) -// LEP-6 review regression: LEP-6 PR286 review fix regression tests. +// LEP-6 config regression tests. // // Coverage: -// - C1: missing-block default for LEP-6 toggles is FALSE (no silent -// upgrade-time opt-in). Already covered structurally by -// TestLoadConfig_LEP6SafeDefaults; this file adds focused negative -// cases (wrong-direction default would cause auto-opt-in) and the -// advisory helper. +// - Missing-block defaults are ON for storage_challenge, LEP-6 dispatch, +// recheck, and self-healing so testnet operators get storage-truth +// runtime after update unless they explicitly emergency-disable it. // - L6: structural validator rejects recheck=true with disabled parents. -// Before this fix, fixtures could carry recheck.enabled=true while -// storage_challenge.enabled=false, silently no-op'd at runtime. -func TestLoadConfig_C1_MissingBlocksDefaultDisabled(t *testing.T) { +func TestLoadConfig_MissingBlocksDefaultEnabled(t *testing.T) { t.Parallel() - // No LEP-6 / recheck / self_healing block at all — defaults must be FALSE. + // No storage_challenge / LEP-6 / recheck / self_healing block at all — defaults must be TRUE. cfg := loadConfigFromBody(t, baseConfigYAML()) - if cfg.StorageChallengeConfig.LEP6.Enabled { - t.Fatalf("C1: storage_challenge.lep6.enabled = true on missing-block; want false (no silent opt-in)") + if !cfg.StorageChallengeConfig.Enabled { + t.Fatalf("storage_challenge.enabled = false on missing-block; want true") } - if cfg.StorageChallengeConfig.LEP6.Recheck.Enabled { - t.Fatalf("C1: storage_challenge.lep6.recheck.enabled = true on missing-block; want false") + if !cfg.StorageChallengeConfig.LEP6.Enabled { + t.Fatalf("storage_challenge.lep6.enabled = false on missing-block; want true") + } + if !cfg.StorageChallengeConfig.LEP6.Recheck.Enabled { + t.Fatalf("storage_challenge.lep6.recheck.enabled = false on missing-block; want true") } - if cfg.SelfHealingConfig.Enabled { - t.Fatalf("C1: self_healing.enabled = true on missing-block; want false") + if !cfg.SelfHealingConfig.Enabled { + t.Fatalf("self_healing.enabled = false on missing-block; want true") } } -func TestLoadConfig_C1_ExplicitTrueRespected(t *testing.T) { +func TestLoadConfig_ExplicitTrueRespected(t *testing.T) { t.Parallel() cfg := loadConfigFromBody(t, baseConfigYAML()+` @@ -51,24 +50,33 @@ self_healing: `) if !cfg.StorageChallengeConfig.LEP6.Enabled { - t.Fatalf("C1: explicit storage_challenge.lep6.enabled=true must be respected") + t.Fatalf("explicit storage_challenge.lep6.enabled=true must be respected") } if !cfg.StorageChallengeConfig.LEP6.Recheck.Enabled { - t.Fatalf("C1: explicit recheck.enabled=true must be respected") + t.Fatalf("explicit recheck.enabled=true must be respected") } if !cfg.SelfHealingConfig.Enabled { - t.Fatalf("C1: explicit self_healing.enabled=true must be respected") + t.Fatalf("explicit self_healing.enabled=true must be respected") } } -func TestLoadConfig_C1_OptInAdvisory(t *testing.T) { +func TestLoadConfig_LEP6OperatorOptInAdvisory(t *testing.T) { t.Parallel() - // All three opted out — advisory must mention each disabled service. - allOff := loadConfigFromBody(t, baseConfigYAML()) + // Explicitly opted out — advisory must mention each disabled service. + allOff := loadConfigFromBody(t, baseConfigYAML()+` +storage_challenge: + enabled: true + lep6: + enabled: false + recheck: + enabled: false +self_healing: + enabled: false +`) advisory := allOff.LEP6OperatorOptInAdvisory() if advisory == "" { - t.Fatalf("C1: advisory must be non-empty when toggles are off") + t.Fatalf("advisory must be non-empty when toggles are off") } for _, want := range []string{ "storage_challenge.lep6.enabled=false", @@ -80,17 +88,8 @@ func TestLoadConfig_C1_OptInAdvisory(t *testing.T) { } } - // All three opted in — advisory must be empty. - allOn := loadConfigFromBody(t, baseConfigYAML()+` -storage_challenge: - enabled: true - lep6: - enabled: true - recheck: - enabled: true -self_healing: - enabled: true -`) + // Missing blocks now default on — advisory must be empty. + allOn := loadConfigFromBody(t, baseConfigYAML()) if got := allOn.LEP6OperatorOptInAdvisory(); got != "" { t.Fatalf("C1 advisory should be empty when all opted in; got %q", got) } diff --git a/supernode/host_reporter/service.go b/supernode/host_reporter/service.go index f54e90f2..0eb91924 100644 --- a/supernode/host_reporter/service.go +++ b/supernode/host_reporter/service.go @@ -27,9 +27,10 @@ import ( ) const ( - defaultPollInterval = 5 * time.Second - defaultDialTimeout = 2 * time.Second - defaultTickTimeout = 30 * time.Second + defaultPollInterval = 5 * time.Second + defaultDialTimeout = 2 * time.Second + defaultTickTimeout = 30 * time.Second + defaultNonFullProofWaitWindow = 90 * time.Second maxConcurrentTargets = 8 @@ -55,6 +56,14 @@ type ProofResultRequeuer interface { RequeueResults(epochID uint64, results []*audittypes.StorageProofResult) } +// ProofResultCounter is implemented by providers that can report whether proof +// rows are buffered without draining them. This lets SHADOW / SOFT reports wait +// briefly for LEP-6 dispatch instead of burning the one-report-per-epoch slot +// before late proof rows arrive. +type ProofResultCounter interface { + CountResults(epochID uint64) int +} + // Service submits one MsgSubmitEpochReport per epoch for the local supernode. // All runtime behavior is driven by on-chain params/queries; there are no local config knobs. type Service struct { @@ -73,6 +82,9 @@ type Service struct { proofResultProviderMu sync.RWMutex proofResultProvider ProofResultProvider + + nonFullProofWaitWindow time.Duration + nonFullProofWaitStarted map[uint64]time.Time } // SetProofResultProvider attaches a ProofResultProvider to be drained on each @@ -130,15 +142,17 @@ func NewService(identity string, lumeraClient lumera.Client, kr keyring.Keyring, } return &Service{ - identity: identity, - lumera: lumeraClient, - keyring: kr, - keyName: keyName, - pollInterval: defaultPollInterval, - dialTimeout: defaultDialTimeout, - metrics: statussvc.NewMetricsCollector(), - storagePaths: storagePaths, - p2pDataDir: strings.TrimSpace(p2pDataDir), + identity: identity, + lumera: lumeraClient, + keyring: kr, + keyName: keyName, + pollInterval: defaultPollInterval, + dialTimeout: defaultDialTimeout, + metrics: statussvc.NewMetricsCollector(), + storagePaths: storagePaths, + p2pDataDir: strings.TrimSpace(p2pDataDir), + nonFullProofWaitWindow: defaultNonFullProofWaitWindow, + nonFullProofWaitStarted: make(map[uint64]time.Time), }, nil } @@ -191,8 +205,20 @@ func (s *Service) tick(ctx context.Context) { var storageProofResults []*audittypes.StorageProofResult proofResultProvider := s.getProofResultProvider() if proofResultProvider != nil { - storageProofResults = proofResultProvider.CollectResults(epochID) mode, modeOK := s.storageTruthEnforcementMode(tickCtx) + if modeOK && mode != audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL && len(assignResp.TargetSupernodeAccounts) > 0 { + if counter, ok := proofResultProvider.(ProofResultCounter); ok && counter.CountResults(epochID) == 0 && s.shouldWaitForNonFullProofRows(epochID) { + logtrace.Info(tickCtx, "epoch report: waiting for non-FULL LEP-6 proof rows", logtrace.Fields{ + "epoch_id": epochID, + "assigned_targets": len(assignResp.TargetSupernodeAccounts), + "mode": mode.String(), + "wait_window_ms": s.nonFullProofWaitWindow.Milliseconds(), + }) + return + } + } + storageProofResults = proofResultProvider.CollectResults(epochID) + delete(s.nonFullProofWaitStarted, epochID) if modeOK && mode == audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_FULL { // FULL mode is the only mode where the chain enforces compound // storage-proof coverage (one RECENT + one OLD per assigned target). @@ -214,15 +240,11 @@ func (s *Service) tick(ctx context.Context) { } } else if modeOK && len(assignResp.TargetSupernodeAccounts) > 0 && len(storageProofResults) == 0 { // SHADOW / SOFT / UNSPECIFIED: chain accepts empty StorageProofResults - // (only FULL enforces compound coverage). Submitting the host / - // peer-observation report is mandatory regardless — withholding it - // would feed audit_missing_reports and risk self-postponement - // (ConsecutiveEpochsToPostpone defaults to 1). The trade-off is - // that a same-epoch idempotency window can cause late-arriving - // proof rows to be rejected as duplicate; that is acceptable in - // observational modes because SHADOW/SOFT proofs do not affect - // scoring (LEP-6 PR286 review F1). - logtrace.Info(tickCtx, "epoch report: submitting in non-FULL mode with empty LEP-6 proof rows", logtrace.Fields{ + // (only FULL enforces compound coverage). We wait only for a bounded + // local window before submitting empty rows, preserving host-report + // liveness while avoiding the early-submit race that discards late + // LEP-6 proof rows as duplicate epoch reports. + logtrace.Info(tickCtx, "epoch report: submitting in non-FULL mode with empty LEP-6 proof rows after wait window", logtrace.Fields{ "epoch_id": epochID, "assigned_targets": len(assignResp.TargetSupernodeAccounts), "mode": mode.String(), @@ -280,10 +302,18 @@ func (s *Service) tick(ctx context.Context) { // - any other error (transient RPC / sequence / validation) → // requeue so next tick can retry with the same proofs. if chainerrors.IsEpochReportDuplicate(err) { - logtrace.Info(tickCtx, "epoch report submit returned chain duplicate; drained proof rows discarded", logtrace.Fields{ + fields := logtrace.Fields{ "epoch_id": epochID, "proof_results": len(storageProofResults), - }) + } + if len(storageProofResults) > 0 { + fields["proof_result_classes"] = proofResultClassCounts(storageProofResults) + } + if len(storageProofResults) > 0 { + logtrace.Warn(tickCtx, "epoch report submit returned chain duplicate; drained proof rows discarded", fields) + } else { + logtrace.Info(tickCtx, "epoch report submit returned chain duplicate; drained proof rows discarded", fields) + } return } requeueProofResults(proofResultProvider, epochID, storageProofResults) @@ -302,6 +332,32 @@ func (s *Service) tick(ctx context.Context) { }) } +func proofResultClassCounts(results []*audittypes.StorageProofResult) map[string]uint64 { + out := make(map[string]uint64) + for _, result := range results { + if result == nil { + continue + } + out[result.ResultClass.String()]++ + } + return out +} + +func (s *Service) shouldWaitForNonFullProofRows(epochID uint64) bool { + if s.nonFullProofWaitWindow <= 0 { + return false + } + if s.nonFullProofWaitStarted == nil { + s.nonFullProofWaitStarted = make(map[uint64]time.Time) + } + started, ok := s.nonFullProofWaitStarted[epochID] + if !ok { + s.nonFullProofWaitStarted[epochID] = time.Now() + return true + } + return time.Since(started) < s.nonFullProofWaitWindow +} + func (s *Service) storageTruthEnforcementMode(ctx context.Context) (audittypes.StorageTruthEnforcementMode, bool) { paramsResp, err := s.lumera.Audit().GetParams(ctx) if err != nil || paramsResp == nil { diff --git a/supernode/host_reporter/tick_behavior_test.go b/supernode/host_reporter/tick_behavior_test.go index 4db639df..475f540b 100644 --- a/supernode/host_reporter/tick_behavior_test.go +++ b/supernode/host_reporter/tick_behavior_test.go @@ -256,6 +256,7 @@ func TestTick_SkipsOnEpochReportLookupError(t *testing.T) { // fixed slice of synthetic StorageProofResult records. type stubProofResultProvider struct { queriedEpochs []uint64 + countedEpochs []uint64 requeuedEpochs []uint64 results []*audittypes.StorageProofResult } @@ -265,6 +266,11 @@ func (s *stubProofResultProvider) CollectResults(epochID uint64) []*audittypes.S return s.results } +func (s *stubProofResultProvider) CountResults(epochID uint64) int { + s.countedEpochs = append(s.countedEpochs, epochID) + return len(s.results) +} + func (s *stubProofResultProvider) RequeueResults(epochID uint64, results []*audittypes.StorageProofResult) { s.requeuedEpochs = append(s.requeuedEpochs, epochID) s.results = append([]*audittypes.StorageProofResult(nil), results...) @@ -321,23 +327,15 @@ func TestTick_AttachedProofResultProviderIsDrainedAndForwarded(t *testing.T) { } } -// TestTick_SHADOWModeSubmitsEmptyProofs is the LEP-6 PR286 F1 regression: -// in SHADOW the chain only enforces compound proof coverage in FULL mode -// (see lumera x/audit/v1/keeper/msg_submit_epoch_report.go:143). The host -// reporter MUST submit the epoch report even when local LEP-6 proof rows -// are empty, otherwise it stops sending host/peer observations entirely -// and feeds the audit_missing_reports postponement path. -func TestTick_SHADOWModeSubmitsEmptyProofs(t *testing.T) { - testTickSubmitsEmptyProofsForMode(t, audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_SHADOW) +func TestTick_SHADOWModeWaitsForProofRowsEarlyInEpoch(t *testing.T) { + testTickWaitsForEmptyProofsEarlyInEpoch(t, audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_SHADOW) } -// TestTick_SOFTModeSubmitsEmptyProofs covers the same F1 fix as SHADOW — -// SOFT is also an observational mode and chain accepts empty proof rows. -func TestTick_SOFTModeSubmitsEmptyProofs(t *testing.T) { - testTickSubmitsEmptyProofsForMode(t, audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_SOFT) +func TestTick_SOFTModeWaitsForProofRowsEarlyInEpoch(t *testing.T) { + testTickWaitsForEmptyProofsEarlyInEpoch(t, audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_SOFT) } -func testTickSubmitsEmptyProofsForMode(t *testing.T, mode audittypes.StorageTruthEnforcementMode) { +func testTickWaitsForEmptyProofsEarlyInEpoch(t *testing.T, mode audittypes.StorageTruthEnforcementMode) { t.Helper() ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -361,12 +359,55 @@ func testTickSubmitsEmptyProofsForMode(t *testing.T, mode audittypes.StorageTrut client.EXPECT().SuperNode().AnyTimes().Return(sn) client.EXPECT().Node().AnyTimes().Return(node) sn.EXPECT().GetSupernodeWithLatestAddress(gomock.Any(), "snA").AnyTimes().Return(&supernodemod.SuperNodeInfo{LatestAddress: "127.0.0.1:4444"}, nil) + auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + provider := &stubProofResultProvider{} + svc, err := NewService(identity, client, kr, keyName, "", "") + if err != nil { + t.Fatalf("new service: %v", err) + } + svc.SetProofResultProvider(provider) + svc.dialTimeout = 10 * time.Millisecond + svc.nonFullProofWaitWindow = time.Hour + svc.tick(context.Background()) + + if len(provider.queriedEpochs) != 0 { + t.Fatalf("expected early wait to avoid draining proof rows, got CollectResults calls %v", provider.queriedEpochs) + } + if len(provider.countedEpochs) != 1 || provider.countedEpochs[0] != 13 { + t.Fatalf("expected non-destructive proof count for epoch 13, got %v", provider.countedEpochs) + } +} + +func TestTick_SHADOWModeSubmitsEmptyProofsAfterWaitWindow(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + kr, keyName, identity := testKeyringAndIdentity(t) + auditMod := &stubAuditModule{ + currentEpoch: &audittypes.QueryCurrentEpochResponse{EpochId: 16}, + anchor: &audittypes.QueryEpochAnchorResponse{Anchor: audittypes.EpochAnchor{EpochId: 16}}, + epochReportErr: status.Error(codes.NotFound, "not found"), + assigned: &audittypes.QueryAssignedTargetsResponse{ + TargetSupernodeAccounts: []string{"snA"}, + }, + params: audittypes.Params{StorageTruthEnforcementMode: audittypes.StorageTruthEnforcementMode_STORAGE_TRUTH_ENFORCEMENT_MODE_SHADOW}, + } + auditMsg := auditmsgmod.NewMockModule(ctrl) + node := nodemod.NewMockModule(ctrl) + sn := supernodemod.NewMockModule(ctrl) + client := lumeraMock.NewMockClient(ctrl) + client.EXPECT().Audit().AnyTimes().Return(auditMod) + client.EXPECT().AuditMsg().AnyTimes().Return(auditMsg) + client.EXPECT().SuperNode().AnyTimes().Return(sn) + client.EXPECT().Node().AnyTimes().Return(node) + sn.EXPECT().GetSupernodeWithLatestAddress(gomock.Any(), "snA").AnyTimes().Return(&supernodemod.SuperNodeInfo{LatestAddress: "127.0.0.1:4444"}, nil) provider := &stubProofResultProvider{} - auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), uint64(13), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), uint64(16), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, _ uint64, _ audittypes.HostReport, _ []*audittypes.StorageChallengeObservation, proofs []*audittypes.StorageProofResult) (*sdktx.BroadcastTxResponse, error) { if len(proofs) != 0 { - t.Fatalf("expected empty proof results in mode %s, got %d", mode, len(proofs)) + t.Fatalf("expected empty proof results after wait expiry, got %d", len(proofs)) } return &sdktx.BroadcastTxResponse{}, nil }, @@ -378,10 +419,12 @@ func testTickSubmitsEmptyProofsForMode(t *testing.T, mode audittypes.StorageTrut } svc.SetProofResultProvider(provider) svc.dialTimeout = 10 * time.Millisecond + svc.nonFullProofWaitWindow = time.Second + svc.nonFullProofWaitStarted[16] = time.Now().Add(-2 * time.Second) svc.tick(context.Background()) - if len(provider.requeuedEpochs) != 0 { - t.Fatalf("expected no requeue when proofs were submitted (empty is fine in %s mode), got %v", mode, provider.requeuedEpochs) + if len(provider.queriedEpochs) != 1 || provider.queriedEpochs[0] != 16 { + t.Fatalf("expected proof rows drained for final empty submit, got %v", provider.queriedEpochs) } } diff --git a/supernode/storage_challenge/result_buffer.go b/supernode/storage_challenge/result_buffer.go index c12fbf92..f9fd13ff 100644 --- a/supernode/storage_challenge/result_buffer.go +++ b/supernode/storage_challenge/result_buffer.go @@ -94,6 +94,15 @@ func (b *Buffer) RequeueResults(epochID uint64, results []*audittypes.StoragePro } } +// CountResults returns the number of buffered proof rows for epochID without +// draining them. Host reporter uses this to avoid submitting an early SHADOW / +// SOFT epoch report before the LEP-6 dispatcher has had a chance to append rows. +func (b *Buffer) CountResults(epochID uint64) int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.byEpoch[epochID]) +} + // HasEligibleResult reports whether the current in-memory buffer already has a // non-NO_ELIGIBLE row for (epoch,target,bucket). It is intentionally scoped to // this process/epoch; the current Lumera audit query interface does not expose diff --git a/supernode/storage_challenge/result_buffer_test.go b/supernode/storage_challenge/result_buffer_test.go index 252e1bea..8ae36413 100644 --- a/supernode/storage_challenge/result_buffer_test.go +++ b/supernode/storage_challenge/result_buffer_test.go @@ -38,6 +38,24 @@ func ticketIDsOf(rs []*audittypes.StorageProofResult) []string { return out } +func TestBuffer_CountResultsDoesNotDrain(t *testing.T) { + b := NewBuffer() + b.Append(21, mkResult(bucketRecent, "ticket-a")) + b.Append(21, mkResult(bucketOld, "ticket-b")) + + if got := b.CountResults(21); got != 2 { + t.Fatalf("want count 2, got %d", got) + } + if got := b.CountResults(22); got != 0 { + t.Fatalf("want count 0 for different epoch, got %d", got) + } + + results := b.CollectResults(21) + if len(results) != 2 { + t.Fatalf("CountResults drained buffer: collect returned %d", len(results)) + } +} + func TestBuffer_BelowCap_ReturnsAllSortedDeterministically(t *testing.T) { b := NewBuffer() // Append in scrambled order; expect sort by (BucketType, TicketId). diff --git a/tests/integration/evmigration/evmigration_test.go b/tests/integration/evmigration/evmigration_test.go index 540e8a75..a4ae4b3d 100644 --- a/tests/integration/evmigration/evmigration_test.go +++ b/tests/integration/evmigration/evmigration_test.go @@ -137,30 +137,10 @@ func TestConfigPersistenceAfterMigration(t *testing.T) { tmpDir := t.TempDir() // Create initial config with legacy identity and evm_key_name set. - cfg := &snConfig.Config{ - SupernodeConfig: snConfig.SupernodeConfig{ - KeyName: "mykey", - Identity: "lumera1legacyaddr123", - Host: "127.0.0.1", - Port: 4444, - EVMKeyName: "evm-key", - }, - KeyringConfig: snConfig.KeyringConfig{ - Backend: "test", - Dir: "keyring", - }, - P2PConfig: snConfig.P2PConfig{ - Port: 4445, - DataDir: "data/p2p", - }, - LumeraClientConfig: snConfig.LumeraClientConfig{ - GRPCAddr: "localhost:9090", - ChainID: "lumera-testnet", - }, - RaptorQConfig: snConfig.RaptorQConfig{ - FilesDir: "data/raptorq", - }, - } + cfg := snConfig.CreateDefaultConfig("mykey", "lumera1legacyaddr123", "lumera-testnet", "test", "keyring", "", "", "") + cfg.SupernodeConfig.Host = "127.0.0.1" + cfg.SupernodeConfig.EVMKeyName = "evm-key" + cfg.RaptorQConfig.FilesDir = "data/raptorq" cfg.BaseDir = tmpDir cfgFile := filepath.Join(tmpDir, "config.yml")