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
825 changes: 693 additions & 132 deletions gen/supernode/storage_challenge.pb.go

Large diffs are not rendered by default.

40 changes: 39 additions & 1 deletion gen/supernode/storage_challenge_grpc.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions pkg/lumera/modules/action/action_mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions pkg/lumera/modules/action/impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"

"github.com/LumeraProtocol/lumera/x/action/v1/types"
"github.com/cosmos/cosmos-sdk/types/query"
"google.golang.org/grpc"
)

Expand Down Expand Up @@ -57,3 +58,30 @@ func (m *module) GetParams(ctx context.Context) (*types.QueryParamsResponse, err

return resp, nil
}

// ListActionsBySuperNode lists actions assigned to a specific supernode.
func (m *module) ListActionsBySuperNode(ctx context.Context, superNodeAddress string) (*types.QueryListActionsBySuperNodeResponse, error) {
var all []*types.Action
var nextKey []byte
for {
resp, err := m.client.ListActionsBySuperNode(ctx, &types.QueryListActionsBySuperNodeRequest{
SuperNodeAddress: superNodeAddress,
Pagination: &query.PageRequest{
Key: nextKey,
Limit: 100,
},
})
if err != nil {
return nil, err
}
if resp == nil {
return &types.QueryListActionsBySuperNodeResponse{Actions: all}, nil
}
all = append(all, resp.Actions...)
if resp.Pagination == nil || len(resp.Pagination.NextKey) == 0 {
resp.Actions = all
return resp, nil
}
nextKey = resp.Pagination.NextKey
}
}
1 change: 1 addition & 0 deletions pkg/lumera/modules/action/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type Module interface {
GetAction(ctx context.Context, actionID string) (*types.QueryGetActionResponse, error)
GetActionFee(ctx context.Context, dataSize string) (*types.QueryGetActionFeeResponse, error)
GetParams(ctx context.Context) (*types.QueryParamsResponse, error)
ListActionsBySuperNode(ctx context.Context, superNodeAddress string) (*types.QueryListActionsBySuperNodeResponse, error)
}

// NewModule creates a new Action module client
Expand Down
71 changes: 71 additions & 0 deletions pkg/storagechallenge/deterministic/lep6_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -665,3 +665,74 @@ func TestSortStrings_StableForPairs(t *testing.T) {
t.Fatalf("stable sort mismatch: %v != %v", xs, want)
}
}

