Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions supernode/host_reporter/cascade_bytes_reporting_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package host_reporter

import (
"context"
"os"
"path/filepath"
"testing"

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"
sdktx "github.com/cosmos/cosmos-sdk/types/tx"
"go.uber.org/mock/gomock"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// This file guards OPEN-4.
//
// Background: LEP-6 PR #286 deleted the single line that copied the Kademlia
// byte count onto the outgoing HostReport, on the reading that the audit module
// does not consume the value. The value is in fact a metric-COURIER: the audit
// handler bridges it into x/supernode SupernodeMetricsState, which is the only
// source Everlight consults for payout weight and eligibility.
//
// Because the chain-side bridge assigns unconditionally (no zero-guard), a
// daemon that omits the field actively ZEROES a good stored value every epoch.
// On a live devnet a SuperNode holding 611,842 bytes was zeroed within one
// epoch, and at the mainnet floor of 1 GiB no such node can ever earn a payout.
//
// The pre-existing unit tests for cascadeKademliaDBBytes only proved the helper
// computes a number. Nothing asserted the number reached the wire, which is
// exactly why the regression shipped. These tests assert the wire contract.

// writeKademliaStore creates SQLite-shaped files totalling wantBytes and returns
// the directory, mirroring the real p2p data layout (data*.sqlite3 + sidecars).
func writeKademliaStore(t *testing.T, files map[string]int) string {
t.Helper()

dir := t.TempDir()
for name, size := range files {
payload := make([]byte, size)
if err := os.WriteFile(filepath.Join(dir, name), payload, 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
return dir
}

// captureHostReport runs one tick and returns the HostReport actually handed to
// SubmitEpochReport. Asserting on the submitted value (rather than on an
// internal helper) is the whole point: it is the only thing the chain sees.
func captureHostReport(t *testing.T, epochID uint64, p2pDataDir string) audittypes.HostReport {
t.Helper()

ctrl := gomock.NewController(t)
defer ctrl.Finish()

kr, keyName, identity := testKeyringAndIdentity(t)
auditMod := &stubAuditModule{
currentEpoch: &audittypes.QueryCurrentEpochResponse{EpochId: epochID},
anchor: &audittypes.QueryEpochAnchorResponse{Anchor: audittypes.EpochAnchor{EpochId: epochID}},
epochReportErr: status.Error(codes.NotFound, "not found"),
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)

var captured audittypes.HostReport
var submitted bool
auditMsg.EXPECT().SubmitEpochReport(gomock.Any(), epochID, gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, _ uint64, hr audittypes.HostReport, _ []*audittypes.StorageChallengeObservation, _ []*audittypes.StorageProofResult) (*sdktx.BroadcastTxResponse, error) {
captured = hr
submitted = true
return &sdktx.BroadcastTxResponse{}, nil
},
)

svc, err := NewService(identity, client, kr, keyName, "", p2pDataDir)
if err != nil {
t.Fatalf("new service: %v", err)
}
svc.tick(context.Background())

if !submitted {
t.Fatal("tick did not submit an epoch report")
}
return captured
}

// TestTick_SubmitsCascadeKademliaBytesOnHostReport is the primary regression
// guard: a SuperNode with real Kademlia data must report those bytes on the
// wire. If someone deletes the assignment again, this fails.
func TestTick_SubmitsCascadeKademliaBytesOnHostReport(t *testing.T) {
const (
mainDB = 400_000
walFile = 180_000
shmFile = 31_842
)
wantBytes := float64(mainDB + walFile + shmFile)

dir := writeKademliaStore(t, map[string]int{
"data001.sqlite3": mainDB,
"data001.sqlite3-wal": walFile,
"data001.sqlite3-shm": shmFile,
})

got := captureHostReport(t, 21, dir)

if got.CascadeKademliaDbBytes != wantBytes {
t.Fatalf("HostReport.CascadeKademliaDbBytes = %v, want %v.\n"+
"The chain bridges this field into SupernodeMetricsState, which is the "+
"sole input to Everlight payout weight. Reporting the wrong value (or "+
"omitting it) silently zeroes the SuperNode's earnings.",
got.CascadeKademliaDbBytes, wantBytes)
}
}

// TestTick_CascadeKademliaBytesIsNonZeroWhenStoreHasData states the invariant in
// the form that actually matters economically, independent of the exact byte
// total: a node holding data must never report zero.
//
// This is the assertion that fails on the shipped v2.6.3 daemon.
func TestTick_CascadeKademliaBytesIsNonZeroWhenStoreHasData(t *testing.T) {
dir := writeKademliaStore(t, map[string]int{
"data001.sqlite3": 611_842,
})

got := captureHostReport(t, 22, dir)

if got.CascadeKademliaDbBytes <= 0 {
t.Fatalf("HostReport.CascadeKademliaDbBytes = %v with a non-empty Kademlia "+
"store; a SuperNode doing real Cascade work must never report zero. "+
"The chain overwrites stored metrics unconditionally, so a zero here "+
"destroys accrued Everlight weight every epoch.",
got.CascadeKademliaDbBytes)
}
}

// TestTick_CascadeKademliaBytesZeroWhenStoreEmpty pins the honest-zero case, so
// the fix above cannot be "satisfied" by hardcoding a constant. An empty store
// must report 0, not a fabricated value.
func TestTick_CascadeKademliaBytesZeroWhenStoreEmpty(t *testing.T) {
got := captureHostReport(t, 23, t.TempDir())

if got.CascadeKademliaDbBytes != 0 {
t.Fatalf("HostReport.CascadeKademliaDbBytes = %v for an empty Kademlia store, want 0",
got.CascadeKademliaDbBytes)
}
}

// TestTick_CascadeKademliaBytesTracksStoreGrowth proves the reported value is
// actually derived from the store rather than any fixed number: growing the
// store must grow the reported bytes.
//
// Without this, a mutation that reports a constant would survive the tests
// above.
func TestTick_CascadeKademliaBytesTracksStoreGrowth(t *testing.T) {
small := captureHostReport(t, 24, writeKademliaStore(t, map[string]int{
"data001.sqlite3": 50_000,
}))
large := captureHostReport(t, 25, writeKademliaStore(t, map[string]int{
"data001.sqlite3": 900_000,
}))

if !(large.CascadeKademliaDbBytes > small.CascadeKademliaDbBytes) {
t.Fatalf("reported bytes must track store size: small=%v large=%v; "+
"a constant or stale value would satisfy the presence checks but is wrong",
small.CascadeKademliaDbBytes, large.CascadeKademliaDbBytes)
}
}

// TestTick_CascadeKademliaBytesOmittedWhenNoDataDir documents the deliberate
// exception: a daemon configured without a p2p data directory has nothing to
// measure and reports 0 rather than guessing.
func TestTick_CascadeKademliaBytesOmittedWhenNoDataDir(t *testing.T) {
got := captureHostReport(t, 26, "")

if got.CascadeKademliaDbBytes != 0 {
t.Fatalf("CascadeKademliaDbBytes = %v with no p2p data dir configured, want 0",
got.CascadeKademliaDbBytes)
}
}
25 changes: 22 additions & 3 deletions supernode/host_reporter/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,28 @@ func (s *Service) tick(ctx context.Context) {
})
}
}
// Final Lumera LEP-6 HostReport no longer carries Cascade Kademlia DB byte counters;
// keep disk usage as the host-side enforcement metric and leave the local helper intact
// for existing diagnostics/tests.
// Cascade Kademlia DB bytes MUST be populated on every epoch report.
//
// DO NOT REMOVE THIS. It was dropped in LEP-6 PR #286 on the reading that
// "the audit module does not consume this value for its own consensus
// logic". That reading is incorrect: the field is a metric-COURIER, not an
// audit input. The chain proto (lumera/audit/v1/audit.proto, HostReport
// field 6) states that on successful epoch-report acceptance the audit
// handler bridges this value into x/supernode SupernodeMetricsState, "which
// is the sole source consulted by Everlight payout / eligibility".
//
// The chain-side bridge assigns unconditionally and has no zero-guard, so a
// daemon that omits this field does not merely fail to update it -- it
// actively OVERWRITES the stored value with 0 on every epoch. Observed on a
// live devnet: a SuperNode holding 611,842 real bytes was zeroed within one
// epoch, and at the mainnet default min_cascade_bytes_for_payment of 1 GiB
// no such node can ever qualify for a payout.
//
// Reporting 0 when the store is genuinely empty is correct and expected;
// silently omitting the field is not.
if cascadeBytes, ok := s.cascadeKademliaDBBytes(tickCtx); ok {
hostReport.CascadeKademliaDbBytes = float64(cascadeBytes)
}

if _, err := s.lumera.AuditMsg().SubmitEpochReport(tickCtx, epochID, hostReport, storageChallengeObservations, storageProofResults); err != nil {
// LEP-6 PR286 review F2: CollectResults destructively drained the
Expand Down
Loading