Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/static/openapi.yml

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions proto/lumera/audit/v1/audit.proto
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ message HostReport {
repeated PortState inbound_port_states = 4;

uint32 failed_actions_count = 5;

// Cascade Kademlia DB size in bytes, self-reported by the SuperNode.
// Carried on HostReport purely as a metric-courier on the audit epoch report
// channel — the audit module does NOT consume this value for its own
// consensus logic (LEP-6 §12). 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.
// MUST be finite and non-negative; zero is valid (empty Kademlia store).
double cascade_kademlia_db_bytes = 6;
}

// StorageChallengeObservation is a prober's reachability observation about an assigned target.
Expand Down
10 changes: 8 additions & 2 deletions tests/systemtests/everlight_system_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,12 @@ func TestEverlightSystem_PayoutAndHistoryWhileStorageFull(t *testing.T) {

elig := cli.CustomQuery("q", "supernode", "sn-eligibility", n0.valAddr)
require.False(t, gjson.Get(elig, "eligible").Bool())
// Per CP3 rebase — supernode keeper returns "no audit epoch report found" earlier in the path.
require.Equal(t, "no audit epoch report found", gjson.Get(elig, "reason").String())
// With the audit→supernode metrics bridge live, every accepted epoch report
// writes SupernodeMetricsState.CascadeKademliaDbBytes (0 here, since the
// host_report payload above carries no cascade_kademlia_db_bytes field).
// Eligibility therefore reaches the threshold check and rejects with
// "cascade bytes below minimum threshold" instead of the pre-bridge
// "no audit epoch report found". Substantive outcome (ineligible, no
// payout) is unchanged — only the rejection reason advances by one step.
require.Equal(t, "cascade bytes below minimum threshold", gjson.Get(elig, "reason").String())
}
90 changes: 89 additions & 1 deletion x/audit/v1/keeper/msg_submit_epoch_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package keeper

import (
"context"
"math"
"strconv"

errorsmod "cosmossdk.io/errors"
sdk "github.com/cosmos/cosmos-sdk/types"

"github.com/LumeraProtocol/lumera/x/audit/v1/types"
sntypes "github.com/LumeraProtocol/lumera/x/supernode/v1/types"
)