// TestChainDefaults_BoundToSupernodeConstants is a chain-binding cross-validation
// guard: the supernode's deterministic primitives are a parallel implementation
// of chain logic, and any drift between supernode constants and chain defaults
// breaks consensus equivalence silently. This test imports the chain types
// package (already in go.mod via PR1) and asserts the supernode's hardcoded
// constants and the values consumed by SelectLEP6Targets/ClassifyTicketBucket
// match chain DefaultParams() byte-for-byte.
//
// Why this test belongs here (not in PR6 e2e): chain defaults are pure values
// reachable without an sdk.Context. A unit-level binding catches drift the
// instant chain bumps a default, before any integration env is even spun up.
//
// If chain ever renames or removes one of these symbols, this test will fail
// to compile — which is the desired loud-failure mode.
func TestChainDefaults_BoundToSupernodeConstants(t *testing.T) {
chain := audittypes.DefaultParams().WithDefaults()

// 1. Challenge target divisor — drives SelectLEP6Targets count.
if got, want := uint32(LEP6ChallengeTargetDivisor), audittypes.DefaultStorageTruthChallengeTargetDivisor; got != want {
t.Fatalf("LEP6ChallengeTargetDivisor drift: supernode=%d chain=%d", got, want)
}
if got, want := chain.StorageTruthChallengeTargetDivisor, audittypes.DefaultStorageTruthChallengeTargetDivisor; got != want {
t.Fatalf("DefaultParams().StorageTruthChallengeTargetDivisor drift: %d vs %d", got, want)
}

// 2. Recent-bucket window — drives ClassifyTicketBucket RECENT boundary.
if chain.StorageTruthRecentBucketMaxBlocks != audittypes.DefaultStorageTruthRecentBucketMaxBlocks {
t.Fatalf("DefaultStorageTruthRecentBucketMaxBlocks drift: params=%d const=%d",
chain.StorageTruthRecentBucketMaxBlocks, audittypes.DefaultStorageTruthRecentBucketMaxBlocks)
}

// 3. Old-bucket window — drives ClassifyTicketBucket OLD boundary.
if chain.StorageTruthOldBucketMinBlocks != audittypes.DefaultStorageTruthOldBucketMinBlocks {
t.Fatalf("DefaultStorageTruthOldBucketMinBlocks drift: params=%d const=%d",
chain.StorageTruthOldBucketMinBlocks, audittypes.DefaultStorageTruthOldBucketMinBlocks)
}

// 4. Old > Recent invariant — chain's bucket classification depends on
// OLD floor being strictly greater than RECENT ceiling. If governance
// ever flips this, supernode's ClassifyTicketBucket would silently
// misclassify all in-between heights.
if chain.StorageTruthOldBucketMinBlocks <= chain.StorageTruthRecentBucketMaxBlocks {
t.Fatalf("OLD floor must exceed RECENT ceiling: old=%d recent=%d",
chain.StorageTruthOldBucketMinBlocks, chain.StorageTruthRecentBucketMaxBlocks)
}

// 5. End-to-end: drive SelectLEP6Targets with chain-sourced divisor on the
// chain test's exact fixture and confirm the same 2-target outcome the
// chain test asserts. Locks supernode→chain agreement to chain's own
// test vector, not just a self-generated one.
active := []string{"sn-a", "sn-b", "sn-c", "sn-d", "sn-e", "sn-f"}
tgt := SelectLEP6Targets(active, chainSeed, chain.StorageTruthChallengeTargetDivisor)
if len(tgt) != 2 {
t.Fatalf("chain-defaults end-to-end: want 2 targets per chain test, got %d (%v)", len(tgt), tgt)
}

// 6. ClassifyTicketBucket sanity at chain-default boundaries: an action
// anchored exactly RECENT_MAX blocks behind current is RECENT; anchored
// OLD_MIN behind is OLD. Crosses both windows; locks bucket logic to
// chain defaults.
const currentH int64 = 1_000_000
recentAnchor := currentH - int64(chain.StorageTruthRecentBucketMaxBlocks) // RECENT inclusive at boundary
oldAnchor := currentH - int64(chain.StorageTruthOldBucketMinBlocks) // OLD inclusive at boundary
if got := ClassifyTicketBucket(currentH, recentAnchor, chain.StorageTruthRecentBucketMaxBlocks, chain.StorageTruthOldBucketMinBlocks); got != audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT {
t.Fatalf("RECENT boundary classification drift: got %v at delta=%d", got, currentH-recentAnchor)
}
if got := ClassifyTicketBucket(currentH, oldAnchor, chain.StorageTruthRecentBucketMaxBlocks, chain.StorageTruthOldBucketMinBlocks); got != audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD {
t.Fatalf("OLD boundary classification drift: got %v at delta=%d", got, currentH-oldAnchor)
}
}
159 changes: 159 additions & 0 deletions pkg/storagechallenge/lep6_resolution.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Package storagechallenge contains the supernode-side off-chain glue for the
// LEP-6 compound storage challenge runtime. The deterministic primitives that
// must agree byte-for-byte across reporters live in
// pkg/storagechallenge/deterministic; this file exposes the integration helpers
// that depend on cascade metadata and chain-side caps.
package storagechallenge

import (
"errors"
"fmt"
"math"

actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types"
audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types"
"github.com/LumeraProtocol/supernode/v2/pkg/cascadekit"
)

// MaxStorageProofResultsPerReport mirrors the chain-side cap that the audit
// keeper enforces in DeliverTx for MsgSubmitEpochReport: an epoch report
// carrying more than this many StorageProofResults is rejected wholesale.
//
// Source of truth: lumera/x/audit/v1/types/keys.go (lines 11-13) at the
// pinned chain commit. The supernode result buffer must self-throttle to this
// cap before handing results to the host reporter — see
// supernode/storage_challenge/result_buffer.go.
const MaxStorageProofResultsPerReport = 16

// ErrUnspecifiedArtifactClass is returned when a caller passes the zero/UNSPECIFIED
// StorageProofArtifactClass to a resolver that requires a concrete class.
var ErrUnspecifiedArtifactClass = errors.New("storagechallenge: artifact class is UNSPECIFIED")

// ResolveArtifactCount returns the canonical artifact count for (meta, class)
// using only the cascade metadata that finalization committed on-chain. It
// replaces a chain GetTicketArtifactCount RPC that does not exist (LEP-6 v2
// plan §9, Resolved Decision 8).
//
// Semantics:
// - INDEX -> uint32(meta.RqIdsIc)
// - SYMBOL -> uint32(len(meta.RqIdsIds))
// - UNSPECIFIED -> error
//
// If both counts are zero (legacy / malformed ticket), this returns (0, nil)
// because the chain accepts that case via its TicketArtifactCountState fallback
// path (x/audit/v1/keeper/msg_submit_epoch_report_storage_proofs.go). Callers
// decide whether to skip such a ticket.
func ResolveArtifactCount(meta *actiontypes.CascadeMetadata, class audittypes.StorageProofArtifactClass) (uint32, error) {
if meta == nil {
return 0, errors.New("storagechallenge: nil cascade metadata")
}
switch class {
case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX:
return uint32(meta.RqIdsIc), nil
case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL:
return uint32(len(meta.RqIdsIds)), nil
case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED:
return 0, ErrUnspecifiedArtifactClass
default:
return 0, fmt.Errorf("storagechallenge: unknown artifact class %v", class)
}
}

