From 500ef73d540a2ac11c4c26946d882c8e48f62421 Mon Sep 17 00:00:00 2001 From: NightCrawler Date: Tue, 14 Apr 2026 11:30:49 +0000 Subject: [PATCH 1/4] docs: add everlight supernode compatibility implementation plan --- ...ht-supernode-compat-implementation-plan.md | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 docs/plans/everlight-supernode-compat-implementation-plan.md diff --git a/docs/plans/everlight-supernode-compat-implementation-plan.md b/docs/plans/everlight-supernode-compat-implementation-plan.md new file mode 100644 index 00000000..5a441af4 --- /dev/null +++ b/docs/plans/everlight-supernode-compat-implementation-plan.md @@ -0,0 +1,265 @@ +# Everlight Supernode Compatibility Plan (from Lumera PR #113) + +Author: NightCrawler +Date: 2026-04-14 +Status: Proposed implementation plan (supernode-side) + +--- + +## 1) Scope and objective + +This plan defines **all required supernode-side changes** so `supernode` is fully compatible with Lumera Everlight Phase 1 behavior introduced in `lumera` PR #113. + +Primary goals: + +1. Supernode emits the **right epoch report data** for Everlight payout and storage-full state transitions. +2. Supernode audit epoch-report submission remains valid under current audit-module validation semantics. +3. Supernode/query usage is aligned with chain changes around `STORAGE_FULL` and new payout query surfaces. +4. Supernode + SDK behavior is covered by deterministic tests (unit + integration/system-level). + +--- + +## 2) Ground truth from chain (what changed and what supernode must respect) + +From `lumera` PR #113 and latest follow-up commits: + +- Everlight payout weighting uses `audit.v1 HostReport.cascade_kademlia_db_bytes`. +- `STORAGE_FULL` transition authority is now audit epoch report path (not legacy supernode metrics path). +- `SubmitEpochReport` semantics on chain currently enforce: + - if reporter is prober in epoch: peer observations for assigned targets are required, + - if reporter is not prober: peer observations are rejected. +- `GetTopSuperNodesForBlock` default selection excludes `POSTPONED` + `STORAGE_FULL` unless explicit state filters are used. +- New supernode query surfaces exist on chain: + - `pool-state`, `sn-eligibility`, `payout-history`. + +Implication: supernode must submit valid epoch reports (host + role-appropriate peer observations) and include cascade bytes in host report. + +--- + +## 3) Current supernode behavior and gaps (RCA) + +### 3.1 Current host reporter (`supernode/host_reporter/service.go`) + +Current behavior: +- Submits one `MsgSubmitEpochReport` per epoch. +- Queries assigned targets and builds `StorageChallengeObservations` correctly. +- Reports `disk_usage_percent` from local storage metrics. + +Gap: +- **Does not populate `HostReport.cascade_kademlia_db_bytes`**. + +Impact: +- Everlight payout eligibility/weight can evaluate as missing/zero bytes, causing payout misses or exclusion despite real stored data. + +### 3.2 Audit submit path (`pkg/lumera/modules/audit_msg/impl.go`) + +Current behavior: +- Builds and submits `MsgSubmitEpochReport`. +- Defensive copy of observations. + +Status: +- Compatible; no required API-level changes. + +### 3.3 Supernode chain query usage + +Current usage: +- Cascade selection path relies on `GetTopSupernodes` for action workflows. +- Bootstrap/routing path also uses list/top queries depending on component. + +Risk area: +- Default top query excludes `STORAGE_FULL`. This is desired for storage-action selection but must be understood by features expecting compute-eligible storage-full behavior. + +### 3.4 Legacy metrics collector (`supernode/supernode_metrics/*`) + +Current status: +- Legacy metrics tx path is superseded by audit epoch reports for this feature. + +Required posture: +- Do not rely on this path for Everlight payout bytes or storage-full transitions. + +--- + +## 4) Required implementation changes (supernode repo) + +## A) Mandatory functional changes + +### A1. Populate `cascade_kademlia_db_bytes` in host epoch report + +Target: +- `supernode/host_reporter/service.go` + +Required behavior: +- During `tick()`, set: + - `HostReport.CascadeKademliaDbBytes` = current node’s measured Cascade Kademlia DB bytes. + +Accepted source options (implementation choice): +1. Reuse existing P2P SQLite size calculation path (`sqliteOnDiskSizeBytes`) via an exported accessor. +2. Reuse status subsystem database metrics if available as absolute bytes (preferred to avoid duplicate logic). + +Important: +- Keep unit consistent with chain expectation: **bytes (not MB)**. +- If only MB is available from status pipeline, convert MB -> bytes deterministically. + +### A2. Keep epoch report submission role-aware and valid + +`host_reporter` already builds observations from `GetAssignedTargets`; preserve this. + +Hard requirements: +- For prober epochs: include one observation per assigned target with required port count. +- For non-prober epochs: send no observations. + +### A3. Operational logging clarity + +Add structured log fields on successful submit: +- epoch_id +- disk_usage_percent +- cascade_kademlia_db_bytes +- observations_count +- assigned_targets_count + +Purpose: rapid production diagnosis when payout/eligibility is questioned. + +--- + +## B) Query/client integration additions (recommended) + +### B1. Add supernode query client wrappers for Everlight surfaces + +Target: +- `pkg/lumera/modules/supernode` (or equivalent query module) + +Add methods: +- `GetPoolState(ctx)` +- `GetSNEligibility(ctx, validatorAddr)` +- `GetPayoutHistory(ctx, validatorAddr, pagination)` + +Why: +- Needed for operator diagnostics and future automation. +- Enables supernode runtime health endpoints to expose payout-readiness status. + +### B2. Add optional compatibility diagnostics endpoint/command + +Expose a compact “Everlight readiness” check in supernode runtime/admin tooling: +- current epoch report bytes value +- current state (ACTIVE/STORAGE_FULL/etc) +- `sn-eligibility` result + reason + +--- + +## C) Selection/behavior policy alignment checks + +### C1. Cascade action selection assumptions + +Review call sites that depend on `GetTopSupernodes` default filtering. + +Goal: +- Ensure storage actions still intentionally avoid `STORAGE_FULL` nodes unless explicit policy says otherwise. +- Document this intentionally in code comments near selection points. + +### C2. P2P bootstrap path sanity + +Paths using supernode list/top queries for routing/bootstrap should be reviewed for unintended exclusion impacts. + +Expected: +- bootstrap should not silently collapse due to state filter assumptions. + +--- + +## 5) Tests required on supernode side + +## Unit tests + +### U1. Host report bytes population + +File: +- `supernode/host_reporter/service_test.go` + +Verify: +- `cascade_kademlia_db_bytes` is populated and >0 when DB exists. +- Units are bytes. +- fallback behavior when measurement unavailable. + +### U2. Epoch role submission correctness + +Verify: +- prober epoch -> observations count matches assigned targets. +- non-prober epoch -> observations omitted. +- no nil observations. + +### U3. Report payload compatibility + +Via mocked audit msg module: +- `SubmitEpochReport` payload includes disk + cascade bytes + expected observation structure. + +## Integration tests (supernode repo) + +### I1. End-to-end epoch report compatibility + +With local chain/devnet harness: +- ensure report accepted for both prober/non-prober epochs. +- ensure no `invalid peer observations` under normal operation. + +### I2. Everlight query smoke checks (if wrappers added) + +Verify wrappers decode: +- `pool-state`, `sn-eligibility`, `payout-history`. + +### I3. Storage-full/payout readiness diagnostics + +After a high disk report: +- state transition visible via chain query. +- `sn-eligibility` reason/value observable. + +--- + +## 6) SDK / shared client considerations + +If `sdk-go` or shared internal clients are used by supernode admin tools: +- align with latest query/CLI flags and request shapes. +- ensure audit assigned-targets and epoch-anchor calls use current flag/proto semantics. + +No protocol-breaking SDK changes are required for `SubmitEpochReport`; this is payload completeness + query wrapper work. + +--- + +## 7) Rollout plan (safe order) + +1. Implement host bytes population (`cascade_kademlia_db_bytes`) + unit tests. +2. Add/confirm role-aware observation tests. +3. Add query wrappers + smoke tests. +4. Run supernode integration test flow against lumera branch with Everlight. +5. Document runtime/operator diagnostics commands. +6. Release with explicit compatibility notes. + +--- + +## 8) Risk register + +- **R1**: Wrong units (MB vs bytes) -> payout distortion. +- **R2**: Missing observations for prober epochs -> rejected reports. +- **R3**: Query wrapper drift against chain proto updates. +- **R4**: Using default top-node query where STORAGE_FULL inclusion is expected. + +Mitigations are covered by U1/U2/I1/I2/C1 checks. + +--- + +## 9) Definition of done + +Supernode-side compatibility is complete when: + +- host reporter includes `cascade_kademlia_db_bytes` in submitted epoch reports, +- epoch reports are accepted in both prober/non-prober roles without manual intervention, +- query wrappers for Everlight surfaces are available and tested, +- integration run confirms no report-validation regressions, +- docs include operator guidance for readiness and troubleshooting. + +--- + +## 10) PR breakdown recommendation (supernode) + +- PR A: host reporter payload + tests (mandatory) +- PR B: query wrappers + diagnostics + tests (recommended) +- PR C: optional bootstrap/selection policy hardening docs/comments + +This allows smallest-risk merge path while unblocking Everlight compatibility quickly. From 7eebcda64eb407a198c8b74e6e21a2a53e6b26f8 Mon Sep 17 00:00:00 2001 From: NightCrawler Date: Tue, 14 Apr 2026 12:02:31 +0000 Subject: [PATCH 2/4] everlight: implement supernode compatibility with audit epoch reports --- go.mod | 6 +-- go.sum | 8 ++-- pkg/lumera/modules/supernode/impl.go | 28 +++++++++++ pkg/lumera/modules/supernode/interface.go | 4 ++ .../modules/supernode/supernode_mock.go | 46 +++++++++++++++++++ pkg/testutil/lumera.go | 13 ++++++ sdk/adapters/lumera/adapter.go | 10 +++- supernode/cmd/start.go | 1 + supernode/host_reporter/service.go | 33 ++++++++++++- supernode/host_reporter/service_test.go | 43 ++++++++++++++++- .../reachability_active_probing_test.go | 10 ++++ 11 files changed, 192 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index f11b6800..93303b3c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/LumeraProtocol/supernode/v2 -go 1.25.5 +go 1.25.9 replace ( github.com/envoyproxy/protoc-gen-validate => github.com/bufbuild/protoc-gen-validate v1.3.0 @@ -12,11 +12,11 @@ require ( cosmossdk.io/math v1.5.3 github.com/AlecAivazis/survey/v2 v2.3.7 github.com/DataDog/zstd v1.5.7 - github.com/LumeraProtocol/lumera v1.11.0-rc + github.com/LumeraProtocol/lumera v1.11.2-0.20260414111336-a484d1fa1fd5 github.com/LumeraProtocol/rq-go v0.2.1 github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce github.com/cenkalti/backoff/v4 v4.3.0 - github.com/cometbft/cometbft v0.38.20 + github.com/cometbft/cometbft v0.38.21 github.com/cosmos/btcutil v1.0.5 github.com/cosmos/cosmos-sdk v0.53.5 github.com/cosmos/go-bip39 v1.0.0 diff --git a/go.sum b/go.sum index a6c9eaa4..7b90624c 100644 --- a/go.sum +++ b/go.sum @@ -111,8 +111,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 h1:ig/FpDD2JofP/NExKQUbn7uOSZzJAQqogfqluZK4ed4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= -github.com/LumeraProtocol/lumera v1.11.0-rc h1:ISJLUhjihuOterLMHpgGWpMZmybR1vmQLNgmSHkc1WA= -github.com/LumeraProtocol/lumera v1.11.0-rc/go.mod h1:p2sZZG3bLzSBdaW883qjuU3DXXY4NJzTTwLywr8uI0w= +github.com/LumeraProtocol/lumera v1.11.2-0.20260414111336-a484d1fa1fd5 h1:xNV1JJUjKW5CEdPxR8NWkndUz0etEWzioQfD8ryrE3M= +github.com/LumeraProtocol/lumera v1.11.2-0.20260414111336-a484d1fa1fd5/go.mod h1:/G9LTPZB+261tHoWoj7q+1fn+O/VV0zzagwLdsThSNo= github.com/LumeraProtocol/rq-go v0.2.1 h1:8B3UzRChLsGMmvZ+UVbJsJj6JZzL9P9iYxbdUwGsQI4= github.com/LumeraProtocol/rq-go v0.2.1/go.mod h1:APnKCZRh1Es2Vtrd2w4kCLgAyaL5Bqrkz/BURoRJ+O8= github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= @@ -239,8 +239,8 @@ github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1: github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= github.com/coder/websocket v1.8.7 h1:jiep6gmlfP/yq2w1gBoubJEXL9gf8x3bp6lzzX8nJxE= github.com/coder/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= -github.com/cometbft/cometbft v0.38.20 h1:i9v9rvh3Z4CZvGSWrByAOpiqNq5WLkat3r/tE/B49RU= -github.com/cometbft/cometbft v0.38.20/go.mod h1:UCu8dlHqvkAsmAFmWDRWNZJPlu6ya2fTWZlDrWsivwo= +github.com/cometbft/cometbft v0.38.21 h1:qcIJSH9LiwU5s6ZgKR5eRbsLNucbubfraDs5bzgjtOI= +github.com/cometbft/cometbft v0.38.21/go.mod h1:UCu8dlHqvkAsmAFmWDRWNZJPlu6ya2fTWZlDrWsivwo= github.com/cometbft/cometbft-db v0.14.1 h1:SxoamPghqICBAIcGpleHbmoPqy+crij/++eZz3DlerQ= github.com/cometbft/cometbft-db v0.14.1/go.mod h1:KHP1YghilyGV/xjD5DP3+2hyigWx0WTp9X+0Gnx0RxQ= github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= diff --git a/pkg/lumera/modules/supernode/impl.go b/pkg/lumera/modules/supernode/impl.go index 93e2d7e0..8bd88408 100644 --- a/pkg/lumera/modules/supernode/impl.go +++ b/pkg/lumera/modules/supernode/impl.go @@ -6,6 +6,7 @@ import ( "github.com/LumeraProtocol/lumera/x/supernode/v1/types" "github.com/LumeraProtocol/supernode/v2/pkg/errors" + "github.com/cosmos/cosmos-sdk/types/query" "google.golang.org/grpc" ) @@ -145,3 +146,30 @@ func (m *module) ListSuperNodes(ctx context.Context) (*types.QueryListSuperNodes } return resp, nil } + +// GetPoolState returns supernode reward pool state. +func (m *module) GetPoolState(ctx context.Context) (*types.QueryPoolStateResponse, error) { + resp, err := m.client.PoolState(ctx, &types.QueryPoolStateRequest{}) + if err != nil { + return nil, fmt.Errorf("failed to get pool state: %w", err) + } + return resp, nil +} + +// GetSNEligibility returns payout eligibility for validator. +func (m *module) GetSNEligibility(ctx context.Context, validatorAddress string) (*types.QuerySNEligibilityResponse, error) { + resp, err := m.client.SNEligibility(ctx, &types.QuerySNEligibilityRequest{ValidatorAddress: validatorAddress}) + if err != nil { + return nil, fmt.Errorf("failed to get supernode eligibility: %w", err) + } + return resp, nil +} + +// GetPayoutHistory returns payout history for validator. +func (m *module) GetPayoutHistory(ctx context.Context, validatorAddress string, pagination *query.PageRequest) (*types.QueryPayoutHistoryResponse, error) { + resp, err := m.client.PayoutHistory(ctx, &types.QueryPayoutHistoryRequest{ValidatorAddress: validatorAddress, Pagination: pagination}) + if err != nil { + return nil, fmt.Errorf("failed to get payout history: %w", err) + } + return resp, nil +} diff --git a/pkg/lumera/modules/supernode/interface.go b/pkg/lumera/modules/supernode/interface.go index acdfea0b..e195f906 100644 --- a/pkg/lumera/modules/supernode/interface.go +++ b/pkg/lumera/modules/supernode/interface.go @@ -5,6 +5,7 @@ import ( "context" "github.com/LumeraProtocol/lumera/x/supernode/v1/types" + "github.com/cosmos/cosmos-sdk/types/query" "google.golang.org/grpc" ) @@ -25,6 +26,9 @@ type Module interface { GetSupernodeWithLatestAddress(ctx context.Context, address string) (*SuperNodeInfo, error) GetParams(ctx context.Context) (*types.QueryParamsResponse, error) ListSuperNodes(ctx context.Context) (*types.QueryListSuperNodesResponse, error) + GetPoolState(ctx context.Context) (*types.QueryPoolStateResponse, error) + GetSNEligibility(ctx context.Context, validatorAddress string) (*types.QuerySNEligibilityResponse, error) + GetPayoutHistory(ctx context.Context, validatorAddress string, pagination *query.PageRequest) (*types.QueryPayoutHistoryResponse, error) } // NewModule creates a new SuperNode module client diff --git a/pkg/lumera/modules/supernode/supernode_mock.go b/pkg/lumera/modules/supernode/supernode_mock.go index 9b3ea06e..cb55c4ed 100644 --- a/pkg/lumera/modules/supernode/supernode_mock.go +++ b/pkg/lumera/modules/supernode/supernode_mock.go @@ -14,6 +14,7 @@ import ( reflect "reflect" types "github.com/LumeraProtocol/lumera/x/supernode/v1/types" + query "github.com/cosmos/cosmos-sdk/types/query" gomock "go.uber.org/mock/gomock" ) @@ -56,6 +57,51 @@ func (mr *MockModuleMockRecorder) GetParams(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetParams", reflect.TypeOf((*MockModule)(nil).GetParams), ctx) } +// GetPayoutHistory mocks base method. +func (m *MockModule) GetPayoutHistory(ctx context.Context, validatorAddress string, pagination *query.PageRequest) (*types.QueryPayoutHistoryResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPayoutHistory", ctx, validatorAddress, pagination) + ret0, _ := ret[0].(*types.QueryPayoutHistoryResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPayoutHistory indicates an expected call of GetPayoutHistory. +func (mr *MockModuleMockRecorder) GetPayoutHistory(ctx, validatorAddress, pagination any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPayoutHistory", reflect.TypeOf((*MockModule)(nil).GetPayoutHistory), ctx, validatorAddress, pagination) +} + +// GetPoolState mocks base method. +func (m *MockModule) GetPoolState(ctx context.Context) (*types.QueryPoolStateResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPoolState", ctx) + ret0, _ := ret[0].(*types.QueryPoolStateResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPoolState indicates an expected call of GetPoolState. +func (mr *MockModuleMockRecorder) GetPoolState(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPoolState", reflect.TypeOf((*MockModule)(nil).GetPoolState), ctx) +} + +// GetSNEligibility mocks base method. +func (m *MockModule) GetSNEligibility(ctx context.Context, validatorAddress string) (*types.QuerySNEligibilityResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSNEligibility", ctx, validatorAddress) + ret0, _ := ret[0].(*types.QuerySNEligibilityResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSNEligibility indicates an expected call of GetSNEligibility. +func (mr *MockModuleMockRecorder) GetSNEligibility(ctx, validatorAddress any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSNEligibility", reflect.TypeOf((*MockModule)(nil).GetSNEligibility), ctx, validatorAddress) +} + // GetSuperNode mocks base method. func (m *MockModule) GetSuperNode(ctx context.Context, address string) (*types.QueryGetSuperNodeResponse, error) { m.ctrl.T.Helper() diff --git a/pkg/testutil/lumera.go b/pkg/testutil/lumera.go index d7bd1212..d0e4d446 100644 --- a/pkg/testutil/lumera.go +++ b/pkg/testutil/lumera.go @@ -25,6 +25,7 @@ import ( sdktx "github.com/cosmos/cosmos-sdk/types/tx" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/cosmos/cosmos-sdk/types/query" ) // MockLumeraClient implements the lumera.Client interface for testing purposes @@ -267,6 +268,18 @@ func (m *MockSupernodeModule) ListSuperNodes(ctx context.Context) (*supernodeTyp return &supernodeTypes.QueryListSuperNodesResponse{}, nil } +func (m *MockSupernodeModule) GetPoolState(ctx context.Context) (*supernodeTypes.QueryPoolStateResponse, error) { + return &supernodeTypes.QueryPoolStateResponse{}, nil +} + +func (m *MockSupernodeModule) GetSNEligibility(ctx context.Context, validatorAddress string) (*supernodeTypes.QuerySNEligibilityResponse, error) { + return &supernodeTypes.QuerySNEligibilityResponse{Eligible: true, Reason: "mock"}, nil +} + +func (m *MockSupernodeModule) GetPayoutHistory(ctx context.Context, validatorAddress string, pagination *query.PageRequest) (*supernodeTypes.QueryPayoutHistoryResponse, error) { + return &supernodeTypes.QueryPayoutHistoryResponse{}, nil +} + // ReportMetrics mocks broadcasting a metrics report transaction. func (m *MockSupernodeMsgModule) ReportMetrics(ctx context.Context, identity string, metrics supernodeTypes.SupernodeMetrics) (*sdktx.BroadcastTxResponse, error) { return &sdktx.BroadcastTxResponse{}, nil diff --git a/sdk/adapters/lumera/adapter.go b/sdk/adapters/lumera/adapter.go index d0a23cb2..44879de7 100644 --- a/sdk/adapters/lumera/adapter.go +++ b/sdk/adapters/lumera/adapter.go @@ -405,7 +405,15 @@ func (a *Adapter) SubmitCascadeClientFailureEvidence( meta := audittypes.CascadeClientFailureEvidenceMetadata{ ReporterComponent: audittypes.CascadeClientFailureReporterComponent_CASCADE_CLIENT_FAILURE_REPORTER_COMPONENT_SDK_GO, TargetSupernodeAccounts: append([]string(nil), targetSupernodeAccounts...), - Details: details, + Details: &audittypes.CascadeClientFailureDetails{ + Operation: details["operation"], + Iteration: details["iteration"], + SupernodeEndpoint: details["supernode_endpoint"], + SupernodeAccount: details["supernode_account"], + TaskId: details["task_id"], + Error: details["error"], + ActionId: details["action_id"], + }, } bz, err := json.Marshal(meta) if err != nil { diff --git a/supernode/cmd/start.go b/supernode/cmd/start.go index be1c1c15..6e3b8e9f 100644 --- a/supernode/cmd/start.go +++ b/supernode/cmd/start.go @@ -168,6 +168,7 @@ The supernode will connect to the Lumera network and begin participating in the kr, appConfig.SupernodeConfig.KeyName, appConfig.BaseDir, + appConfig.GetP2PDataDir(), ) if err != nil { logtrace.Fatal(ctx, "Failed to initialize host reporter", logtrace.Fields{"error": err.Error()}) diff --git a/supernode/host_reporter/service.go b/supernode/host_reporter/service.go index 3dc2198d..fd786f68 100644 --- a/supernode/host_reporter/service.go +++ b/supernode/host_reporter/service.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "net" + "os" + "path/filepath" "strconv" "strings" "time" @@ -40,9 +42,10 @@ type Service struct { metrics *statussvc.MetricsCollector storagePaths []string + p2pDataDir string } -func NewService(identity string, lumeraClient lumera.Client, kr keyring.Keyring, keyName string, baseDir string) (*Service, error) { +func NewService(identity string, lumeraClient lumera.Client, kr keyring.Keyring, keyName string, baseDir string, p2pDataDir string) (*Service, error) { identity = strings.TrimSpace(identity) if identity == "" { return nil, fmt.Errorf("identity is empty") @@ -86,6 +89,7 @@ func NewService(identity string, lumeraClient lumera.Client, kr keyring.Keyring, dialTimeout: defaultDialTimeout, metrics: statussvc.NewMetricsCollector(), storagePaths: storagePaths, + p2pDataDir: strings.TrimSpace(p2pDataDir), }, nil } @@ -144,6 +148,9 @@ func (s *Service) tick(ctx context.Context) { if diskUsagePercent, ok := s.diskUsagePercent(tickCtx); ok { hostReport.DiskUsagePercent = diskUsagePercent } + if cascadeBytes, ok := s.cascadeKademliaDBBytes(tickCtx); ok { + hostReport.CascadeKademliaDbBytes = float64(cascadeBytes) + } if _, err := s.lumera.AuditMsg().SubmitEpochReport(tickCtx, epochID, hostReport, storageChallengeObservations); err != nil { logtrace.Warn(tickCtx, "epoch report submit failed", logtrace.Fields{ @@ -170,6 +177,30 @@ func (s *Service) diskUsagePercent(ctx context.Context) (float64, bool) { return infos[0].UsagePercent, true } +func (s *Service) cascadeKademliaDBBytes(_ context.Context) (uint64, bool) { + dir := strings.TrimSpace(s.p2pDataDir) + if dir == "" { + return 0, false + } + // Kademlia SQLite store uses data*.sqlite3 files (+ WAL/SHM sidecars). + matches, err := filepath.Glob(filepath.Join(dir, "data*.sqlite3*")) + if err != nil || len(matches) == 0 { + return 0, false + } + var total uint64 + for _, p := range matches { + st, err := os.Stat(p) + if err != nil || st == nil || st.IsDir() { + continue + } + total += uint64(st.Size()) + } + if total == 0 { + return 0, false + } + return total, true +} + func (s *Service) buildStorageChallengeObservations(ctx context.Context, epochID uint64, requiredOpenPorts []uint32, targets []string) []*audittypes.StorageChallengeObservation { if len(targets) == 0 { return nil diff --git a/supernode/host_reporter/service_test.go b/supernode/host_reporter/service_test.go index 93de4c3c..fe8ed21a 100644 --- a/supernode/host_reporter/service_test.go +++ b/supernode/host_reporter/service_test.go @@ -1,6 +1,11 @@ package host_reporter -import "testing" +import ( + "context" + "os" + "path/filepath" + "testing" +) func TestNormalizeProbeHost(t *testing.T) { t.Parallel() @@ -27,3 +32,39 @@ func TestNormalizeProbeHost(t *testing.T) { }) } } + +func TestCascadeKademliaDBBytes(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + mustWrite := func(name string, size int) { + p := filepath.Join(dir, name) + b := make([]byte, size) + if err := os.WriteFile(p, b, 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + mustWrite("data001.sqlite3", 100) + mustWrite("data001.sqlite3-wal", 50) + mustWrite("data001.sqlite3-shm", 25) + mustWrite("unrelated.txt", 999) + + s := &Service{p2pDataDir: dir} + got, ok := s.cascadeKademliaDBBytes(context.Background()) + if !ok { + t.Fatalf("expected ok=true") + } + if want := uint64(175); got != want { + t.Fatalf("cascadeKademliaDBBytes=%d want %d", got, want) + } +} + +func TestCascadeKademliaDBBytes_NoMatches(t *testing.T) { + t.Parallel() + s := &Service{p2pDataDir: t.TempDir()} + _, ok := s.cascadeKademliaDBBytes(context.Background()) + if ok { + t.Fatalf("expected ok=false when no sqlite db files exist") + } +} diff --git a/supernode/supernode_metrics/reachability_active_probing_test.go b/supernode/supernode_metrics/reachability_active_probing_test.go index 5f138fe9..858d189a 100644 --- a/supernode/supernode_metrics/reachability_active_probing_test.go +++ b/supernode/supernode_metrics/reachability_active_probing_test.go @@ -24,6 +24,7 @@ import ( "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/supernode_msg" "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/tx" "github.com/LumeraProtocol/supernode/v2/pkg/reachability" + "github.com/cosmos/cosmos-sdk/types/query" ) func TestBuildProbeCandidatesFilters(t *testing.T) { @@ -470,3 +471,12 @@ func (m *fakeSupernodeModule) GetParams(context.Context) (*sntypes.QueryParamsRe func (m *fakeSupernodeModule) ListSuperNodes(context.Context) (*sntypes.QueryListSuperNodesResponse, error) { return &sntypes.QueryListSuperNodesResponse{Supernodes: m.supernodes}, nil } +func (m *fakeSupernodeModule) GetPoolState(context.Context) (*sntypes.QueryPoolStateResponse, error) { + return &sntypes.QueryPoolStateResponse{}, nil +} +func (m *fakeSupernodeModule) GetSNEligibility(context.Context, string) (*sntypes.QuerySNEligibilityResponse, error) { + return &sntypes.QuerySNEligibilityResponse{Eligible: true, Reason: "mock"}, nil +} +func (m *fakeSupernodeModule) GetPayoutHistory(context.Context, string, *query.PageRequest) (*sntypes.QueryPayoutHistoryResponse, error) { + return &sntypes.QueryPayoutHistoryResponse{}, nil +} From a0bfde693c250cda8498017a7159f720f0f4997a Mon Sep 17 00:00:00 2001 From: NightCrawler Date: Tue, 14 Apr 2026 16:19:46 +0000 Subject: [PATCH 3/4] host_reporter: measure disk usage on p2p data dir mount --- supernode/host_reporter/service.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/supernode/host_reporter/service.go b/supernode/host_reporter/service.go index fd786f68..e0c1c16e 100644 --- a/supernode/host_reporter/service.go +++ b/supernode/host_reporter/service.go @@ -75,8 +75,12 @@ func NewService(identity string, lumeraClient lumera.Client, kr keyring.Keyring, } storagePaths := []string{} - if baseDir = strings.TrimSpace(baseDir); baseDir != "" { - // Match legacy disk reporting behavior: measure the volume where the supernode stores its data. + p2pDataDir = strings.TrimSpace(p2pDataDir) + if p2pDataDir != "" { + // Everlight requirement: disk usage must reflect the mount/volume where p2p data is stored. + storagePaths = []string{p2pDataDir} + } else if baseDir = strings.TrimSpace(baseDir); baseDir != "" { + // Fallback for legacy setups where p2p data dir isn't configured. storagePaths = []string{baseDir} } From 3bfe9b5ebbec568f89db9f3fb9d31e3ee2a2e476 Mon Sep 17 00:00:00 2001 From: NightCrawler Date: Wed, 15 Apr 2026 11:35:10 +0000 Subject: [PATCH 4/4] supernode: align probing roles with ACTIVE+STORAGE_FULL semantics --- sdk/adapters/lumera/types.go | 16 +- supernode/host_reporter/tick_behavior_test.go | 226 ++++++++++++++++++ supernode/supernode_metrics/active_probing.go | 4 +- .../reachability_active_probing_test.go | 50 +++- supernode/verifier/verifier.go | 4 +- supernode/verifier/verifier_test.go | 38 +++ 6 files changed, 323 insertions(+), 15 deletions(-) create mode 100644 supernode/host_reporter/tick_behavior_test.go create mode 100644 supernode/verifier/verifier_test.go diff --git a/sdk/adapters/lumera/types.go b/sdk/adapters/lumera/types.go index c560d3fc..a631b7db 100644 --- a/sdk/adapters/lumera/types.go +++ b/sdk/adapters/lumera/types.go @@ -16,12 +16,13 @@ const ( type SUPERNODE_STATE string const ( - SUPERNODE_STATE_UNSPECIFIED SUPERNODE_STATE = "SUPERNODE_STATE_UNSPECIFIED" - SUPERNODE_STATE_ACTIVE SUPERNODE_STATE = "SUPERNODE_STATE_ACTIVE" - SUPERNODE_STATE_DISABLED SUPERNODE_STATE = "SUPERNODE_STATE_DISABLED" - SUPERNODE_STATE_STOPPED SUPERNODE_STATE = "SUPERNODE_STATE_STOPPED" - SUPERNODE_STATE_PENALIZED SUPERNODE_STATE = "SUPERNODE_STATE_PENALIZED" - SUPERNODE_STATE_POSTPONED SUPERNODE_STATE = "SUPERNODE_STATE_POSTPONED" + SUPERNODE_STATE_UNSPECIFIED SUPERNODE_STATE = "SUPERNODE_STATE_UNSPECIFIED" + SUPERNODE_STATE_ACTIVE SUPERNODE_STATE = "SUPERNODE_STATE_ACTIVE" + SUPERNODE_STATE_DISABLED SUPERNODE_STATE = "SUPERNODE_STATE_DISABLED" + SUPERNODE_STATE_STOPPED SUPERNODE_STATE = "SUPERNODE_STATE_STOPPED" + SUPERNODE_STATE_PENALIZED SUPERNODE_STATE = "SUPERNODE_STATE_PENALIZED" + SUPERNODE_STATE_POSTPONED SUPERNODE_STATE = "SUPERNODE_STATE_POSTPONED" + SUPERNODE_STATE_STORAGE_FULL SUPERNODE_STATE = "SUPERNODE_STATE_STORAGE_FULL" ) // Action represents an action registered on the Lumera blockchain @@ -65,7 +66,8 @@ func ParseSupernodeState(state string) SUPERNODE_STATE { SUPERNODE_STATE_DISABLED, SUPERNODE_STATE_STOPPED, SUPERNODE_STATE_PENALIZED, - SUPERNODE_STATE_POSTPONED: + SUPERNODE_STATE_POSTPONED, + SUPERNODE_STATE_STORAGE_FULL: return SUPERNODE_STATE(state) default: return SUPERNODE_STATE_UNSPECIFIED diff --git a/supernode/host_reporter/tick_behavior_test.go b/supernode/host_reporter/tick_behavior_test.go new file mode 100644 index 00000000..27927c72 --- /dev/null +++ b/supernode/host_reporter/tick_behavior_test.go @@ -0,0 +1,226 @@ +package host_reporter + +import ( + "context" + "errors" + "testing" + "time" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + lumeraMock "github.com/LumeraProtocol/supernode/v2/pkg/lumera" + auditmsgmod "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/audit_msg" + nodemod "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/node" + supernodemod "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/supernode" + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" + "github.com/cosmos/cosmos-sdk/crypto/hd" + "github.com/cosmos/cosmos-sdk/crypto/keyring" + sdktx "github.com/cosmos/cosmos-sdk/types/tx" + "github.com/cosmos/go-bip39" + "go.uber.org/mock/gomock" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type stubAuditModule struct { + currentEpoch *audittypes.QueryCurrentEpochResponse + anchor *audittypes.QueryEpochAnchorResponse + epochReportErr error + assigned *audittypes.QueryAssignedTargetsResponse +} + +func (s *stubAuditModule) GetParams(ctx context.Context) (*audittypes.QueryParamsResponse, error) { + return &audittypes.QueryParamsResponse{}, nil +} +func (s *stubAuditModule) GetEpochAnchor(ctx context.Context, epochID uint64) (*audittypes.QueryEpochAnchorResponse, error) { + return s.anchor, nil +} +func (s *stubAuditModule) GetCurrentEpoch(ctx context.Context) (*audittypes.QueryCurrentEpochResponse, error) { + return s.currentEpoch, nil +} +func (s *stubAuditModule) GetCurrentEpochAnchor(ctx context.Context) (*audittypes.QueryCurrentEpochAnchorResponse, error) { + return &audittypes.QueryCurrentEpochAnchorResponse{}, nil +} +func (s *stubAuditModule) GetAssignedTargets(ctx context.Context, supernodeAccount string, epochID uint64) (*audittypes.QueryAssignedTargetsResponse, error) { + return s.assigned, nil +} +func (s *stubAuditModule) GetEpochReport(ctx context.Context, epochID uint64, supernodeAccount string) (*audittypes.QueryEpochReportResponse, error) { + if s.epochReportErr != nil { + return nil, s.epochReportErr + } + return &audittypes.QueryEpochReportResponse{}, nil +} + +func testKeyringAndIdentity(t *testing.T) (keyring.Keyring, string, string) { + t.Helper() + interfaceRegistry := codectypes.NewInterfaceRegistry() + cryptocodec.RegisterInterfaces(interfaceRegistry) + cdc := codec.NewProtoCodec(interfaceRegistry) + kr := keyring.NewInMemory(cdc) + + entropy, err := bip39.NewEntropy(128) + if err != nil { + t.Fatalf("entropy: %v", err) + } + mnemonic, err := bip39.NewMnemonic(entropy) + if err != nil { + t.Fatalf("mnemonic: %v", err) + } + algoList, _ := kr.SupportedAlgorithms() + signingAlgo, err := keyring.NewSigningAlgoFromString("secp256k1", algoList) + if err != nil { + t.Fatalf("signing algo: %v", err) + } + hdPath := hd.CreateHDPath(118, 0, 0).String() + rec, err := kr.NewAccount("test", mnemonic, "", hdPath, signingAlgo) + if err != nil { + t.Fatalf("new account: %v", err) + } + addr, err := rec.GetAddress() + if err != nil { + t.Fatalf("get addr: %v", err) + } + return kr, "test", addr.String() +} + +func TestTick_ProberSubmitsObservationsForAssignedTargets(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + kr, keyName, identity := testKeyringAndIdentity(t) + auditMod := &stubAuditModule{ + currentEpoch: &audittypes.QueryCurrentEpochResponse{EpochId: 7}, + anchor: &audittypes.QueryEpochAnchorResponse{Anchor: audittypes.EpochAnchor{EpochId: 7}}, + epochReportErr: status.Error(codes.NotFound, "not found"), + assigned: &audittypes.QueryAssignedTargetsResponse{ + TargetSupernodeAccounts: []string{"snA", "snB"}, + RequiredOpenPorts: []uint32{4444}, + }, + } + 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").Return(&supernodemod.SuperNodeInfo{LatestAddress: "127.0.0.1:4444"}, nil) + sn.EXPECT().GetSupernodeWithLatestAddress(gomock.Any(), "snB").Return(&supernodemod.SuperNodeInfo{LatestAddress: "127.0.0.1:4444"}, nil) + auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), uint64(7), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ uint64, _ audittypes.HostReport, obs []*audittypes.StorageChallengeObservation) (*sdktx.BroadcastTxResponse, error) { + if len(obs) != 2 { + t.Fatalf("expected 2 observations, got %d", len(obs)) + } + for _, o := range obs { + if o == nil || o.TargetSupernodeAccount == "" || len(o.PortStates) != 1 { + t.Fatalf("invalid observation: %+v", o) + } + } + return &sdktx.BroadcastTxResponse{}, nil + }, + ) + + svc, err := NewService(identity, client, kr, keyName, "", "") + if err != nil { + t.Fatalf("new service: %v", err) + } + svc.dialTimeout = 10 * time.Millisecond + svc.tick(context.Background()) +} + +func TestTick_NonProberSubmitsHostOnly(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + kr, keyName, identity := testKeyringAndIdentity(t) + auditMod := &stubAuditModule{ + currentEpoch: &audittypes.QueryCurrentEpochResponse{EpochId: 8}, + anchor: &audittypes.QueryEpochAnchorResponse{Anchor: audittypes.EpochAnchor{EpochId: 8}}, + epochReportErr: status.Error(codes.NotFound, "not found"), + assigned: &audittypes.QueryAssignedTargetsResponse{ + TargetSupernodeAccounts: nil, + RequiredOpenPorts: []uint32{4444, 4445}, + }, + } + 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) + auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), uint64(8), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ uint64, _ audittypes.HostReport, obs []*audittypes.StorageChallengeObservation) (*sdktx.BroadcastTxResponse, error) { + if len(obs) != 0 { + t.Fatalf("expected 0 observations for non-prober, got %d", len(obs)) + } + return &sdktx.BroadcastTxResponse{}, nil + }, + ) + + svc, err := NewService(identity, client, kr, keyName, "", "") + if err != nil { + t.Fatalf("new service: %v", err) + } + svc.tick(context.Background()) +} + +func TestTick_SkipsWhenEpochAlreadyReported(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + kr, keyName, identity := testKeyringAndIdentity(t) + auditMod := &stubAuditModule{ + currentEpoch: &audittypes.QueryCurrentEpochResponse{EpochId: 9}, + anchor: &audittypes.QueryEpochAnchorResponse{Anchor: audittypes.EpochAnchor{EpochId: 9}}, + epochReportErr: nil, + assigned: &audittypes.QueryAssignedTargetsResponse{}, + } + 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) + auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + svc, err := NewService(identity, client, kr, keyName, "", "") + if err != nil { + t.Fatalf("new service: %v", err) + } + svc.tick(context.Background()) +} + +func TestTick_SkipsOnEpochReportLookupError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + kr, keyName, identity := testKeyringAndIdentity(t) + auditMod := &stubAuditModule{ + currentEpoch: &audittypes.QueryCurrentEpochResponse{EpochId: 10}, + anchor: &audittypes.QueryEpochAnchorResponse{Anchor: audittypes.EpochAnchor{EpochId: 10}}, + epochReportErr: errors.New("rpc unavailable"), + assigned: &audittypes.QueryAssignedTargetsResponse{}, + } + 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) + auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + svc, err := NewService(identity, client, kr, keyName, "", "") + if err != nil { + t.Fatalf("new service: %v", err) + } + svc.tick(context.Background()) +} diff --git a/supernode/supernode_metrics/active_probing.go b/supernode/supernode_metrics/active_probing.go index 3545d979..51e2e04e 100644 --- a/supernode/supernode_metrics/active_probing.go +++ b/supernode/supernode_metrics/active_probing.go @@ -346,7 +346,7 @@ func buildProbeCandidates(supernodes []*sntypes.SuperNode) (senders []probeTarge if state == sntypes.SuperNodeStateStopped { continue } - if state != sntypes.SuperNodeStateActive && state != sntypes.SuperNodeStatePostponed { + if state != sntypes.SuperNodeStateActive && state != sntypes.SuperNodeStateStorageFull && state != sntypes.SuperNodeStatePostponed { continue } @@ -383,7 +383,7 @@ func buildProbeCandidates(supernodes []*sntypes.SuperNode) (senders []probeTarge } peersByID[peerID] = t receivers = append(receivers, t) - if state == sntypes.SuperNodeStateActive { + if state == sntypes.SuperNodeStateActive || state == sntypes.SuperNodeStateStorageFull { senders = append(senders, t) } } diff --git a/supernode/supernode_metrics/reachability_active_probing_test.go b/supernode/supernode_metrics/reachability_active_probing_test.go index 858d189a..857c8da5 100644 --- a/supernode/supernode_metrics/reachability_active_probing_test.go +++ b/supernode/supernode_metrics/reachability_active_probing_test.go @@ -60,6 +60,15 @@ func TestBuildProbeCandidatesFilters(t *testing.T) { {Height: height, Address: "203.0.113.3:4444"}, }, }, + { + SupernodeAccount: "storage_full", + States: []*sntypes.SuperNodeStateRecord{ + {Height: height, State: sntypes.SuperNodeStateStorageFull}, + }, + PrevIpAddresses: []*sntypes.IPAddressHistory{ + {Height: height, Address: "203.0.113.6:4444"}, + }, + }, { SupernodeAccount: "ipv6", States: []*sntypes.SuperNodeStateRecord{ @@ -90,11 +99,11 @@ func TestBuildProbeCandidatesFilters(t *testing.T) { } senders, receivers, peersByID := buildProbeCandidates(active) - if len(senders) != 4 { - t.Fatalf("expected 4 senders (ACTIVE only), got %d", len(senders)) + if len(senders) != 5 { + t.Fatalf("expected 5 senders (ACTIVE+STORAGE_FULL), got %d", len(senders)) } - if len(receivers) != 5 { - t.Fatalf("expected 5 receivers (ACTIVE+POSTPONED), got %d", len(receivers)) + if len(receivers) != 6 { + t.Fatalf("expected 6 receivers (ACTIVE+STORAGE_FULL+POSTPONED), got %d", len(receivers)) } if peersByID["a"].grpcPort != 4444 || peersByID["a"].p2pPort != 5555 || peersByID["a"].metricsHeight != height { t.Fatalf("unexpected peer a: %+v", peersByID["a"]) @@ -102,6 +111,9 @@ func TestBuildProbeCandidatesFilters(t *testing.T) { if peersByID["postponed"].identity == "" { t.Fatalf("expected postponed peer present") } + if peersByID["storage_full"].identity == "" { + t.Fatalf("expected storage_full peer present") + } } func TestOpenPortsClosedWhenQuorumSatisfiedAndNoEvidence(t *testing.T) { @@ -480,3 +492,33 @@ func (m *fakeSupernodeModule) GetSNEligibility(context.Context, string) (*sntype func (m *fakeSupernodeModule) GetPayoutHistory(context.Context, string, *query.PageRequest) (*sntypes.QueryPayoutHistoryResponse, error) { return &sntypes.QueryPayoutHistoryResponse{}, nil } + +func TestBuildProbeCandidates_PostponedReceiveOnly_StorageFullCanSend(t *testing.T) { + height := int64(100) + supernodes := []*sntypes.SuperNode{ + mkSNState("active", "203.0.113.10", height, height, sntypes.SuperNodeStateActive), + mkSNState("storage", "203.0.113.11", height, height, sntypes.SuperNodeStateStorageFull), + mkSNState("postponed", "203.0.113.12", height, height, sntypes.SuperNodeStatePostponed), + } + + senders, receivers, _ := buildProbeCandidates(supernodes) + + senderSet := map[string]bool{} + for _, s := range senders { + senderSet[s.identity] = true + } + receiverSet := map[string]bool{} + for _, r := range receivers { + receiverSet[r.identity] = true + } + + if !senderSet["active"] || !senderSet["storage"] { + t.Fatalf("expected ACTIVE and STORAGE_FULL in senders, got=%v", senderSet) + } + if senderSet["postponed"] { + t.Fatalf("expected POSTPONED to be receive-only, got senders=%v", senderSet) + } + if !receiverSet["active"] || !receiverSet["storage"] || !receiverSet["postponed"] { + t.Fatalf("expected ACTIVE/STORAGE_FULL/POSTPONED in receivers, got=%v", receiverSet) + } +} diff --git a/supernode/verifier/verifier.go b/supernode/verifier/verifier.go index 7367c089..8fe24836 100644 --- a/supernode/verifier/verifier.go +++ b/supernode/verifier/verifier.go @@ -131,9 +131,9 @@ func (cv *ConfigVerifier) checkSupernodeExists(ctx context.Context, result *Veri func (cv *ConfigVerifier) checkSupernodeState(result *VerificationResult, supernodeInfo *snmodule.SuperNodeInfo) { state := adapterlumera.ParseSupernodeState(supernodeInfo.CurrentState) - allowedStates := fmt.Sprintf("%s or %s", adapterlumera.SUPERNODE_STATE_ACTIVE, adapterlumera.SUPERNODE_STATE_POSTPONED) + allowedStates := fmt.Sprintf("%s or %s or %s", adapterlumera.SUPERNODE_STATE_ACTIVE, adapterlumera.SUPERNODE_STATE_STORAGE_FULL, adapterlumera.SUPERNODE_STATE_POSTPONED) - if supernodeInfo.CurrentState == "" || state == adapterlumera.SUPERNODE_STATE_ACTIVE { + if supernodeInfo.CurrentState == "" || state == adapterlumera.SUPERNODE_STATE_ACTIVE || state == adapterlumera.SUPERNODE_STATE_STORAGE_FULL { return } diff --git a/supernode/verifier/verifier_test.go b/supernode/verifier/verifier_test.go new file mode 100644 index 00000000..e09ccefe --- /dev/null +++ b/supernode/verifier/verifier_test.go @@ -0,0 +1,38 @@ +package verifier + +import ( + "testing" + + snmodule "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/supernode" +) + +func TestCheckSupernodeState_AllowsStorageFull(t *testing.T) { + cv := &ConfigVerifier{} + result := &VerificationResult{Valid: true} + + cv.checkSupernodeState(result, &snmodule.SuperNodeInfo{CurrentState: "SUPERNODE_STATE_STORAGE_FULL"}) + + if !result.Valid { + t.Fatalf("expected STORAGE_FULL to be allowed, got invalid result: %+v", result.Errors) + } + if len(result.Errors) != 0 { + t.Fatalf("expected no errors, got: %+v", result.Errors) + } +} + +func TestCheckSupernodeState_PostponedWarnsNotErrors(t *testing.T) { + cv := &ConfigVerifier{} + result := &VerificationResult{Valid: true} + + cv.checkSupernodeState(result, &snmodule.SuperNodeInfo{CurrentState: "SUPERNODE_STATE_POSTPONED"}) + + if !result.Valid { + t.Fatalf("expected POSTPONED to remain valid with warning") + } + if len(result.Errors) != 0 { + t.Fatalf("expected no errors, got: %+v", result.Errors) + } + if len(result.Warnings) == 0 { + t.Fatalf("expected warning for POSTPONED state") + } +}