Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions control-plane/internal/blockchainbridge/roundresult.go
Original file line number Diff line number Diff line change
@@ -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
}
175 changes: 175 additions & 0 deletions control-plane/internal/blockchainbridge/roundresult_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
36 changes: 36 additions & 0 deletions control-plane/internal/dashboard/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()});
10 changes: 10 additions & 0 deletions control-plane/internal/dashboard/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@
<div id="validators-warning" class="warning" hidden>Ensemble des validateurs indisponible — ne pas confondre avec zéro validateur actif.</div>
<div class="table-wrap"><table><thead><tr><th>Validator</th></tr></thead><tbody id="validators"></tbody></table></div>
</section>
<section class="panel" id="validator-scores-panel">
<div class="panel-head"><div><span class="eyebrow">VALIDATOR SCORES</span><h2>Historique des rounds de scoring</h2></div></div>
<p class="muted">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.</p>
<div class="score-controls">
<input id="score-provider-id" type="text" placeholder="provider_id">
<button id="score-load" type="button">Charger</button>
</div>
<div id="score-warning" class="warning" hidden></div>
<div class="table-wrap"><table><thead><tr><th>Dimension</th><th>Round</th><th>Score</th><th>Précédent</th><th>Confiance</th><th>Soumissions</th><th>Statut</th><th>Bloc de clôture</th></tr></thead><tbody id="score-rows"></tbody></table></div>
</section>
<section class="panel"><div class="panel-head"><div><span class="eyebrow">PROVIDERS</span><h2>État du réseau</h2></div><button id="refresh" type="button">Actualiser</button></div>
<div id="warning" class="warning" hidden></div>
<div class="table-wrap"><table><thead><tr><th>Provider</th><th>État durable</th><th>Connexion</th><th>Agent</th><th>CPU</th><th>RAM</th><th>Stockage</th><th>Bande passante</th><th>Réputation</th><th>Offre on-chain</th><th>On-chain</th></tr></thead><tbody id="providers"></tbody></table></div>
Expand Down
1 change: 1 addition & 0 deletions control-plane/internal/dashboard/assets/style.css

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

1 change: 1 addition & 0 deletions control-plane/internal/dashboard/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "/" {
Expand Down
Loading