diff --git a/control-plane/internal/blockchainbridge/roundresult.go b/control-plane/internal/blockchainbridge/roundresult.go new file mode 100644 index 0000000..609f27d --- /dev/null +++ b/control-plane/internal/blockchainbridge/roundresult.go @@ -0,0 +1,152 @@ +package blockchainbridge + +import ( + "context" + "encoding/binary" + "encoding/hex" + "errors" + + "github.com/OneOfOne/xxhash" + "golang.org/x/crypto/blake2b" +) + +// RoundStatus mirrors pallet-network-validator::RoundStatus byte-for-byte +// (declaration order = SCALE variant tag): Final, Disputed, +// DisputeUpheld, DisputeRejected. +type RoundStatus byte + +const ( + RoundFinal RoundStatus = iota + RoundDisputed + RoundDisputeUpheld + RoundDisputeRejected +) + +func (s RoundStatus) String() string { + switch s { + case RoundFinal: + return "final" + case RoundDisputed: + return "disputed" + case RoundDisputeUpheld: + return "dispute_upheld" + case RoundDisputeRejected: + return "dispute_rejected" + default: + return "unknown" + } +} + +// RoundResult mirrors pallet_network_validator::pallet::RoundResult +// exactly (blockchain/pallets/network-validator/src/lib.rs). +type RoundResult struct { + ScoreBps uint16 + PreviousScoreBps uint16 + Submissions uint32 + CommitteeTarget uint32 + ClosedAt uint32 + Status RoundStatus +} + +// Confidence is Submissions/CommitteeTarget as a 0..10_000 basis-point +// figure -- a thin display value over RoundResult, not a new decode -- +// exposed as its own method so a dashboard can distinguish a +// well-attested score from a thin one without recomputing this everywhere +// (issue #76's "score history... without false success" acceptance +// criterion). 0 CommitteeTarget (should not happen in practice, but a +// decoded value is still just bytes off the chain, never trusted blindly) +// returns 0 rather than dividing by zero. +func (r RoundResult) ConfidenceBps() uint32 { + if r.CommitteeTarget == 0 { + return 0 + } + confidence := uint64(r.Submissions) * 10_000 / uint64(r.CommitteeTarget) + if confidence > 10_000 { + confidence = 10_000 + } + return uint32(confidence) +} + +// FinalizedRoundResult reads pallet-network-validator's Rounds NMap for +// (provider, round, dimension) at the current finalized head. found is +// false when the round hasn't closed yet (no close_round call has landed +// for it) -- a normal, common state for a round still in progress or one +// that never reached quorum, distinct from a read failure. +func (c *RPCClient) FinalizedRoundResult(ctx context.Context, provider [32]byte, round uint64, dimension ScoreDimension) (RoundResult, bool, error) { + head, err := c.FinalizedHead(ctx) + if err != nil { + return RoundResult{}, false, err + } + value, found, err := c.Storage(ctx, roundResultStorageKey(provider, round, dimension), head) + if err != nil || !found { + return RoundResult{}, found, err + } + result, err := decodeRoundResult(value) + if err != nil { + return RoundResult{}, false, err + } + return result, true, nil +} + +// roundResultStorageKey addresses pallet-network-validator's Rounds +// StorageNMap, keyed (Blake2_128Concat AccountId, Twox64Concat u64, +// Twox64Concat ScoreDimension) -- unlike mapStorageKey's single hashed +// key, an NMap's storage key is the pallet/item prefix followed by each +// key component's own hasher output concatenated in declaration order, +// each one immediately followed by that component's own raw SCALE +// encoding (the "Concat" half of each hasher's name). Evidence uses the +// identical key shape (same three key types, same hashers) -- if a +// caller ever needs to read Evidence too, this same construction applies +// with "Evidence" in place of "Rounds". +func roundResultStorageKey(provider [32]byte, round uint64, dimension ScoreDimension) string { + key := append(twox128([]byte("NetworkValidator")), twox128([]byte("Rounds"))...) + + providerDigest, _ := blake2b.New(16, nil) + _, _ = providerDigest.Write(provider[:]) + key = append(key, providerDigest.Sum(nil)...) + key = append(key, provider[:]...) + + roundEncoded := make([]byte, 8) + binary.LittleEndian.PutUint64(roundEncoded, round) + key = append(key, twox64(roundEncoded)...) + key = append(key, roundEncoded...) + + dimensionEncoded := []byte{byte(dimension)} + key = append(key, twox64(dimensionEncoded)...) + key = append(key, dimensionEncoded...) + + return "0x" + hex.EncodeToString(key) +} + +// twox64 is twox128's 8-byte sibling (Twox64Concat's hash component): a +// single xxHash64 pass with seed 0, matching Substrate's twox_64 exactly +// -- twox128 concatenates two differently-seeded 8-byte checksums, +// twox64 is just the first of those on its own. +func twox64(value []byte) []byte { + result := make([]byte, 8) + binary.LittleEndian.PutUint64(result, xxhash.Checksum64S(value, 0)) + return result +} + +// decodeRoundResult decodes RoundResult's six fixed-width fields in +// declaration order -- no compact encoding, no length prefix, matching +// every other plain-struct decode in this package (e.g. +// decodeReputationVector). +func decodeRoundResult(data []byte) (RoundResult, error) { + const wantLength = 2 + 2 + 4 + 4 + 4 + 1 + if len(data) != wantLength { + return RoundResult{}, errors.New("round result has an unexpected encoded length") + } + status := RoundStatus(data[16]) + if status > RoundDisputeRejected { + return RoundResult{}, errors.New("round result has an unknown status variant") + } + return RoundResult{ + ScoreBps: binary.LittleEndian.Uint16(data[0:2]), + PreviousScoreBps: binary.LittleEndian.Uint16(data[2:4]), + Submissions: binary.LittleEndian.Uint32(data[4:8]), + CommitteeTarget: binary.LittleEndian.Uint32(data[8:12]), + ClosedAt: binary.LittleEndian.Uint32(data[12:16]), + Status: status, + }, nil +} diff --git a/control-plane/internal/blockchainbridge/roundresult_test.go b/control-plane/internal/blockchainbridge/roundresult_test.go new file mode 100644 index 0000000..868456d --- /dev/null +++ b/control-plane/internal/blockchainbridge/roundresult_test.go @@ -0,0 +1,175 @@ +package blockchainbridge + +import ( + "context" + "net/http" + "os" + "strings" + "testing" + "time" +) + +func TestRoundResultStorageKeyIsAPerKeyMapEntry(t *testing.T) { + var providerA, providerB [32]byte + providerA[0], providerB[0] = 1, 2 + + base := roundResultStorageKey(providerA, 7, DimensionNetwork) + + cases := map[string]string{ + "different provider": roundResultStorageKey(providerB, 7, DimensionNetwork), + "different round": roundResultStorageKey(providerA, 8, DimensionNetwork), + "different dimension": roundResultStorageKey(providerA, 7, DimensionStorage), + } + for name, other := range cases { + if other == base { + t.Fatalf("expected %s to change the storage key, but it did not", name) + } + } + if roundResultStorageKey(providerA, 7, DimensionNetwork) != base { + t.Fatal("expected a deterministic storage key for identical inputs") + } +} + +// TestRoundResultStorageKeyPrefixMatchesPalletAndItemName pins the +// pallet/item prefix bytes (the first 32 hex-encoded bytes = twox128 +// "NetworkValidator" ++ twox128 "Rounds") independently of the per-key +// hashing, so a future rename of either string is caught here rather than +// only surfacing as a live-chain "not found" that looks identical to a +// round that legitimately hasn't closed yet. +func TestRoundResultStorageKeyPrefixMatchesPalletAndItemName(t *testing.T) { + var provider [32]byte + key := roundResultStorageKey(provider, 0, DimensionCompute) + wantPrefix := "0x" + toHex(twox128([]byte("NetworkValidator"))) + toHex(twox128([]byte("Rounds"))) + if !strings.HasPrefix(key, wantPrefix) { + t.Fatalf("storage key %s does not start with pallet/item prefix %s", key, wantPrefix) + } +} + +func toHex(b []byte) string { + const hexDigits = "0123456789abcdef" + out := make([]byte, len(b)*2) + for i, v := range b { + out[2*i] = hexDigits[v>>4] + out[2*i+1] = hexDigits[v&0x0f] + } + return string(out) +} + +func TestTwox64ProducesEightBytesAndDiffersFromTwox128sFirstHalf(t *testing.T) { + value := []byte("round") + got := twox64(value) + if len(got) != 8 { + t.Fatalf("twox64 returned %d bytes, want 8", len(got)) + } + // twox128 is defined as two 8-byte xxHash64 passes (seeds 0 and 1) + // concatenated; twox64 must be exactly the first of those, not some + // independent construction that happens to also be 8 bytes. + full := twox128(value) + if string(full[:8]) != string(got) { + t.Fatal("expected twox64 to equal twox128's first 8 bytes (the seed-0 half)") + } +} + +func TestDecodeRoundResultMatchesPalletFieldOrder(t *testing.T) { + data := []byte{ + 0x10, 0x27, // score_bps = 10000 (LE u16) + 0xE8, 0x03, // previous_score_bps = 1000 (LE u16) + 0x05, 0x00, 0x00, 0x00, // submissions = 5 (LE u32) + 0x05, 0x00, 0x00, 0x00, // committee_target = 5 (LE u32) + 0x64, 0x00, 0x00, 0x00, // closed_at = 100 (LE u32) + 0x00, // status = Final + } + got, err := decodeRoundResult(data) + if err != nil { + t.Fatalf("decode: %v", err) + } + want := RoundResult{ + ScoreBps: 10000, + PreviousScoreBps: 1000, + Submissions: 5, + CommitteeTarget: 5, + ClosedAt: 100, + Status: RoundFinal, + } + if got != want { + t.Fatalf("got %+v, want %+v", got, want) + } + if got.ConfidenceBps() != 10_000 { + t.Fatalf("expected full attendance (5/5) to be 10000 bps confidence, got %d", got.ConfidenceBps()) + } +} + +func TestDecodeRoundResultRejectsWrongLength(t *testing.T) { + if _, err := decodeRoundResult(make([]byte, 16)); err == nil { + t.Fatal("expected an error for a truncated RoundResult") + } + if _, err := decodeRoundResult(make([]byte, 18)); err == nil { + t.Fatal("expected an error for an over-long RoundResult") + } +} + +func TestDecodeRoundResultRejectsUnknownStatusVariant(t *testing.T) { + data := make([]byte, 17) + data[16] = 4 // one past DisputeRejected(3) + if _, err := decodeRoundResult(data); err == nil { + t.Fatal("expected an error for an out-of-range RoundStatus variant tag") + } +} + +func TestRoundResultConfidenceBpsHandlesPartialAttendanceAndZeroTarget(t *testing.T) { + partial := RoundResult{Submissions: 2, CommitteeTarget: 5} + if got := partial.ConfidenceBps(); got != 4_000 { + t.Fatalf("expected 2/5 = 4000 bps, got %d", got) + } + zero := RoundResult{Submissions: 3, CommitteeTarget: 0} + if got := zero.ConfidenceBps(); got != 0 { + t.Fatalf("expected a zero CommitteeTarget to report 0 confidence rather than divide by zero, got %d", got) + } +} + +func TestRoundStatusStringCoversEveryVariant(t *testing.T) { + cases := map[RoundStatus]string{ + RoundFinal: "final", + RoundDisputed: "disputed", + RoundDisputeUpheld: "dispute_upheld", + RoundDisputeRejected: "dispute_rejected", + RoundStatus(200): "unknown", + } + for status, want := range cases { + if got := status.String(); got != want { + t.Fatalf("RoundStatus(%d).String() = %q, want %q", status, got, want) + } + } +} + +// TestFinalizedRoundResultAgainstLocalNode proves the read path (storage +// key + state_getStorage + decode) actually round-trips against a real +// running chain. As documented throughout this session, the local dev +// chain's compiled wasm predates pallet-network-validator entirely, so no +// round can genuinely exist on it -- this test can only prove the +// "not found" path behaves correctly (no RPC error, found=false) rather +// than a real decode. That is still worth pinning: it is the exact +// behavior a dashboard sees for every round that has not closed yet, the +// overwhelmingly common case. +func TestFinalizedRoundResultAgainstLocalNode(t *testing.T) { + endpoint := os.Getenv("OPENINFRA_TEST_SUBSTRATE_RPC_URL") + if endpoint == "" { + t.Skip("local Substrate integration environment is not configured") + } + rpc, err := NewRPCClient(endpoint, &http.Client{Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("configure RPC: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var provider [32]byte + provider[0] = 0xEE + _, found, err := rpc.FinalizedRoundResult(ctx, provider, 0, DimensionNetwork) + if err != nil { + t.Fatalf("read round result: %v", err) + } + if found { + t.Fatal("did not expect a closed round for a made-up provider/round pair") + } +} diff --git a/control-plane/internal/dashboard/assets/app.js b/control-plane/internal/dashboard/assets/app.js index 46c0054..dd2729c 100644 --- a/control-plane/internal/dashboard/assets/app.js +++ b/control-plane/internal/dashboard/assets/app.js @@ -29,3 +29,39 @@ async function refresh(){ }catch(error){if(error.name!=='AbortError'){text('sample','Indisponible');const warning=$('warning');warning.hidden=false;warning.textContent='Le dashboard ne peut pas charger les données.'}} } $('refresh').addEventListener('click',refresh);refresh();setInterval(refresh,10000); + +// #76 validator score history: fetched on demand for a single provider_id +// rather than folded into refresh()'s periodic /api/v1/overview poll -- +// scanning pallet-network-validator's Rounds NMap is real per-round chain +// I/O server-side (see internal/dashboard/validatorscores.go), so this +// stays an explicit, infrequent action, not a background one. +function scoreStatusLabel(status){ + // Mirrors blockchainbridge.RoundStatus.String() -- keep in sync with + // internal/blockchainbridge/roundresult.go if a variant is ever added. + const labels={final:'clos',disputed:'contesté',dispute_upheld:'contestation retenue',dispute_rejected:'contestation rejetée'}; + return labels[status]||status; +} +async function loadValidatorScores(){ + const providerId=$('score-provider-id').value.trim(); + const warning=$('score-warning');const rows=$('score-rows'); + warning.hidden=true;warning.textContent=''; + if(!providerId){warning.hidden=false;warning.textContent='Indiquez un provider_id.';return} + rows.replaceChildren(); + try{ + const response=await fetch(`/api/v1/validator-scores/${encodeURIComponent(providerId)}`); + if(response.status===404){warning.hidden=false;warning.textContent='Provider introuvable.';return} + if(!response.ok)throw new Error(`HTTP ${response.status}`); + const data=await response.json(); + if(data.partial){warning.hidden=false;warning.textContent='Lecture partielle — certains rounds n\'ont pas pu être lus on-chain.'} + let any=false; + for(const dimension of data.dimensions||[]){ + for(const round of dimension.rounds||[]){ + any=true; + rows.append(row([dimension.dimension,round.round,(round.score_bps/100).toFixed(2)+' %',(round.previous_score_bps/100).toFixed(2)+' %',(round.confidence_bps/100).toFixed(0)+' %',`${round.submissions}/${round.committee_target}`,scoreStatusLabel(round.status),round.closed_at_block])); + } + } + if(!any&&!data.partial){warning.hidden=false;warning.textContent='Aucun round clos pour ce provider dans la fenêtre récente.'} + }catch(error){warning.hidden=false;warning.textContent='Impossible de charger l\'historique de scoring.'} +} +$('score-load').addEventListener('click',loadValidatorScores); +$('score-provider-id').addEventListener('keydown',e=>{if(e.key==='Enter')loadValidatorScores()}); diff --git a/control-plane/internal/dashboard/assets/index.html b/control-plane/internal/dashboard/assets/index.html index a1ef5d7..a631432 100644 --- a/control-plane/internal/dashboard/assets/index.html +++ b/control-plane/internal/dashboard/assets/index.html @@ -39,6 +39,16 @@
Validator
+
+
VALIDATOR SCORES

