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 @@
Ensemble des validateurs indisponible — ne pas confondre avec zéro validateur actif.
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.
+
+
+
+
+
+
Dimension
Round
Score
Précédent
Confiance
Soumissions
Statut
Bloc de clôture
+
PROVIDERS
État du réseau
Provider
État durable
Connexion
Agent
CPU
RAM
Stockage
Bande passante
Réputation
Offre on-chain
On-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.