const (
Expand Down Expand Up @@ -38,14 +40,22 @@ func (m msgServer) SubmitEpochReport(ctx context.Context, req *types.MsgSubmitEp
return nil, errorsmod.Wrapf(types.ErrInvalidEpochID, "epoch_id not accepted at height %d", sdkCtx.BlockHeight())
}

_, found, err := m.supernodeKeeper.GetSuperNodeByAccount(sdkCtx, req.Creator)
sn, found, err := m.supernodeKeeper.GetSuperNodeByAccount(sdkCtx, req.Creator)
if err != nil {
return nil, err
}
if !found {
return nil, errorsmod.Wrap(types.ErrReporterNotFound, "creator is not a registered supernode")
}

// Validate self-reported HostReport host-metric fields. LEP-6 §12 left this
// field as a metric-courier (no audit-side consensus meaning); the audit
// handler still rejects malformed values defensively before bridging them
// into x/supernode metrics state.
if err := validateHostMetricFields(req.HostReport); err != nil {
return nil, err
}

anchor, found := m.GetEpochAnchor(sdkCtx, req.EpochId)
if !found {
return nil, errorsmod.Wrapf(types.ErrInvalidEpochID, "epoch anchor not found for epoch_id %d", req.EpochId)
Expand Down Expand Up @@ -157,6 +167,16 @@ func (m msgServer) SubmitEpochReport(ctx context.Context, req *types.MsgSubmitEp
m.SetReportIndex(sdkCtx, req.EpochId, reporterAccount)
m.SetHostReportIndex(sdkCtx, req.EpochId, reporterAccount)

// Bridge cascade_kademlia_db_bytes from the audit HostReport into the
// x/supernode SupernodeMetricsState. Post LEP-6 §12, this is the SOLE
// writer of that field for Everlight payout / eligibility reads via
// getLatestCascadeBytesFromAudit. The bridge is read-modify-write so
// fields owned by the (now operationally dead) legacy
// MsgReportSupernodeMetrics handler are preserved if previously present.
if err := m.bridgeCascadeBytesToSupernodeMetrics(sdkCtx, sn, req.HostReport.CascadeKademliaDbBytes); err != nil {
return nil, err
}

seenSupernodes := make(map[string]struct{}, len(req.StorageChallengeObservations))
for _, obs := range req.StorageChallengeObservations {
if obs == nil {
Expand Down Expand Up @@ -217,3 +237,71 @@ func (k Keeper) applyIncompleteReportPenalty(ctx sdk.Context, epochID uint64, re
))
return nil
}

// validateHostMetricFields rejects malformed host-metric values that the audit
// handler accepts purely as metric-couriers (no audit-side consensus meaning).
// This is the single enforcement point for HostReport host-metric invariants
// (LEP-6 §12 — see proto/lumera/audit/v1/audit.proto::HostReport).
func validateHostMetricFields(h types.HostReport) error {
if math.IsNaN(h.CascadeKademliaDbBytes) || math.IsInf(h.CascadeKademliaDbBytes, 0) {
return errorsmod.Wrap(types.ErrInvalidHostMetric, "cascade_kademlia_db_bytes must be a finite number")
}
if h.CascadeKademliaDbBytes < 0 {
return errorsmod.Wrapf(types.ErrInvalidHostMetric, "cascade_kademlia_db_bytes must be >= 0, got %v", h.CascadeKademliaDbBytes)
}
return nil
}

// bridgeCascadeBytesToSupernodeMetrics writes the cascade_kademlia_db_bytes
// metric reported on the audit HostReport into x/supernode SupernodeMetricsState.
// Read-modify-write semantics: any other Metrics fields previously persisted
// (e.g. by the legacy MsgReportSupernodeMetrics handler) are preserved.
// Height is updated to the current block; ReportCount is incremented.
//
// Post LEP-6 §12, this is the SOLE writer of SupernodeMetricsState.Metrics.
// CascadeKademliaDbBytes used by Everlight payout / eligibility reads via
// getLatestCascadeBytesFromAudit.
//
// The bridge is defensively a no-op (with an event for observability) if the
// SuperNode record has an empty/invalid ValidatorAddress. That is a pre-existing
// x/supernode invariant violation (chain registration enforces non-empty),
// outside the audit module's scope; the bridge surfaces it via an event but
// does not fail the epoch report on someone else's data corruption.
func (k Keeper) bridgeCascadeBytesToSupernodeMetrics(ctx sdk.Context, sn sntypes.SuperNode, cascadeBytes float64) error {
if sn.ValidatorAddress == "" {
ctx.EventManager().EmitEvent(sdk.NewEvent(
"audit_cascade_bytes_bridge_skipped",
sdk.NewAttribute(sdk.AttributeKeyModule, types.ModuleName),
sdk.NewAttribute("supernode_account", sn.SupernodeAccount),
sdk.NewAttribute("reason", "empty_validator_address"),
))
return nil
}
valAddr, err := sdk.ValAddressFromBech32(sn.ValidatorAddress)
if err != nil {
ctx.EventManager().EmitEvent(sdk.NewEvent(
"audit_cascade_bytes_bridge_skipped",
sdk.NewAttribute(sdk.AttributeKeyModule, types.ModuleName),
sdk.NewAttribute("supernode_account", sn.SupernodeAccount),
sdk.NewAttribute("validator_address", sn.ValidatorAddress),
sdk.NewAttribute("reason", "invalid_validator_address"),
sdk.NewAttribute("error", err.Error()),
))
return nil
}

state, ok := k.supernodeKeeper.GetMetricsState(ctx, valAddr)
if !ok {
state = sntypes.SupernodeMetricsState{
ValidatorAddress: sn.ValidatorAddress,
}
}
if state.Metrics == nil {
state.Metrics = &sntypes.SupernodeMetrics{}
}
state.Metrics.CascadeKademliaDbBytes = cascadeBytes
state.Height = ctx.BlockHeight()
state.ReportCount++

return k.supernodeKeeper.SetMetricsState(ctx, state)
}
Loading
Loading