// ResolveArtifactKey returns the deterministic artifact key (content-addressed
// identifier) for (meta, class, ordinal).
//
// - SYMBOL: meta.RqIdsIds[ordinal] (bounds-checked).
// - INDEX: derived via cascadekit.GenerateIndexIDs(meta.Signatures, RqIdsIc,
// RqIdsMax) — the same derivation the supernode cascade module uses to
// materialise INDEX files (supernode/cascade/helper.go,
// supernode/cascade/reseed.go). Per LEP-6 v2 plan §9 Resolved Decision 2.
//
// Returns an error on UNSPECIFIED class, ordinal out of range, or empty
// metadata for the requested class.
func ResolveArtifactKey(meta *actiontypes.CascadeMetadata, class audittypes.StorageProofArtifactClass, ordinal uint32) (string, error) {
if meta == nil {
return "", errors.New("storagechallenge: nil cascade metadata")
}
switch class {
case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL:
if int(ordinal) >= len(meta.RqIdsIds) {
return "", fmt.Errorf("storagechallenge: SYMBOL ordinal %d out of range (have %d ids)", ordinal, len(meta.RqIdsIds))
}
return meta.RqIdsIds[ordinal], nil
case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX:
if meta.Signatures == "" {
return "", errors.New("storagechallenge: INDEX key requested but cascade metadata has empty signatures")
}
if meta.RqIdsMax == 0 {
return "", errors.New("storagechallenge: INDEX key requested but RqIdsMax is zero")
}
ids, err := cascadekit.GenerateIndexIDs(meta.Signatures, uint32(meta.RqIdsIc), uint32(meta.RqIdsMax))
if err != nil {
return "", fmt.Errorf("storagechallenge: derive INDEX ids: %w", err)
}
if int(ordinal) >= len(ids) {
return "", fmt.Errorf("storagechallenge: INDEX ordinal %d out of range (derived %d ids)", ordinal, len(ids))
}
return ids[ordinal], nil
case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED:
return "", ErrUnspecifiedArtifactClass
default:
return "", fmt.Errorf("storagechallenge: unknown artifact class %v", class)
}
}

// ResolveArtifactSize returns the exact byte size used to derive LEP-6
// multi-range offsets for a selected artifact.
//
// SYMBOL artifacts are RaptorQ symbols. The exact symbol size is derived from
// the finalized Action.FileSizeKbs and meta.RqIdsMax:
//
// symbolSize = ceil(fileSizeKbs*1024 / meta.RqIdsMax)
//
// INDEX artifacts are generated deterministically from meta.Signatures,
// meta.RqIdsIc, and meta.RqIdsMax; their exact compressed byte length is the
// length of the selected generated index file.
func ResolveArtifactSize(act *actiontypes.Action, meta *actiontypes.CascadeMetadata, class audittypes.StorageProofArtifactClass, ordinal uint32) (uint64, error) {
if act == nil {
return 0, errors.New("storagechallenge: nil action")
}
if meta == nil {
return 0, errors.New("storagechallenge: nil cascade metadata")
}
switch class {
case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL:
if act.FileSizeKbs <= 0 {
return 0, fmt.Errorf("storagechallenge: action fileSizeKbs must be > 0 for SYMBOL artifact size (got %d)", act.FileSizeKbs)
}
if meta.RqIdsMax <= 0 {
return 0, errors.New("storagechallenge: RqIdsMax must be > 0 for SYMBOL artifact size")
}
if int(ordinal) >= len(meta.RqIdsIds) {
return 0, fmt.Errorf("storagechallenge: SYMBOL ordinal %d out of range (have %d ids)", ordinal, len(meta.RqIdsIds))
}
fileBytes := uint64(act.FileSizeKbs) * 1024
if fileBytes > math.MaxUint64-uint64(meta.RqIdsMax)+1 {
return 0, errors.New("storagechallenge: SYMBOL artifact size overflow")
}
return (fileBytes + uint64(meta.RqIdsMax) - 1) / uint64(meta.RqIdsMax), nil
case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX:
if meta.Signatures == "" {
return 0, errors.New("storagechallenge: INDEX size requested but cascade metadata has empty signatures")
}
if meta.RqIdsMax == 0 {
return 0, errors.New("storagechallenge: INDEX size requested but RqIdsMax is zero")
}
_, files, err := cascadekit.GenerateIndexFiles(meta.Signatures, uint32(meta.RqIdsIc), uint32(meta.RqIdsMax))
if err != nil {
return 0, fmt.Errorf("storagechallenge: derive INDEX files: %w", err)
}
if int(ordinal) >= len(files) {
return 0, fmt.Errorf("storagechallenge: INDEX ordinal %d out of range (derived %d files)", ordinal, len(files))
}
return uint64(len(files[ordinal])), nil
case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED:
return 0, ErrUnspecifiedArtifactClass
default:
return 0, fmt.Errorf("storagechallenge: unknown artifact class %v", class)
}
}
Loading