From af43dd82cc33233285d904fa7326b0f5d0fd5939 Mon Sep 17 00:00:00 2001 From: j-rafique Date: Mon, 4 May 2026 11:50:37 +0000 Subject: [PATCH] feat(storagechallenge): add LEP-6 deterministic primitives (PR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces pkg/storagechallenge/deterministic/lep6.go, the off-chain computation library shared by the storage_challenge runtime, recheck service, and self-healing dispatcher. Every function is pure (no I/O, no clock, no goroutines) so independent reporters challenging the same (target, ticket) pair produce byte-identical StorageProofResult fields. Functions land in two categories: CHAIN-MIRRORED (must match lumera/x/audit/v1/keeper/audit_peer_assignment.go byte-for-byte; the chain re-runs them to validate MsgSubmitEpochReport): - SelectLEP6Targets — 1/3 deterministic target subset (SHA-256(seed||0x00||account||0x00||"challenge_target"), targetCount = ceil(N/divisor) clamped to [1, N]) - PairChallengerToTarget / AssignChallengerTargets — challenger->target pairing (label "pair"), with no-self and lex tie-break SUPERNODE-CANONICAL (chain stores outputs as opaque strings; this file defines the canonical encoding all reporters must use to stay in lockstep): - ClassifyTicketBucket — RECENT/OLD bucket classification using Action.BlockHeight (Action.UpdatedHeight does not exist; see docs/plans/LEP6_SUPERNODE_IMPLEMENTATION_PLAN.md "Resolved Decision 3") - SelectTicketForBucket — deterministic per-(target,bucket) ticket pick with excluded-set support for active heal ops - SelectArtifactClass — LEP-6 §10 weighted roll (20% INDEX / 80% SYMBOL) with deterministic fallback when a class has no artifacts - SelectArtifactOrdinal — uniform ordinal mod artifactCount - ComputeMultiRangeOffsets — k=4 range offsets in [0, size-rangeLen) - ComputeCompoundChallengeHash — BLAKE3 over concat of slices in offset order (lukechampine.com/blake3 to match the chain's library) - DerivationInputHash — canonical hex of derivation inputs - TranscriptHash — full canonical transcript identifier with sorted observer ids; struct-input form prevents field-order mistakes Domain separators ("challenge_target", "pair", "ticket_rank", "artifact_class", "artifact_ordinal", "range_offset", "derivation_input", "transcript") and enum string forms ("INDEX"/ "SYMBOL", "RECENT"/"OLD"/"PROBATION"/"RECHECK") are package constants; freezing them prevents accidental drift between callers and tests. Any change is a protocol-level break that requires versioning. Tests: - TestStorageTruthAssignmentHash_KnownVector locks the byte-level SHA-256 composition against an independent computation, guaranteeing the chain-mirrored helper has not drifted. - TestSelectLEP6Targets_OneThirdCoverage_AssignmentMatchesChain uses the chain's own audit_peer_assignment_test.go fixture (seed="01234567890123456789012345678901", active={sn-a..sn-f}, divisor=3) — output {sn-f, sn-e}. - TestAssignChallengerTargets_KnownAssignment locks the full pairing {sn-a -> sn-f, sn-b -> sn-e}. - TestSelectArtifactClass_WeightedDistribution validates ~20% INDEX over 5000 draws (±2% tolerance). - Determinism, sensitivity, error-path, and out-of-bounds tests for every primitive. Verified: `go test ./pkg/storagechallenge/deterministic/...` passes; the existing deterministic_test.go pre-LEP-6 tests continue to pass. --- pkg/storagechallenge/deterministic/lep6.go | 724 ++++++++++++++++++ .../deterministic/lep6_test.go | 667 ++++++++++++++++ 2 files changed, 1391 insertions(+) create mode 100644 pkg/storagechallenge/deterministic/lep6.go create mode 100644 pkg/storagechallenge/deterministic/lep6_test.go diff --git a/pkg/storagechallenge/deterministic/lep6.go b/pkg/storagechallenge/deterministic/lep6.go new file mode 100644 index 00000000..1a456259 --- /dev/null +++ b/pkg/storagechallenge/deterministic/lep6.go @@ -0,0 +1,724 @@ +// LEP-6 deterministic primitives. +// +// This file implements the off-chain computation library that the supernode's +// storage_challenge runtime, recheck service, and self-healing dispatcher all +// share. Every function in this file is pure: same inputs always yield the same +// output, no I/O, no goroutines, no clock. +// +// # Why "deterministic" matters +// +// LEP-6 distributes one compound storage challenge per epoch to a deterministic +// 1/3 subset of active supernodes. Multiple independent reporters (probers) +// must agree byte-for-byte on: +// +// - which supernodes are challenged this epoch (target set), +// - which (challenger, target) pair an individual reporter is assigned to, +// - which ticket is selected per bucket, +// - which artifact (class, ordinal, key) is challenged, +// - which byte ranges are sampled, +// - the resulting transcript identifiers. +// +// If any one of those steps diverges between two supernodes, their +// StorageProofResults will not match and the chain will treat them as +// contradictions — penalising both. This package is therefore the single +// canonical implementation that every supernode must run. +// +// # Chain-mirrored vs supernode-canonical primitives +// +// Two primitives MUST mirror the chain byte-for-byte because the chain itself +// runs them to compute the assignment for `MsgSubmitEpochReport` validation: +// +// - SelectLEP6Targets — mirrors lumera/x/audit/v1/keeper/audit_peer_assignment.go +// (rankStorageTruthAccounts, label "challenge_target") +// - PairChallengerToTarget — mirrors the inline pair-ranking loop in the +// same file (label "pair") +// +// Both use SHA-256 with the byte composition documented on +// storageTruthAssignmentHash below — exactly matching +// lumera/x/audit/v1/keeper/audit_peer_assignment.go:232. +// +// All other primitives (ticket / class / ordinal selection, multi-range +// offsets, compound hash, derivation input hash, transcript hash) are NOT +// computed on the chain. The chain stores their outputs as opaque strings and +// only validates structural fields (non-empty, ordinal < count, class ∈ +// {INDEX, SYMBOL}, etc.). The supernode is therefore the canonical source for +// these encodings. To keep all reporters in lockstep, every supernode must use +// the byte schema defined here. Changes to these schemas are protocol-level +// changes that require coordination across the network — do not adjust them +// without bumping a versioned domain separator. +// +// # Reference test vectors +// +// - audit_peer_assignment_test.go::TestStorageTruthAssignmentUsesOneThirdCoverage +// uses seed=[]byte("01234567890123456789012345678901") with active set +// {sn-a..sn-f} and divisor=3, expecting targetCount=2. Reproduced as +// TestSelectLEP6Targets_OneThirdCoverage_AssignmentMatchesChain. +package deterministic + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "math" + "sort" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "lukechampine.com/blake3" +) + +// LEP6 default constants. +// +// Mirrors lumera/x/audit/v1/types/params.go defaults; duplicated here so the +// supernode can compute primitives without round-tripping to the chain when +// the operator has not overridden the relevant params. +const ( + // LEP6CompoundRangesPerArtifact is k in LEP-6 §11. + LEP6CompoundRangesPerArtifact = 4 + // LEP6CompoundRangeLenBytes is range_len in LEP-6 §11. + LEP6CompoundRangeLenBytes = 256 + // LEP6ChallengeTargetDivisor selects 1/3 of active supernodes per epoch. + LEP6ChallengeTargetDivisor = 3 + // LEP6ArtifactClassRollModulus is the divisor for the §10 class roll + // (0..1 -> INDEX, 2..9 -> SYMBOL). + LEP6ArtifactClassRollModulus = 10 + // LEP6ArtifactClassIndexCutoff is exclusive upper bound for INDEX bucket + // (roll < cutoff -> INDEX). + LEP6ArtifactClassIndexCutoff = 2 +) + +// Domain separator labels used across LEP-6 hash inputs. Freezing these as +// constants prevents accidental drift between callers and tests. +const ( + domainTargetRank = "challenge_target" + domainPairRank = "pair" + domainTicketRank = "ticket_rank" + domainArtifactClass = "artifact_class" + domainArtifactOrdinal = "artifact_ordinal" + domainRangeOffset = "range_offset" + domainDerivationInput = "derivation_input" + domainTranscript = "transcript" +) + +// Stable string forms for proto enums that participate in hash inputs. We +// deliberately use the short proto suffix (INDEX/SYMBOL, RECENT/OLD/...) — not +// the integer varint, which is brittle if the proto enum is ever renumbered, +// and not the full SCREAMING_SNAKE String() form, which is verbose. Once +// frozen, these strings become part of the protocol surface. +var ( + artifactClassDomain = map[audittypes.StorageProofArtifactClass]string{ + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX: "INDEX", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL: "SYMBOL", + } + bucketDomain = map[audittypes.StorageProofBucketType]string{ + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT: "RECENT", + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD: "OLD", + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_PROBATION: "PROBATION", + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECHECK: "RECHECK", + } +) + +// ArtifactClassDomain returns the canonical hash-input string for the given +// artifact class, or empty string if the class is unspecified or unknown. It +// is exported because PR3+ callers may need to verify a freshly-decoded enum +// has a stable domain string before proceeding. +func ArtifactClassDomain(class audittypes.StorageProofArtifactClass) string { + return artifactClassDomain[class] +} + +// BucketDomain returns the canonical hash-input string for the given bucket +// type, or empty string if the bucket is unspecified or unknown. +func BucketDomain(bucket audittypes.StorageProofBucketType) string { + return bucketDomain[bucket] +} + +// storageTruthAssignmentHash mirrors +// lumera/x/audit/v1/keeper/audit_peer_assignment.go:232 byte-for-byte. It +// computes: +// +// SHA-256(seed || 0x00 || part_0 || 0x00 || part_1 || ... || 0x00 || part_n) +// +// with a NUL byte written before EACH part (not between parts; not after the +// seed alone). No length prefix, no trailing terminator, raw UTF-8 of each +// part string. This is the exact composition the chain validates against. +func storageTruthAssignmentHash(seed []byte, parts ...string) []byte { + h := sha256.New() + _, _ = h.Write(seed) + for _, part := range parts { + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(part)) + } + return h.Sum(nil) +} + +// rankedAccount carries an account/id paired with its sort key. +type rankedAccount struct { + id string + rank []byte +} + +// rankAccounts is the package-internal helper used by SelectLEP6Targets and +// (indirectly) by PairChallengerToTarget. It mirrors rankStorageTruthAccounts +// at audit_peer_assignment.go:214–230 — sort ascending by rank with lex +// tiebreak on id. +func rankAccounts(seed []byte, accounts []string, label string) []rankedAccount { + ranked := make([]rankedAccount, len(accounts)) + for i, a := range accounts { + ranked[i] = rankedAccount{ + id: a, + rank: storageTruthAssignmentHash(seed, a, label), + } + } + sort.Slice(ranked, func(i, j int) bool { + if c := compareBytes(ranked[i].rank, ranked[j].rank); c != 0 { + return c < 0 + } + return ranked[i].id < ranked[j].id + }) + return ranked +} + +// SelectLEP6Targets returns the deterministic challenge-target subset for the +// given epoch seed. Mirrors the chain's per-account ranking exactly: +// targetCount = ceil(len(activeIDs) / divisor), clamped to [1, len(activeIDs)]. +// +// The activeIDs slice is treated as the candidate set as-is. Callers that +// need to deduplicate / sort first must do so before calling — the chain +// itself feeds in `sortedUniqueStrings(activeSorted)` and then takes the +// intersection with explicit `targetsSorted`. For supernode purposes the +// active supernode list is already canonicalised by the chain, so the caller +// typically passes that list straight through. +// +// If divisor is zero, the LEP-6 default (3) is used so partial param overrides +// don't produce 1-target-per-supernode coverage by accident. +func SelectLEP6Targets(activeIDs []string, seed []byte, divisor uint32) []string { + if len(activeIDs) == 0 { + return nil + } + if divisor == 0 { + divisor = LEP6ChallengeTargetDivisor + } + count := (len(activeIDs) + int(divisor) - 1) / int(divisor) + if count < 1 { + count = 1 + } + if count > len(activeIDs) { + count = len(activeIDs) + } + ranked := rankAccounts(seed, activeIDs, domainTargetRank) + out := make([]string, count) + for i := 0; i < count; i++ { + out[i] = ranked[i].id + } + return out +} + +// PairChallengerToTarget assigns one target from `targets` to the given +// challenger using the chain's pair-ranking algorithm. +// +// The chain assigns targets in iteration order over sorted unique active +// challengers, picking for each challenger the smallest-rank unassigned +// target (ties broken lex on target id), and a challenger never gets itself +// as a target. This function reproduces that loop deterministically: for the +// caller's challenger, it selects the unassigned target with smallest pair +// rank that is not equal to the caller, treating `assigned` as the set of +// targets already taken by lower-ranked challengers in the same epoch. +// +// `assigned` may be nil. If non-nil, the function will not return any target +// already present in it. The caller is expected to feed in the fixed-iteration +// view of the assignment as the chain computes it (see +// SelectLEP6Targets + iterate through challengers in deterministic order). +// +// Returns "" if no valid target remains for this challenger. +func PairChallengerToTarget(challenger string, targets []string, seed []byte, assigned map[string]struct{}) string { + bestTarget := "" + var bestRank []byte + for _, t := range targets { + if t == challenger { + continue + } + if assigned != nil { + if _, taken := assigned[t]; taken { + continue + } + } + rank := storageTruthAssignmentHash(seed, challenger, t, domainPairRank) + if bestTarget == "" { + bestTarget = t + bestRank = rank + continue + } + c := compareBytes(rank, bestRank) + if c < 0 || (c == 0 && t < bestTarget) { + bestTarget = t + bestRank = rank + } + } + return bestTarget +} + +// AssignChallengerTargets reproduces the full chain-side challenger→target +// pairing for the entire active set when the target-candidate set is the active +// set. It is provided for tests and for callers who need the complete map (e.g. +// observability emit paths). For runtime use the chain's QueryAssignedTargets +// is the canonical source — call this only when chain access is unavailable or +// for cross-checking. +// +// Iteration order is the lexicographic order of `activeIDs`, matching the +// chain's `sortedUniqueStrings(activeSorted)` precondition. Callers who need +// to feed in an already-sorted unique slice may; otherwise the function does +// not modify its inputs. +func AssignChallengerTargets(activeIDs, targets []string, seed []byte) map[string]string { + return AssignChallengerTargetsWithCandidates(activeIDs, targets, activeIDs, seed) +} + +// AssignChallengerTargetsWithCandidates mirrors Lumera's final +// audit_peer_assignment.go pairing loop, including the self-target fallback: if +// the selected target set has no available non-self target for a challenger, the +// chain scans the full ranked candidate set and picks the best non-self, +// unassigned candidate. This matters when a selected target would otherwise be +// assigned to itself. +func AssignChallengerTargetsWithCandidates(activeIDs, selectedTargets, targetCandidates []string, seed []byte) map[string]string { + if len(activeIDs) == 0 || len(selectedTargets) == 0 { + return map[string]string{} + } + challengers := sortedUniqueCopy(activeIDs) + rankedCandidates := rankAccounts(seed, sortedUniqueCopy(targetCandidates), domainTargetRank) + if len(rankedCandidates) == 0 { + rankedCandidates = rankAccounts(seed, challengers, domainTargetRank) + } + + assigned := make(map[string]struct{}, len(selectedTargets)) + unassignedSelected := make(map[string]struct{}, len(selectedTargets)) + for _, target := range selectedTargets { + if target != "" { + unassignedSelected[target] = struct{}{} + } + } + + uniqueTargetCount := len(unassignedSelected) + out := make(map[string]string, len(challengers)) + for _, challenger := range challengers { + if len(assigned) >= uniqueTargetCount { + break + } + + bestTarget := "" + var bestRank []byte + for target := range unassignedSelected { + if target == challenger { + continue + } + rank := storageTruthAssignmentHash(seed, challenger, target, domainPairRank) + if bestTarget == "" || compareBytes(rank, bestRank) < 0 || (compareBytes(rank, bestRank) == 0 && target < bestTarget) { + bestTarget = target + bestRank = rank + } + } + + if bestTarget == "" { + for _, target := range rankedCandidates { + if _, alreadyAssigned := assigned[target.id]; alreadyAssigned || target.id == challenger { + continue + } + rank := storageTruthAssignmentHash(seed, challenger, target.id, domainPairRank) + if bestTarget == "" || compareBytes(rank, bestRank) < 0 || (compareBytes(rank, bestRank) == 0 && target.id < bestTarget) { + bestTarget = target.id + bestRank = rank + } + } + if bestTarget == "" { + continue + } + } + + delete(unassignedSelected, bestTarget) + assigned[bestTarget] = struct{}{} + out[challenger] = bestTarget + } + return out +} + +// ClassifyTicketBucket classifies a ticket into a LEP-6 bucket based on its +// on-chain anchor height. Per LEP-6 §8 (and the chain's bucket-default fix in +// `LEP-6-consensus-gap-fixes`), bucket boundaries derive from the chain's +// epoch span: +// +// - RECENT if currentHeight - anchorHeight <= recentBucketMaxBlocks (default 3 * epoch_length_blocks) +// - OLD if currentHeight - anchorHeight >= oldBucketMinBlocks (default 30 * epoch_length_blocks) +// - else UNSPECIFIED (middle bucket — eligible only for rechecks / +// probation per LEP-6 §8) +// +// The "anchor height" is the cascade Action's BlockHeight (set at +// RegisterAction; not updated at finalization). Action.UpdatedHeight does not +// exist — see PR2 implementation note in +// docs/plans/LEP6_SUPERNODE_IMPLEMENTATION_PLAN.md §"Resolved Decision 3". +// +// If currentHeight < anchorHeight (clock-skew or replay scenarios), the +// classification falls through to UNSPECIFIED. +func ClassifyTicketBucket(currentHeight, anchorHeight int64, recentBucketMaxBlocks, oldBucketMinBlocks uint64) audittypes.StorageProofBucketType { + if currentHeight < anchorHeight { + return audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_UNSPECIFIED + } + delta := uint64(currentHeight - anchorHeight) + if delta <= recentBucketMaxBlocks { + return audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT + } + if delta >= oldBucketMinBlocks { + return audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD + } + return audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_UNSPECIFIED +} + +// SelectTicketForBucket picks one ticket deterministically for a given +// (target, bucket) pair from a pool of eligible tickets, excluding any tickets +// the caller marks as ineligible (e.g. tickets with an active heal op per +// LEP-6 §9 step 2). +// +// Rank: SHA-256(seed || 0x00 || target || 0x00 || bucket || 0x00 || ticket_id || 0x00 || "ticket_rank") +// — where bucket is the BucketDomain() string ("RECENT", "OLD", "PROBATION", +// or "RECHECK"). Ascending sort, lex tiebreak on ticket id. +// +// Returns "" if no eligible ticket remains, signalling NO_ELIGIBLE_TICKET to +// the caller per LEP-6 §9. +func SelectTicketForBucket(eligibleTicketIDs []string, excluded map[string]struct{}, seed []byte, target string, bucket audittypes.StorageProofBucketType) string { + bucketStr := BucketDomain(bucket) + if bucketStr == "" { + return "" + } + bestTicket := "" + var bestRank []byte + for _, t := range eligibleTicketIDs { + if t == "" { + continue + } + if excluded != nil { + if _, skip := excluded[t]; skip { + continue + } + } + rank := storageTruthAssignmentHash(seed, target, bucketStr, t, domainTicketRank) + if bestTicket == "" { + bestTicket = t + bestRank = rank + continue + } + c := compareBytes(rank, bestRank) + if c < 0 || (c == 0 && t < bestTicket) { + bestTicket = t + bestRank = rank + } + } + return bestTicket +} + +// SelectArtifactClass implements the LEP-6 §10 step 1 class roll: +// +// class_roll = SHA-256(seed || 0x00 || target || 0x00 || ticket_id || 0x00 || "artifact_class")[:8] (big-endian uint64) mod 10 +// class_roll < 2 -> INDEX, else SYMBOL +// +// If the chosen class has zero artifacts, the function falls back +// deterministically to the other class. If neither class has any artifacts, +// returns UNSPECIFIED — the caller should record NO_ELIGIBLE_TICKET. +func SelectArtifactClass(seed []byte, target, ticketID string, indexCount, symbolCount uint32) audittypes.StorageProofArtifactClass { + if indexCount == 0 && symbolCount == 0 { + return audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED + } + rollHash := storageTruthAssignmentHash(seed, target, ticketID, domainArtifactClass) + roll := binary.BigEndian.Uint64(rollHash[:8]) % LEP6ArtifactClassRollModulus + preferIndex := roll < LEP6ArtifactClassIndexCutoff + if preferIndex { + if indexCount > 0 { + return audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX + } + return audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL + } + if symbolCount > 0 { + return audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL + } + return audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX +} + +// SelectArtifactOrdinal implements LEP-6 §10 step 2: +// +// artifact_ordinal = SHA-256(seed || 0x00 || target || 0x00 || ticket_id || 0x00 || class_domain || 0x00 || "artifact_ordinal")[:8] (big-endian uint64) mod artifactCount +// +// Returns an error if artifactCount is zero (caller must have already +// validated the class has artifacts via SelectArtifactClass). Returns an +// error for unsupported classes. +func SelectArtifactOrdinal(seed []byte, target, ticketID string, class audittypes.StorageProofArtifactClass, artifactCount uint32) (uint32, error) { + if artifactCount == 0 { + return 0, fmt.Errorf("deterministic.SelectArtifactOrdinal: artifactCount must be > 0") + } + classDomain := ArtifactClassDomain(class) + if classDomain == "" { + return 0, fmt.Errorf("deterministic.SelectArtifactOrdinal: unsupported class %v", class) + } + h := storageTruthAssignmentHash(seed, target, ticketID, classDomain, domainArtifactOrdinal) + return uint32(binary.BigEndian.Uint64(h[:8]) % uint64(artifactCount)), nil +} + +// ComputeMultiRangeOffsets produces the LEP-6 §11 deterministic byte-range +// offsets for a single artifact challenge: +// +// offset_i = SHA-256(seed || 0x00 || target || 0x00 || ticket_id || +// 0x00 || class_domain || 0x00 || u32be(ordinal) || +// 0x00 || u32be(i))[:8] (big-endian uint64) +// mod (artifactSize - rangeLen) +// +// Defaults: k=4 ranges, rangeLen=256 bytes (LEP-6 spec values). Both must be +// passed explicitly so a future param change at the chain level can be +// surfaced cleanly. The returned slice has length exactly k. +// +// Returns an error if rangeLen >= artifactSize (would yield negative modulus +// space) or if any input is degenerate (k=0, empty class). +// +// IMPORTANT: u32be(ordinal) and u32be(i) are written as raw 4-byte +// big-endian integers, not as decimal-string forms — this keeps the byte +// schema unambiguous and length-stable. If you change to decimal, you must +// version the domain separator and update the protocol guide. +func ComputeMultiRangeOffsets(seed []byte, target, ticketID string, class audittypes.StorageProofArtifactClass, ordinal uint32, artifactSize, rangeLen uint64, k int) ([]uint64, error) { + if k <= 0 { + return nil, fmt.Errorf("deterministic.ComputeMultiRangeOffsets: k must be > 0") + } + if rangeLen == 0 { + return nil, fmt.Errorf("deterministic.ComputeMultiRangeOffsets: rangeLen must be > 0") + } + if artifactSize <= rangeLen { + return nil, fmt.Errorf("deterministic.ComputeMultiRangeOffsets: artifactSize (%d) must be > rangeLen (%d)", artifactSize, rangeLen) + } + classDomain := ArtifactClassDomain(class) + if classDomain == "" { + return nil, fmt.Errorf("deterministic.ComputeMultiRangeOffsets: unsupported class %v", class) + } + span := artifactSize - rangeLen + offsets := make([]uint64, k) + var ordBuf, idxBuf [4]byte + binary.BigEndian.PutUint32(ordBuf[:], ordinal) + for i := 0; i < k; i++ { + binary.BigEndian.PutUint32(idxBuf[:], uint32(i)) + // We deliberately reach into the same `seed || 0x00 || part || ...` + // composition by passing the binary parts as Go strings (the helper + // takes string parts, but Go strings are byte sequences and may + // contain arbitrary bytes including NULs without issue here — the + // helper writes []byte(part) raw). + h := storageTruthAssignmentHash(seed, + target, + ticketID, + classDomain, + string(ordBuf[:]), + string(idxBuf[:]), + domainRangeOffset, + ) + offsets[i] = binary.BigEndian.Uint64(h[:8]) % span + } + return offsets, nil +} + +// ComputeCompoundChallengeHash computes the BLAKE3-256 hash of the +// concatenation of `len(offsets)` byte ranges, each `rangeLen` bytes long, at +// the given offsets within `data`. This is the proof-construction primitive +// per LEP-6 §11: +// +// challenge_hash = blake3(slice_0 || slice_1 || ... || slice_{k-1}) +// +// Slices are read in the order provided (NOT sorted) so the caller's chosen +// offset ordering — which matches ComputeMultiRangeOffsets' i=0..k-1 — is +// preserved and reproducible by observers. +// +// Returns an error if any offset+rangeLen exceeds len(data). +func ComputeCompoundChallengeHash(data []byte, offsets []uint64, rangeLen uint64) ([32]byte, error) { + var zero [32]byte + if rangeLen == 0 { + return zero, fmt.Errorf("deterministic.ComputeCompoundChallengeHash: rangeLen must be > 0") + } + if rangeLen > math.MaxInt { + return zero, fmt.Errorf("deterministic.ComputeCompoundChallengeHash: rangeLen too large") + } + dataLen := uint64(len(data)) + h := blake3.New(32, nil) + for i, off := range offsets { + end := off + rangeLen + if end < off || end > dataLen { + return zero, fmt.Errorf("deterministic.ComputeCompoundChallengeHash: range %d (offset=%d len=%d) exceeds data size %d", i, off, rangeLen, dataLen) + } + _, _ = h.Write(data[off:end]) + } + var out [32]byte + copy(out[:], h.Sum(nil)) + return out, nil +} + +// DerivationInputHash produces the canonical hex string the supernode submits +// as `StorageProofResult.derivation_input_hash`. The chain stores it as an +// opaque non-empty string and uses it for transcript indexing only; this +// function defines the canonical encoding so that two reporters challenging +// the same (target, ticket, class, ordinal, offsets) combination produce +// identical hashes. +// +// Encoding: +// +// SHA-256(seed || 0x00 || target || 0x00 || ticket_id || 0x00 || +// class_domain || 0x00 || u32be(ordinal) || 0x00 || +// u64be(rangeLen) || 0x00 || u64be(offset_0) || ... || +// 0x00 || u64be(offset_{k-1}) || 0x00 || "derivation_input") +// +// Returned as lowercase hex (no 0x prefix, length 64). +func DerivationInputHash(seed []byte, target, ticketID string, class audittypes.StorageProofArtifactClass, ordinal uint32, offsets []uint64, rangeLen uint64) (string, error) { + classDomain := ArtifactClassDomain(class) + if classDomain == "" { + return "", fmt.Errorf("deterministic.DerivationInputHash: unsupported class %v", class) + } + parts := make([]string, 0, 4+len(offsets)+1) + parts = append(parts, target, ticketID, classDomain) + + var ordBuf [4]byte + binary.BigEndian.PutUint32(ordBuf[:], ordinal) + parts = append(parts, string(ordBuf[:])) + + var lenBuf [8]byte + binary.BigEndian.PutUint64(lenBuf[:], rangeLen) + parts = append(parts, string(lenBuf[:])) + + for _, off := range offsets { + var offBuf [8]byte + binary.BigEndian.PutUint64(offBuf[:], off) + parts = append(parts, string(offBuf[:])) + } + parts = append(parts, domainDerivationInput) + + return hex.EncodeToString(storageTruthAssignmentHash(seed, parts...)), nil +} + +// TranscriptInputs bundles the fields that go into TranscriptHash. Using a +// struct keeps the call site readable and makes the input ordering explicit — +// any caller who tries to reorder fields will hit the field-name resolution +// at compile time, not at hash-compare time. +type TranscriptInputs struct { + EpochID uint64 + ChallengerSupernodeAccount string + TargetSupernodeAccount string + TicketID string + Bucket audittypes.StorageProofBucketType + ArtifactClass audittypes.StorageProofArtifactClass + ArtifactOrdinal uint32 + ArtifactKey string + DerivationInputHash string // hex from DerivationInputHash (or empty for NO_ELIGIBLE_TICKET) + CompoundProofHashHex string // hex of ComputeCompoundChallengeHash output (or empty for NO_ELIGIBLE_TICKET) + ObserverIDs []string +} + +// TranscriptHash produces the canonical hex string the supernode submits as +// `StorageProofResult.transcript_hash`. +// +// Encoding: +// +// SHA-256(seed=u64be(epoch_id) || 0x00 || challenger || 0x00 || target || +// 0x00 || ticket_id || 0x00 || bucket_domain || 0x00 || +// class_domain || 0x00 || u32be(ordinal) || 0x00 || artifact_key || +// 0x00 || derivation_input_hash || 0x00 || compound_proof_hash_hex || +// 0x00 || u32be(len(observer_ids)) || +// 0x00 || observer_id_0 || ... || 0x00 || observer_id_n-1 || +// 0x00 || "transcript") +// +// Note that the "seed" passed into storageTruthAssignmentHash here is +// u64be(epoch_id) — not the epoch anchor seed — because transcripts are +// epoch-scoped identifiers and the epoch anchor seed already commits to the +// chain state at that height. Including the epoch id directly keeps the +// transcript portable across replay scenarios. +// +// Observer ids are sorted lex before hashing so observer-set permutations do +// not produce different transcripts for the same logical proof. +// +// Returns lowercase hex. +func TranscriptHash(in TranscriptInputs) (string, error) { + bucketDom := BucketDomain(in.Bucket) + if bucketDom == "" { + return "", fmt.Errorf("deterministic.TranscriptHash: unsupported bucket %v", in.Bucket) + } + // Class is allowed to be UNSPECIFIED only in the NO_ELIGIBLE_TICKET + // transcript shape (TicketID == ""). For all other inputs we require a + // known class. + classDom := ArtifactClassDomain(in.ArtifactClass) + if classDom == "" { + if in.TicketID != "" { + return "", fmt.Errorf("deterministic.TranscriptHash: unsupported class %v with non-empty ticket", in.ArtifactClass) + } + classDom = "UNSPECIFIED" + } + + var epochSeed [8]byte + binary.BigEndian.PutUint64(epochSeed[:], in.EpochID) + + var ordBuf [4]byte + binary.BigEndian.PutUint32(ordBuf[:], in.ArtifactOrdinal) + + observers := append([]string(nil), in.ObserverIDs...) + sort.Strings(observers) + var obsCount [4]byte + binary.BigEndian.PutUint32(obsCount[:], uint32(len(observers))) + + parts := make([]string, 0, 11+len(observers)) + parts = append(parts, + in.ChallengerSupernodeAccount, + in.TargetSupernodeAccount, + in.TicketID, + bucketDom, + classDom, + string(ordBuf[:]), + in.ArtifactKey, + in.DerivationInputHash, + in.CompoundProofHashHex, + string(obsCount[:]), + ) + parts = append(parts, observers...) + parts = append(parts, domainTranscript) + + return hex.EncodeToString(storageTruthAssignmentHash(epochSeed[:], parts...)), nil +} + +func sortedUniqueCopy(in []string) []string { + if len(in) == 0 { + return nil + } + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, v := range in { + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + sort.Strings(out) + return out +} + +// compareBytes is bytes.Compare; inlined to avoid the bytes import for a +// single call site. +func compareBytes(a, b []byte) int { + la, lb := len(a), len(b) + n := la + if lb < n { + n = lb + } + for i := 0; i < n; i++ { + if a[i] != b[i] { + if a[i] < b[i] { + return -1 + } + return 1 + } + } + switch { + case la < lb: + return -1 + case la > lb: + return 1 + default: + return 0 + } +} diff --git a/pkg/storagechallenge/deterministic/lep6_test.go b/pkg/storagechallenge/deterministic/lep6_test.go new file mode 100644 index 00000000..36142609 --- /dev/null +++ b/pkg/storagechallenge/deterministic/lep6_test.go @@ -0,0 +1,667 @@ +package deterministic + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "sort" + "testing" + + audittypes "github.com/LumeraProtocol/lumera/x/audit/v1/types" + "lukechampine.com/blake3" +) + +// chainSeed reproduces the test fixture used by the chain's +// audit_peer_assignment_test.go::TestStorageTruthAssignmentUsesOneThirdCoverage. +// Keeping it identical here lets us cross-check supernode ↔ chain behaviour +// against the same input. +var chainSeed = []byte("01234567890123456789012345678901") + +func TestStorageTruthAssignmentHash_KnownVector(t *testing.T) { + // Byte-level expectation locked against an independent SHA-256 + // computation of the chain's exact byte composition: + // seed || 0x00 || "sn-a" || 0x00 || "challenge_target" + got := storageTruthAssignmentHash(chainSeed, "sn-a", "challenge_target") + wantHex := "bf2bd1e684b3640d2bb047f4d71db719f8f4aa2c3b1601df492115f6e3552b7f" + want, _ := hex.DecodeString(wantHex) + if !bytes.Equal(got, want) { + t.Fatalf("storageTruthAssignmentHash mismatch\nwant %s\ngot %s", wantHex, hex.EncodeToString(got)) + } + + // Spot-check the helper interleaves NULs correctly — direct SHA-256 of + // the equivalent byte stream must match. + h := sha256.New() + h.Write(chainSeed) + h.Write([]byte{0}) + h.Write([]byte("sn-a")) + h.Write([]byte{0}) + h.Write([]byte("challenge_target")) + if !bytes.Equal(got, h.Sum(nil)) { + t.Fatalf("storageTruthAssignmentHash diverges from inline SHA-256") + } +} + +func TestSelectLEP6Targets_OneThirdCoverage_AssignmentMatchesChain(t *testing.T) { + active := []string{"sn-a", "sn-b", "sn-c", "sn-d", "sn-e", "sn-f"} + targets := SelectLEP6Targets(active, chainSeed, 3) + // Chain test asserts targetCount == 2 with len(active)=6, divisor=3. + if len(targets) != 2 { + t.Fatalf("expected 2 targets, got %d (%v)", len(targets), targets) + } + // Independently computed below in a Python sketch (see PR description); + // freezing here as a regression vector. + want := []string{"sn-f", "sn-e"} + if !equalSliceOrdered(targets, want) { + t.Fatalf("targets mismatch\nwant %v\ngot %v", want, targets) + } +} + +func TestAssignChallengerTargets_KnownAssignment(t *testing.T) { + active := []string{"sn-a", "sn-b", "sn-c", "sn-d", "sn-e", "sn-f"} + targets := SelectLEP6Targets(active, chainSeed, 3) + got := AssignChallengerTargets(active, targets, chainSeed) + want := map[string]string{"sn-a": "sn-f", "sn-b": "sn-e"} + if len(got) != len(want) { + t.Fatalf("assignment size mismatch\nwant %v\ngot %v", want, got) + } + for k, v := range want { + if got[k] != v { + t.Fatalf("assignment[%s] = %s, want %s (full got=%v)", k, got[k], v, got) + } + } + // Self-assignment must never happen. + for c, tg := range got { + if c == tg { + t.Fatalf("challenger %s was assigned to itself", c) + } + } + // All assigned targets must come from the SelectLEP6Targets set. + allowed := map[string]struct{}{} + for _, x := range targets { + allowed[x] = struct{}{} + } + for _, tg := range got { + if _, ok := allowed[tg]; !ok { + t.Fatalf("assigned target %s not in target set %v", tg, targets) + } + } + // No two challengers share the same target. + seen := map[string]string{} + for c, tg := range got { + if prev, dup := seen[tg]; dup { + t.Fatalf("target %s assigned to both %s and %s", tg, prev, c) + } + seen[tg] = c + } +} + +func TestAssignChallengerTargets_SelfSelectedTargetFallsBackLikeChain(t *testing.T) { + active := []string{"sn-a", "sn-b"} + // Force the final Lumera edge case: only selected target is the first + // challenger itself. Chain audit_peer_assignment.go then falls back to the + // full ranked candidate set and assigns the best non-self candidate instead + // of returning no assignment. + got := AssignChallengerTargetsWithCandidates(active, []string{"sn-a"}, active, chainSeed) + if got["sn-a"] != "sn-b" { + t.Fatalf("expected self-target fallback sn-a→sn-b, got %v", got) + } + if _, assignedSecond := got["sn-b"]; assignedSecond { + t.Fatalf("targetCount=1 should stop after one assignment, got %v", got) + } +} + +func TestAssignChallengerTargets_DeduplicatesSelectedTargetsForStopCondition(t *testing.T) { + active := []string{"sn-a", "sn-b", "sn-c"} + got := AssignChallengerTargetsWithCandidates(active, []string{"sn-a", "sn-a", ""}, active, chainSeed) + if len(got) != 1 { + t.Fatalf("duplicate/empty selected targets should count as one unique target, got %v", got) + } + for challenger, target := range got { + if challenger == target { + t.Fatalf("challenger %s was assigned to itself", challenger) + } + } +} + +func TestSelectLEP6Targets_SmallSets(t *testing.T) { + // targetCount must always be ≥1 even when divisor > activeCount. + got := SelectLEP6Targets([]string{"sn-a", "sn-b"}, chainSeed, 3) + if len(got) != 1 { + t.Fatalf("targetCount should clamp to 1, got %d (%v)", len(got), got) + } + got = SelectLEP6Targets([]string{"sn-a"}, chainSeed, 3) + if len(got) != 1 || got[0] != "sn-a" { + t.Fatalf("singleton should pass through, got %v", got) + } + got = SelectLEP6Targets(nil, chainSeed, 3) + if got != nil { + t.Fatalf("nil input should yield nil, got %v", got) + } + // Divisor zero defaults to LEP6ChallengeTargetDivisor. + a := SelectLEP6Targets([]string{"sn-a", "sn-b", "sn-c"}, chainSeed, 0) + b := SelectLEP6Targets([]string{"sn-a", "sn-b", "sn-c"}, chainSeed, LEP6ChallengeTargetDivisor) + if !equalSliceOrdered(a, b) { + t.Fatalf("divisor=0 should default to %d; %v != %v", LEP6ChallengeTargetDivisor, a, b) + } +} + +func TestSelectLEP6Targets_DeterministicAcrossRuns(t *testing.T) { + active := []string{"x", "y", "z", "a", "b", "c", "d", "e", "f", "g", "h"} + first := SelectLEP6Targets(active, chainSeed, 4) + for i := 0; i < 50; i++ { + got := SelectLEP6Targets(active, chainSeed, 4) + if !equalSliceOrdered(first, got) { + t.Fatalf("non-deterministic on run %d: %v != %v", i, first, got) + } + } +} + +func TestPairChallengerToTarget_NoSelfTarget(t *testing.T) { + got := PairChallengerToTarget("sn-a", []string{"sn-a", "sn-b", "sn-c"}, chainSeed, nil) + if got == "sn-a" { + t.Fatalf("PairChallengerToTarget must not return self; got %s", got) + } + if got != "sn-b" && got != "sn-c" { + t.Fatalf("unexpected target %s", got) + } +} + +func TestPairChallengerToTarget_RespectsAssigned(t *testing.T) { + all := []string{"sn-b", "sn-c", "sn-d"} + assigned := map[string]struct{}{"sn-b": {}, "sn-c": {}} + got := PairChallengerToTarget("sn-a", all, chainSeed, assigned) + if got != "sn-d" { + t.Fatalf("expected sn-d (only unassigned), got %s", got) + } + // All taken → empty. + full := map[string]struct{}{"sn-b": {}, "sn-c": {}, "sn-d": {}} + got = PairChallengerToTarget("sn-a", all, chainSeed, full) + if got != "" { + t.Fatalf("expected empty when all targets taken, got %s", got) + } +} + +func TestClassifyTicketBucket_Boundaries(t *testing.T) { + // recent ≤ 3*epoch, old ≥ 30*epoch — using 400-block epochs. + const epoch = 400 + recent := uint64(3 * epoch) // 1200 + old := uint64(30 * epoch) // 12000 + cases := []struct { + name string + anchor int64 + now int64 + want audittypes.StorageProofBucketType + }{ + {"current_block", 1000, 1000, audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT}, + {"recent_inside", 1000, 1000 + int64(recent) - 1, audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT}, + {"recent_boundary", 1000, 1000 + int64(recent), audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT}, + {"middle_just_after_recent", 1000, 1000 + int64(recent) + 1, audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_UNSPECIFIED}, + {"middle_just_before_old", 1000, 1000 + int64(old) - 1, audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_UNSPECIFIED}, + {"old_boundary", 1000, 1000 + int64(old), audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD}, + {"old_far", 1000, 1000 + int64(old) + 5000, audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD}, + {"future_anchor_falls_through", 2000, 1000, audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_UNSPECIFIED}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ClassifyTicketBucket(tc.now, tc.anchor, recent, old) + if got != tc.want { + t.Fatalf("anchor=%d now=%d → %v, want %v", tc.anchor, tc.now, got, tc.want) + } + }) + } +} + +func TestSelectTicketForBucket_DeterministicAndExcludes(t *testing.T) { + tickets := []string{"t1", "t2", "t3", "t4", "t5"} + bucket := audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT + a := SelectTicketForBucket(tickets, nil, chainSeed, "sn-target", bucket) + if a == "" { + t.Fatal("expected a ticket, got empty") + } + for i := 0; i < 50; i++ { + b := SelectTicketForBucket(tickets, nil, chainSeed, "sn-target", bucket) + if a != b { + t.Fatalf("non-deterministic ticket selection: %s vs %s on run %d", a, b, i) + } + } + // Exclude the chosen one — must pick a different ticket. + excl := map[string]struct{}{a: {}} + b := SelectTicketForBucket(tickets, excl, chainSeed, "sn-target", bucket) + if b == "" || b == a { + t.Fatalf("exclusion broken: a=%s, b=%s", a, b) + } + // Exclude all — must yield empty. + allExcluded := map[string]struct{}{} + for _, t := range tickets { + allExcluded[t] = struct{}{} + } + c := SelectTicketForBucket(tickets, allExcluded, chainSeed, "sn-target", bucket) + if c != "" { + t.Fatalf("expected empty when all excluded, got %s", c) + } + // Different bucket → may pick a different ticket (and must be deterministic). + d := SelectTicketForBucket(tickets, nil, chainSeed, "sn-target", audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD) + if d == "" { + t.Fatal("OLD bucket should also produce a ticket") + } + d2 := SelectTicketForBucket(tickets, nil, chainSeed, "sn-target", audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD) + if d != d2 { + t.Fatalf("OLD bucket non-deterministic: %s vs %s", d, d2) + } + // Empty tickets → empty result, no panic. + if got := SelectTicketForBucket(nil, nil, chainSeed, "sn-target", bucket); got != "" { + t.Fatalf("nil input should give empty, got %s", got) + } +} + +func TestSelectTicketForBucket_UsesTicketRankDomainSeparator(t *testing.T) { + tickets := []string{"t1", "t2"} + got := SelectTicketForBucket(tickets, nil, chainSeed, "sn-target", audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT) + if got != "t2" { + t.Fatalf("expected ticket_rank-domain selection t2, got %s", got) + } + + wantRank := storageTruthAssignmentHash(chainSeed, "sn-target", "RECENT", "t2", domainTicketRank) + withoutDomain := storageTruthAssignmentHash(chainSeed, "sn-target", "RECENT", "t2") + if bytes.Equal(wantRank, withoutDomain) { + t.Fatalf("ticket_rank domain separator must change the ticket ranking hash") + } +} + +func TestSelectArtifactClass_WeightedDistribution(t *testing.T) { + // 20% INDEX, 80% SYMBOL over many ticket draws. + indexN, symbolN := 0, 0 + for i := 0; i < 5000; i++ { + ticket := "t-" + ifmt(i) + c := SelectArtifactClass(chainSeed, "sn-target", ticket, 100, 100) + switch c { + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX: + indexN++ + case audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL: + symbolN++ + default: + t.Fatalf("unexpected class %v on draw %d", c, i) + } + } + total := indexN + symbolN + idxFrac := float64(indexN) / float64(total) + // Expected 0.2, allow ±2% tolerance for 5000 draws. + if idxFrac < 0.18 || idxFrac > 0.22 { + t.Fatalf("INDEX fraction %.4f outside expected 0.18-0.22 (n=%d/%d)", idxFrac, indexN, total) + } +} + +func TestSelectArtifactClass_FallbackWhenClassEmpty(t *testing.T) { + // indexCount=0 → must always return SYMBOL even when roll wants INDEX. + for i := 0; i < 100; i++ { + c := SelectArtifactClass(chainSeed, "sn-target", "t-"+ifmt(i), 0, 50) + if c != audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL { + t.Fatalf("with indexCount=0, must fall back to SYMBOL; got %v", c) + } + } + // symbolCount=0 → always INDEX. + for i := 0; i < 100; i++ { + c := SelectArtifactClass(chainSeed, "sn-target", "t-"+ifmt(i), 50, 0) + if c != audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX { + t.Fatalf("with symbolCount=0, must fall back to INDEX; got %v", c) + } + } + // Both zero → UNSPECIFIED. + if c := SelectArtifactClass(chainSeed, "sn-target", "t1", 0, 0); c != audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED { + t.Fatalf("with both zero, expected UNSPECIFIED; got %v", c) + } +} + +func TestSelectArtifactOrdinal_BoundsAndDeterminism(t *testing.T) { + const count = 64 + first, err := SelectArtifactOrdinal(chainSeed, "sn-target", "ticket-1", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, count) + if err != nil { + t.Fatalf("err: %v", err) + } + if first >= count { + t.Fatalf("ordinal out of range: %d", first) + } + for i := 0; i < 50; i++ { + again, _ := SelectArtifactOrdinal(chainSeed, "sn-target", "ticket-1", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, count) + if again != first { + t.Fatalf("non-deterministic: %d vs %d", first, again) + } + } + // Errors: + if _, err := SelectArtifactOrdinal(chainSeed, "sn-target", "t", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, 0); err == nil { + t.Fatal("expected error for count=0") + } + if _, err := SelectArtifactOrdinal(chainSeed, "sn-target", "t", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED, 5); err == nil { + t.Fatal("expected error for unspecified class") + } +} + +func TestComputeMultiRangeOffsets_AllInBounds(t *testing.T) { + const size, rl = uint64(10000), uint64(256) + offsets, err := ComputeMultiRangeOffsets(chainSeed, "sn-target", "ticket-1", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, 0, size, rl, 4) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(offsets) != 4 { + t.Fatalf("expected 4 offsets, got %d", len(offsets)) + } + for i, off := range offsets { + if off+rl > size { + t.Fatalf("offset %d (%d + %d) exceeds size %d", i, off, rl, size) + } + } + // Determinism. + o2, _ := ComputeMultiRangeOffsets(chainSeed, "sn-target", "ticket-1", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, 0, size, rl, 4) + if !equalSliceUint64(offsets, o2) { + t.Fatalf("non-deterministic offsets: %v vs %v", offsets, o2) + } +} + +func TestComputeMultiRangeOffsets_OffsetsDistinctOnDifferentInputs(t *testing.T) { + const size, rl = uint64(10000), uint64(256) + a, _ := ComputeMultiRangeOffsets(chainSeed, "sn-target", "ticket-1", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, 0, size, rl, 4) + b, _ := ComputeMultiRangeOffsets(chainSeed, "sn-target", "ticket-2", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, 0, size, rl, 4) + if equalSliceUint64(a, b) { + t.Fatalf("different ticket should change offsets, got identical %v", a) + } + c, _ := ComputeMultiRangeOffsets(chainSeed, "sn-target", "ticket-1", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX, 0, size, rl, 4) + if equalSliceUint64(a, c) { + t.Fatalf("different class should change offsets, got identical %v", a) + } + d, _ := ComputeMultiRangeOffsets(chainSeed, "sn-target", "ticket-1", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, 1, size, rl, 4) + if equalSliceUint64(a, d) { + t.Fatalf("different ordinal should change offsets, got identical %v", a) + } +} + +func TestComputeMultiRangeOffsets_Errors(t *testing.T) { + cls := audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL + if _, err := ComputeMultiRangeOffsets(chainSeed, "x", "t", cls, 0, 100, 256, 4); err == nil { + t.Fatal("expected error when rangeLen >= artifactSize") + } + if _, err := ComputeMultiRangeOffsets(chainSeed, "x", "t", cls, 0, 1000, 256, 0); err == nil { + t.Fatal("expected error for k=0") + } + if _, err := ComputeMultiRangeOffsets(chainSeed, "x", "t", cls, 0, 1000, 0, 4); err == nil { + t.Fatal("expected error for rangeLen=0") + } + if _, err := ComputeMultiRangeOffsets(chainSeed, "x", "t", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED, 0, 1000, 256, 4); err == nil { + t.Fatal("expected error for unspecified class") + } +} + +func TestComputeCompoundChallengeHash_KnownVector(t *testing.T) { + // 1024-byte input filled with byte values 0..255 cycling. + data := make([]byte, 1024) + for i := range data { + data[i] = byte(i) + } + offsets := []uint64{0, 256, 512, 768} + rl := uint64(256) + got, err := ComputeCompoundChallengeHash(data, offsets, rl) + if err != nil { + t.Fatalf("err: %v", err) + } + // Independent reference computation. + h := blake3.New(32, nil) + for _, off := range offsets { + h.Write(data[off : off+rl]) + } + var want [32]byte + copy(want[:], h.Sum(nil)) + if got != want { + t.Fatalf("compound hash mismatch\nwant %x\ngot %x", want, got) + } +} + +func TestComputeCompoundChallengeHash_OrderMatters(t *testing.T) { + // Build data where each 256-byte slice has a unique signature: fill with + // `byte(off >> 8)` so slice [0:256] = 0x00…, [256:512] = 0x01…, etc. + data := make([]byte, 1024) + for i := range data { + data[i] = byte(i >> 8) + } + a, _ := ComputeCompoundChallengeHash(data, []uint64{0, 256, 512, 768}, 256) + b, _ := ComputeCompoundChallengeHash(data, []uint64{768, 512, 256, 0}, 256) + if a == b { + t.Fatal("compound hash should be order-sensitive (slices concatenated in offset order)") + } +} + +func TestComputeCompoundChallengeHash_OutOfBounds(t *testing.T) { + data := make([]byte, 100) + if _, err := ComputeCompoundChallengeHash(data, []uint64{50}, 60); err == nil { + t.Fatal("expected error for out-of-bounds slice") + } + if _, err := ComputeCompoundChallengeHash(data, []uint64{100}, 1); err == nil { + t.Fatal("expected error when offset == len(data)") + } +} + +func TestDerivationInputHash_DeterministicAndSensitive(t *testing.T) { + cls := audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL + a, err := DerivationInputHash(chainSeed, "sn-target", "ticket-1", cls, 7, []uint64{1, 2, 3, 4}, 256) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(a) != 64 { + t.Fatalf("expected 64-char hex, got %d (%q)", len(a), a) + } + b, _ := DerivationInputHash(chainSeed, "sn-target", "ticket-1", cls, 7, []uint64{1, 2, 3, 4}, 256) + if a != b { + t.Fatalf("non-deterministic: %s vs %s", a, b) + } + // Each input field must change the hash. + cases := []struct { + name string + fn func() (string, error) + }{ + {"ticket", func() (string, error) { + return DerivationInputHash(chainSeed, "sn-target", "ticket-2", cls, 7, []uint64{1, 2, 3, 4}, 256) + }}, + {"ordinal", func() (string, error) { + return DerivationInputHash(chainSeed, "sn-target", "ticket-1", cls, 8, []uint64{1, 2, 3, 4}, 256) + }}, + {"offsets", func() (string, error) { + return DerivationInputHash(chainSeed, "sn-target", "ticket-1", cls, 7, []uint64{1, 2, 3, 5}, 256) + }}, + {"rangeLen", func() (string, error) { + return DerivationInputHash(chainSeed, "sn-target", "ticket-1", cls, 7, []uint64{1, 2, 3, 4}, 257) + }}, + {"target", func() (string, error) { + return DerivationInputHash(chainSeed, "other-target", "ticket-1", cls, 7, []uint64{1, 2, 3, 4}, 256) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, _ := tc.fn() + if got == a { + t.Fatalf("changing %s did not change hash (%s)", tc.name, got) + } + }) + } +} + +func TestTranscriptHash_DeterministicAndSensitive(t *testing.T) { + in := TranscriptInputs{ + EpochID: 42, + ChallengerSupernodeAccount: "sn-prober", + TargetSupernodeAccount: "sn-target", + TicketID: "ticket-1", + Bucket: audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, + ArtifactClass: audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL, + ArtifactOrdinal: 3, + ArtifactKey: "p2p-key-abc", + DerivationInputHash: "deadbeef", + CompoundProofHashHex: "feedface", + ObserverIDs: []string{"sn-obs-1", "sn-obs-2"}, + } + a, err := TranscriptHash(in) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(a) != 64 { + t.Fatalf("expected 64-char hex, got %d", len(a)) + } + // Determinism even when observers are in shuffled input order. + in2 := in + in2.ObserverIDs = []string{"sn-obs-2", "sn-obs-1"} + b, _ := TranscriptHash(in2) + if a != b { + t.Fatalf("observer order should be normalised: %s vs %s", a, b) + } + + // NO_ELIGIBLE_TICKET shape (ticket_id == "" with UNSPECIFIED class) is allowed. + noTicket := TranscriptInputs{ + EpochID: 42, + ChallengerSupernodeAccount: "sn-prober", + TargetSupernodeAccount: "sn-target", + TicketID: "", + Bucket: audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT, + ArtifactClass: audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED, + ObserverIDs: []string{"sn-obs-1"}, + } + if _, err := TranscriptHash(noTicket); err != nil { + t.Fatalf("NO_ELIGIBLE_TICKET shape should be valid, err: %v", err) + } + + // UNSPECIFIED class with non-empty ticket → error. + bad := in + bad.ArtifactClass = audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED + if _, err := TranscriptHash(bad); err == nil { + t.Fatal("expected error for UNSPECIFIED class with ticket") + } + + // Each major input must change the hash. + cases := []func(*TranscriptInputs){ + func(x *TranscriptInputs) { x.EpochID = 43 }, + func(x *TranscriptInputs) { x.ChallengerSupernodeAccount = "sn-other-prober" }, + func(x *TranscriptInputs) { x.TargetSupernodeAccount = "sn-other-target" }, + func(x *TranscriptInputs) { x.TicketID = "ticket-2" }, + func(x *TranscriptInputs) { x.Bucket = audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD }, + func(x *TranscriptInputs) { x.ArtifactOrdinal = 4 }, + func(x *TranscriptInputs) { x.ArtifactKey = "p2p-key-other" }, + func(x *TranscriptInputs) { x.DerivationInputHash = "00" }, + func(x *TranscriptInputs) { x.CompoundProofHashHex = "11" }, + func(x *TranscriptInputs) { x.ObserverIDs = []string{"sn-obs-3"} }, + } + for i, mut := range cases { + x := in + mut(&x) + got, _ := TranscriptHash(x) + if got == a { + t.Fatalf("mutation %d did not change transcript hash", i) + } + } +} + +func TestTranscriptHash_UnsupportedBucket(t *testing.T) { + in := TranscriptInputs{ + Bucket: audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_UNSPECIFIED, + } + if _, err := TranscriptHash(in); err == nil { + t.Fatal("expected error for UNSPECIFIED bucket") + } +} + +func TestArtifactClassDomain_Stable(t *testing.T) { + cases := map[audittypes.StorageProofArtifactClass]string{ + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_INDEX: "INDEX", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_SYMBOL: "SYMBOL", + audittypes.StorageProofArtifactClass_STORAGE_PROOF_ARTIFACT_CLASS_UNSPECIFIED: "", + } + for cls, want := range cases { + got := ArtifactClassDomain(cls) + if got != want { + t.Fatalf("ArtifactClassDomain(%v) = %q, want %q", cls, got, want) + } + } +} + +func TestBucketDomain_Stable(t *testing.T) { + cases := map[audittypes.StorageProofBucketType]string{ + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECENT: "RECENT", + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_OLD: "OLD", + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_PROBATION: "PROBATION", + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_RECHECK: "RECHECK", + audittypes.StorageProofBucketType_STORAGE_PROOF_BUCKET_TYPE_UNSPECIFIED: "", + } + for b, want := range cases { + if got := BucketDomain(b); got != want { + t.Fatalf("BucketDomain(%v) = %q, want %q", b, got, want) + } + } +} + +// --- helpers ---------------------------------------------------------------- + +func equalSliceOrdered(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func equalSliceUint64(a, b []uint64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// ifmt is a tiny non-allocating int-to-decimal-string we use only inside +// distribution tests where strconv.Itoa would still be fine — kept minimal so +// the test file has no extra imports beyond the production package's. +func ifmt(i int) string { + if i == 0 { + return "0" + } + neg := i < 0 + if neg { + i = -i + } + var buf [20]byte + pos := len(buf) + for i > 0 { + pos-- + buf[pos] = byte('0' + i%10) + i /= 10 + } + if neg { + pos-- + buf[pos] = '-' + } + return string(buf[pos:]) +} + +// Stable cross-platform sort.Strings sanity check (catches accidental +// reliance on platform-specific stable-sort behaviour in CI). +func TestSortStrings_StableForPairs(t *testing.T) { + xs := []string{"sn-c", "sn-a", "sn-b"} + sort.Strings(xs) + want := []string{"sn-a", "sn-b", "sn-c"} + if !equalSliceOrdered(xs, want) { + t.Fatalf("stable sort mismatch: %v != %v", xs, want) + } +}