Historique des rounds de scoring

+

Résultats des derniers rounds de challenge (ADR-013) pour un provider donné, par dimension. Un round sans ligne n'a simplement pas encore été clos — ce n'est pas une erreur.

+
+ + +
+ +
DimensionRoundScorePrécédentConfianceSoumissionsStatutBloc de clôture
+
PROVIDERS

État du réseau

ProviderÉtat durableConnexionAgentCPURAMStockageBande passanteRéputationOffre on-chainOn-chain
diff --git a/control-plane/internal/dashboard/assets/style.css b/control-plane/internal/dashboard/assets/style.css index e9e21cd..1544d4c 100644 --- a/control-plane/internal/dashboard/assets/style.css +++ b/control-plane/internal/dashboard/assets/style.css @@ -1,2 +1,3 @@ :root{color-scheme:dark;--bg:#08100d;--panel:#101b17;--line:#263a31;--text:#eef8f2;--muted:#91a69b;--green:#58e89b;--amber:#f4c76b}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 0,#153527 0,transparent 32rem),var(--bg);font:15px/1.5 system-ui,sans-serif;color:var(--text)}header,main{max-width:1200px;margin:auto;padding:28px}header{display:flex;justify-content:space-between;align-items:end}h1,h2{margin:.2rem 0;letter-spacing:-.035em}h1{font-size:clamp(2rem,5vw,4rem)}.eyebrow,.sample,th{font-size:.72rem;letter-spacing:.16em;color:var(--green)}.cards{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:24px 0}.cards article,.panel{background:color-mix(in srgb,var(--panel) 92%,transparent);border:1px solid var(--line);border-radius:14px;padding:20px}.cards span,.cards small{display:block;color:var(--muted)}.cards strong{display:block;font-size:2rem;margin:.5rem 0}.panel-head{display:flex;justify-content:space-between;align-items:center}button{background:var(--green);border:0;border-radius:8px;padding:10px 14px;font-weight:700;cursor:pointer}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;margin-top:16px}th,td{text-align:left;padding:13px 10px;border-bottom:1px solid var(--line);white-space:nowrap}td{color:#c9d8d0}.status{display:inline-flex;gap:6px;align-items:center}.status:before{content:"";width:8px;height:8px;border-radius:50%;background:var(--muted)}.status.FRESH:before{background:var(--green);box-shadow:0 0 10px var(--green)}.status.STALE:before{background:var(--amber)}.warning{border-left:3px solid var(--amber);padding:10px 14px;background:#352b15;margin-top:12px}@media(max-width:800px){.cards{grid-template-columns:1fr 1fr}header{align-items:start;flex-direction:column}.sample{margin-top:12px}}@media(max-width:480px){.cards{grid-template-columns:1fr}header,main{padding:18px}} #auth-panel{margin-bottom:24px}.muted{color:var(--muted);margin:0 0 12px}code{background:#0d1a14;border:1px solid var(--line);border-radius:6px;padding:2px 6px;font-size:.85em;word-break:break-all}.account-line{margin:0 0 12px}#auth-logged-in button,#auth-logged-out button{margin-right:10px}#auth-issued-key{word-break:break-all} +.score-controls{display:flex;gap:10px}.score-controls input{flex:1;min-width:0;background:#0d1a14;border:1px solid var(--line);border-radius:8px;padding:10px 12px;color:var(--text);font:inherit} diff --git a/control-plane/internal/dashboard/dashboard.go b/control-plane/internal/dashboard/dashboard.go index 0c1e4ba..c2e12bc 100644 --- a/control-plane/internal/dashboard/dashboard.go +++ b/control-plane/internal/dashboard/dashboard.go @@ -141,6 +141,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/v1/auth/login", s.authLogin) mux.HandleFunc("POST /api/v1/auth/api-keys", s.authIssueAPIKey) mux.HandleFunc("GET /api/v1/agent-endpoint/{provider_id}", s.agentEndpoint) + mux.HandleFunc("GET /api/v1/validator-scores/{provider_id}", s.validatorScores) mux.Handle("GET /dashboard/", http.StripPrefix("/dashboard/", http.FileServer(http.FS(static)))) mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { diff --git a/control-plane/internal/dashboard/validatorscores.go b/control-plane/internal/dashboard/validatorscores.go new file mode 100644 index 0000000..afa4860 --- /dev/null +++ b/control-plane/internal/dashboard/validatorscores.go @@ -0,0 +1,201 @@ +package dashboard + +import ( + "context" + "crypto/ed25519" + "errors" + "net/http" + "sync" + "time" + + "github.com/jackc/pgx/v5" + "github.com/openinfra/network/internal/blockchainbridge" + "github.com/openinfra/network/internal/networkvalidator" +) + +// validatorScoreDimensions is every ScoreDimension pallet-network-validator +// defines, in the pallet's own declaration order -- iterated over rather +// than discovered, since ScoreDimension's variant set only ever changes +// with a pallet upgrade, the same assumption ParseScoreDimension already +// makes. +var validatorScoreDimensions = []blockchainbridge.ScoreDimension{ + blockchainbridge.DimensionCompute, + blockchainbridge.DimensionStorage, + blockchainbridge.DimensionNetwork, + blockchainbridge.DimensionAvailability, + blockchainbridge.DimensionReliability, +} + +// validatorScoreLookbackRounds bounds how far back, per dimension, this +// endpoint scans for closed rounds before giving up -- Rounds is an +// OptionQuery NMap with no enumeration RPC available under this node's +// --rpc-methods=safe allowlist (see FinalizedProviderAccounts's doc +// comment on state_getKeysPaged vs state_getPairs for the same +// constraint), so a per-round point read is the only read shape +// available and each one is a real network round trip. 12 rounds is +// enough to show a handful of closed rounds' worth of history even for a +// dimension that only closes sporadically, while keeping the worst case +// (every round in the window open) at a bounded, human-noticeable-but- +// not-alarming number of sequential reads per dimension. +const validatorScoreLookbackRounds = 12 + +// validatorScoreMaxEntriesPerDimension caps how many closed rounds are +// returned once found, so a dimension that has closed every round in the +// lookback window doesn't return an unbounded (from the caller's +// perspective) payload. +const validatorScoreMaxEntriesPerDimension = 8 + +// RoundScore is one closed round's outcome for a single dimension, +// shaped for direct display -- see #76's "validator score history... +// without false success" acceptance criterion, which is why Confidence +// is precomputed here rather than left for the client to derive from +// Submissions/CommitteeTarget (a client that forgets to divide would +// otherwise render a thin, low-confidence round exactly like a +// well-attested one). +type RoundScore struct { + Round uint64 `json:"round"` + ScoreBps uint16 `json:"score_bps"` + PreviousScoreBps uint16 `json:"previous_score_bps"` + Submissions uint32 `json:"submissions"` + CommitteeTarget uint32 `json:"committee_target"` + ConfidenceBps uint32 `json:"confidence_bps"` + ClosedAt uint32 `json:"closed_at_block"` + Status string `json:"status"` +} + +// DimensionScoreHistory is one dimension's slice of ValidatorScores. +type DimensionScoreHistory struct { + Dimension string `json:"dimension"` + Rounds []RoundScore `json:"rounds"` +} + +// ValidatorScores is GET /api/v1/validator-scores/{provider_id}'s +// response body: the provider's recent Network Validator challenge +// outcomes, one history per dimension. CurrentRound is included so a +// client can tell "this dimension has never closed a round" (empty +// Rounds) apart from "this dimension has closed rounds, but none in the +// scanned window" -- both render as an empty list otherwise. +type ValidatorScores struct { + ProviderID string `json:"provider_id"` + CurrentRound uint64 `json:"current_round"` + Dimensions []DimensionScoreHistory `json:"dimensions"` + Partial bool `json:"partial,omitempty"` +} + +// validatorScores is #76's validator-facing dashboard view: per-dimension +// challenge-round history for one provider, read live from +// pallet-network-validator's Rounds NMap (see +// internal/blockchainbridge/roundresult.go). Deliberately unauthenticated +// and rate-limited like agentEndpoint -- the underlying reputation +// figures this summarizes are already public (ProviderReputationVector +// renders in /api/v1/overview), so this is a narrower, per-provider, +// per-round view of the same public on-chain state, not a new trust +// boundary. +func (s *Server) validatorScores(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) + defer cancel() + if !s.allowRate(ctx, w, r, "validator-scores") { + return + } + providerID := r.PathValue("provider_id") + if providerID == "" || len(providerID) > maxProviderIDLength { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider_id is required and bounded"}) + return + } + + var publicKey []byte + err := s.pool.QueryRow(ctx, `SELECT public_key FROM providers WHERE provider_id=$1`, providerID).Scan(&publicKey) + if errors.Is(err, pgx.ErrNoRows) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"}) + return + } + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "provider lookup unavailable"}) + return + } + if len(publicKey) != ed25519.PublicKeySize { + // Same defensive check loadOverview applies before treating a + // provider's public_key as a 32-byte AccountId -- a corrupt or + // pre-migration row should not crash this endpoint. + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "provider has no usable on-chain identity"}) + return + } + var account [32]byte + copy(account[:], publicKey) + + head, err := s.chain.FinalizedHead(ctx) + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "blockchain status unavailable"}) + return + } + header, err := s.chain.HeaderAt(ctx, head) + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "blockchain status unavailable"}) + return + } + finalizedBlockNumber, err := header.BlockNumber() + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "blockchain status unavailable"}) + return + } + currentRound := networkvalidator.RoundLength(networkvalidator.DefaultRoundLengthBlocks).Round(finalizedBlockNumber) + + result := ValidatorScores{ + ProviderID: providerID, + CurrentRound: currentRound, + Dimensions: make([]DimensionScoreHistory, len(validatorScoreDimensions)), + } + + // One dimension's lookback scan is a sequence of independent + // point reads, so the 5 dimensions run concurrently -- otherwise + // this endpoint's worst case is 5 * validatorScoreLookbackRounds + // sequential RPC round trips. + var partial sync.Mutex + var wg sync.WaitGroup + for index, dimension := range validatorScoreDimensions { + wg.Add(1) + go func(index int, dimension blockchainbridge.ScoreDimension) { + defer wg.Done() + history, sawError := s.dimensionScoreHistory(ctx, account, currentRound, dimension) + result.Dimensions[index] = history + if sawError { + partial.Lock() + result.Partial = true + partial.Unlock() + } + }(index, dimension) + } + wg.Wait() + + writeJSON(w, http.StatusOK, result) +} + +func (s *Server) dimensionScoreHistory(ctx context.Context, account [32]byte, currentRound uint64, dimension blockchainbridge.ScoreDimension) (DimensionScoreHistory, bool) { + history := DimensionScoreHistory{Dimension: dimension.String(), Rounds: []RoundScore{}} + sawError := false + for offset := uint64(0); offset <= validatorScoreLookbackRounds && offset <= currentRound; offset++ { + if len(history.Rounds) >= validatorScoreMaxEntriesPerDimension { + break + } + round := currentRound - offset + result, found, err := s.chain.FinalizedRoundResult(ctx, account, round, dimension) + if err != nil { + sawError = true + continue + } + if !found { + continue + } + history.Rounds = append(history.Rounds, RoundScore{ + Round: round, + ScoreBps: result.ScoreBps, + PreviousScoreBps: result.PreviousScoreBps, + Submissions: result.Submissions, + CommitteeTarget: result.CommitteeTarget, + ConfidenceBps: result.ConfidenceBps(), + ClosedAt: result.ClosedAt, + Status: result.Status.String(), + }) + } + return history, sawError +} diff --git a/control-plane/internal/dashboard/validatorscores_test.go b/control-plane/internal/dashboard/validatorscores_test.go new file mode 100644 index 0000000..17ea630 --- /dev/null +++ b/control-plane/internal/dashboard/validatorscores_test.go @@ -0,0 +1,153 @@ +package dashboard + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/openinfra/network/internal/blockchainbridge" + "github.com/openinfra/network/internal/userauth" + "github.com/openinfra/network/internal/walletlogin" + "github.com/openinfra/network/migrations" +) + +// newValidatorScoresTestServer is newAuthTestServer's sibling: it also +// needs a real chain client, since validatorScores (unlike the auth +// endpoints) reads pallet-network-validator directly. Gated on both +// Postgres and Substrate integration environment variables -- this test +// is skipped, not failed, when either is absent, matching every other +// live-chain test in this codebase. +func newValidatorScoresTestServer(t *testing.T) (context.Context, *Server, *pgxpool.Pool) { + t.Helper() + databaseURL := os.Getenv("OPENINFRA_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("OPENINFRA_TEST_DATABASE_URL is not set") + } + rpcURL := os.Getenv("OPENINFRA_TEST_SUBSTRATE_RPC_URL") + if rpcURL == "" { + t.Skip("OPENINFRA_TEST_SUBSTRATE_RPC_URL is not set") + } + ctx := context.Background() + admin, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(admin.Close) + schema := "dashboard_validator_scores_test_" + strings.ReplaceAll(uuid.NewString(), "-", "") + if _, err := admin.Exec(ctx, fmt.Sprintf(`CREATE SCHEMA %q`, schema)); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = admin.Exec(ctx, fmt.Sprintf(`DROP SCHEMA %q CASCADE`, schema)) }) + + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + t.Fatal(err) + } + config.ConnConfig.RuntimeParams["search_path"] = schema + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + if err := migrations.Apply(ctx, pool); err != nil { + t.Fatal(err) + } + + chain, err := blockchainbridge.NewRPCClient(rpcURL, &http.Client{Timeout: 5 * time.Second}) + if err != nil { + t.Fatal(err) + } + + users := userauth.NewPostgresRepository(pool) + wallet := walletlogin.NewService(walletlogin.NewPostgresRepository(pool), users) + server := New(pool, nil, chain, wallet, users, nil) // nil redis/limiter: unused by this endpoint under test + return ctx, server, pool +} + +func TestValidatorScoresReturnsNotFoundForAnUnknownProvider(t *testing.T) { + _, server, _ := newValidatorScoresTestServer(t) + handler := server.Handler() + recorder := doJSON(t, handler, http.MethodGet, "/api/v1/validator-scores/never-registered", nil) + if recorder.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", recorder.Code) + } +} + +func TestValidatorScoresRejectsAnOversizedProviderID(t *testing.T) { + _, server, _ := newValidatorScoresTestServer(t) + handler := server.Handler() + oversized := strings.Repeat("a", 200) + recorder := doJSON(t, handler, http.MethodGet, "/api/v1/validator-scores/"+oversized, nil) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", recorder.Code) + } +} + +// TestValidatorScoresReturnsEveryDimensionWithEmptyHistoryAgainstALiveChain +// proves the whole read path -- provider lookup, finalized-head read, +// round derivation, and a concurrent per-dimension NMap scan -- against a +// real running chain. As documented throughout this session, the local +// dev chain's wasm predates pallet-network-validator, so no round can +// genuinely have closed; this test pins the resulting shape (every +// dimension present, each with an empty Rounds list, Partial false since +// "not found" is not an error) rather than a real score, which is still +// the overwhelmingly common response shape for a provider a validator +// hasn't scored yet. +func TestValidatorScoresReturnsEveryDimensionWithEmptyHistoryAgainstALiveChain(t *testing.T) { + ctx, server, pool := newValidatorScoresTestServer(t) + handler := server.Handler() + + publicKey := make([]byte, 32) + publicKey[0] = 0xCD + if _, err := pool.Exec(ctx, ` + INSERT INTO providers (provider_id, public_key, protocol_version, agent_version, capabilities, status, registered_at) + VALUES ('provider-under-test', $1, '1', 'test', $2, 2, now())`, + publicKey, []byte{}, + ); err != nil { + t.Fatal(err) + } + + recorder := doJSON(t, handler, http.MethodGet, "/api/v1/validator-scores/provider-under-test", nil) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + var response ValidatorScores + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.ProviderID != "provider-under-test" { + t.Fatalf("provider_id = %q", response.ProviderID) + } + if response.Partial { + t.Fatal("a round genuinely not found should not mark the response partial") + } + if len(response.Dimensions) != len(validatorScoreDimensions) { + t.Fatalf("dimensions = %d, want %d", len(response.Dimensions), len(validatorScoreDimensions)) + } + seen := make(map[string]bool, len(response.Dimensions)) + for _, dimension := range response.Dimensions { + seen[dimension.Dimension] = true + if len(dimension.Rounds) != 0 { + t.Fatalf("dimension %s: expected no closed rounds against this chain, got %+v", dimension.Dimension, dimension.Rounds) + } + } + for _, want := range validatorScoreDimensions { + if !seen[want.String()] { + t.Fatalf("missing dimension %s in response", want.String()) + } + } +} + +// The len(publicKey) != ed25519.PublicKeySize branch in validatorScores is +// defense in depth, not independently testable through a real insert: the +// providers table's own CHECK (octet_length(public_key) = 32) constraint +// (migrations/000001_provider_join.sql) already makes a short key +// unreachable at this layer -- the identical situation as loadOverview's +// matching check in dashboard.go.