diff --git a/control-plane/internal/blockchainbridge/networkvalidator.go b/control-plane/internal/blockchainbridge/networkvalidator.go new file mode 100644 index 0000000..d88cba2 --- /dev/null +++ b/control-plane/internal/blockchainbridge/networkvalidator.go @@ -0,0 +1,89 @@ +package blockchainbridge + +import ( + "context" + "encoding/hex" + "errors" +) + +// ActiveNetworkValidators reads pallet-network-validator's +// ActiveValidatorSet at blockHash (callers pass the finalized head they +// already have, e.g. from FinalizedHead, to avoid a redundant RPC round +// trip) and returns the raw account ids it holds. Read-only, +// dashboard-facing: it never submits anything, and a failure here must +// never be mistaken for "zero validators" by the caller (see dashboard.go, +// which treats an error as "unavailable", not "empty"). +func (c *RPCClient) ActiveNetworkValidators(ctx context.Context, blockHash string) ([][32]byte, error) { + value, found, err := c.Storage(ctx, networkValidatorStorageKey("ActiveValidatorSet"), blockHash) + if err != nil { + return nil, err + } + if !found { + return nil, nil + } + return decodeAccountIdVec(value) +} + +// networkValidatorStorageKey addresses a StorageValue item (no map key +// component), unlike mapStorageKey which hashes a lookup key onto the +// prefix. "NetworkValidator" must match the pallet's identifier in +// construct_runtime! exactly, the same convention providerStorageKey +// relies on for "ProviderRegistry". +func networkValidatorStorageKey(item string) string { + key := append(twox128([]byte("NetworkValidator")), twox128([]byte(item))...) + return "0x" + hex.EncodeToString(key) +} + +// decodeAccountIdVec decodes a SCALE-encoded BoundedVec: +// a compact length prefix followed by that many 32-byte account ids. +func decodeAccountIdVec(data []byte) ([][32]byte, error) { + count, offset, err := decodeCompactUint(data) + if err != nil { + return nil, err + } + remaining := uint64(len(data) - offset) + if remaining != count*32 { + return nil, errors.New("account id vector length does not match its prefix") + } + accounts := make([][32]byte, 0, count) + for i := uint64(0); i < count; i++ { + var account [32]byte + copy(account[:], data[offset+int(i)*32:offset+int(i+1)*32]) + accounts = append(accounts, account) + } + return accounts, nil +} + +// decodeCompactUint decodes a SCALE compact-encoded unsigned integer from +// the start of data, mirroring compactUint's encoding one mode at a time. +// Returns the value and how many bytes it occupied. +func decodeCompactUint(data []byte) (uint64, int, error) { + if len(data) == 0 { + return 0, 0, errors.New("compact integer: empty input") + } + switch data[0] & 0b11 { + case 0: + return uint64(data[0] >> 2), 1, nil + case 1: + if len(data) < 2 { + return 0, 0, errors.New("compact integer: truncated two-byte mode") + } + return uint64(uint16(data[0])|uint16(data[1])<<8) >> 2, 2, nil + case 2: + if len(data) < 4 { + return 0, 0, errors.New("compact integer: truncated four-byte mode") + } + value := uint32(data[0]) | uint32(data[1])<<8 | uint32(data[2])<<16 | uint32(data[3])<<24 + return uint64(value) >> 2, 4, nil + default: // big-integer mode + length := int(data[0]>>2) + 4 + if len(data) < 1+length { + return 0, 0, errors.New("compact integer: truncated big-integer mode") + } + var value uint64 + for i := length - 1; i >= 0; i-- { + value = value<<8 | uint64(data[1+i]) + } + return value, 1 + length, nil + } +} diff --git a/control-plane/internal/blockchainbridge/networkvalidator_test.go b/control-plane/internal/blockchainbridge/networkvalidator_test.go new file mode 100644 index 0000000..e7c332f --- /dev/null +++ b/control-plane/internal/blockchainbridge/networkvalidator_test.go @@ -0,0 +1,98 @@ +package blockchainbridge + +import ( + "testing" +) + +func TestDecodeCompactUintRoundTripsWithTheExistingEncoder(t *testing.T) { + cases := []uint64{0, 1, 63, 64, 65, 16_383, 16_384, 1<<30 - 1, 1 << 30, 1 << 40} + for _, value := range cases { + encoded := compactUint(value) + decoded, consumed, err := decodeCompactUint(encoded) + if err != nil { + t.Fatalf("decodeCompactUint(%d encoded): %v", value, err) + } + if decoded != value { + t.Fatalf("round trip mismatch: encoded %d, decoded %d", value, decoded) + } + if consumed != len(encoded) { + t.Fatalf("expected to consume all %d encoded bytes, consumed %d", len(encoded), consumed) + } + } +} + +func TestDecodeCompactUintRejectsTruncatedInput(t *testing.T) { + cases := [][]byte{ + {}, + {0b01}, // two-byte mode, only one byte present + {0b10}, // four-byte mode, only one byte present + {0b10, 0, 0}, // four-byte mode, three bytes present + {0b11}, // big-integer mode, length byte only + } + for _, input := range cases { + if _, _, err := decodeCompactUint(input); err == nil { + t.Fatalf("expected an error decoding truncated input %v", input) + } + } +} + +func TestDecodeAccountIdVecRoundTripsAnArbitraryCount(t *testing.T) { + var accounts [][32]byte + for i := 0; i < 5; i++ { + var account [32]byte + for b := range account { + account[b] = byte(i) + } + accounts = append(accounts, account) + } + encoded := compactUint(uint64(len(accounts))) + for _, account := range accounts { + encoded = append(encoded, account[:]...) + } + decoded, err := decodeAccountIdVec(encoded) + if err != nil { + t.Fatalf("decodeAccountIdVec: %v", err) + } + if len(decoded) != len(accounts) { + t.Fatalf("expected %d accounts, got %d", len(accounts), len(decoded)) + } + for i := range accounts { + if decoded[i] != accounts[i] { + t.Fatalf("account %d mismatch: want %x, got %x", i, accounts[i], decoded[i]) + } + } +} + +func TestDecodeAccountIdVecHandlesAnEmptySet(t *testing.T) { + decoded, err := decodeAccountIdVec(compactUint(0)) + if err != nil { + t.Fatalf("decodeAccountIdVec(empty): %v", err) + } + if len(decoded) != 0 { + t.Fatalf("expected zero accounts, got %d", len(decoded)) + } +} + +func TestDecodeAccountIdVecRejectsALengthMismatch(t *testing.T) { + // Prefix claims 2 accounts but only one 32-byte chunk follows. + encoded := append(compactUint(2), make([]byte, 32)...) + if _, err := decodeAccountIdVec(encoded); err == nil { + t.Fatal("expected a length-mismatch error") + } +} + +func TestNetworkValidatorStorageKeyIsAFixedLengthPrefix(t *testing.T) { + key := networkValidatorStorageKey("ActiveValidatorSet") + // "0x" + 16 bytes (pallet) + 16 bytes (item) = 2 + 64 hex chars. + if len(key) != 66 { + t.Fatalf("expected a 32-byte storage prefix, got key of length %d: %s", len(key), key) + } + if key[:2] != "0x" { + t.Fatalf("expected a 0x-prefixed key, got %s", key) + } + // Same pallet/item always yields the same key -- it addresses a fixed + // StorageValue, not a per-account map entry. + if key != networkValidatorStorageKey("ActiveValidatorSet") { + t.Fatal("expected a deterministic storage key") + } +} diff --git a/control-plane/internal/dashboard/assets/app.js b/control-plane/internal/dashboard/assets/app.js index 21cc3b6..a5082ee 100644 --- a/control-plane/internal/dashboard/assets/app.js +++ b/control-plane/internal/dashboard/assets/app.js @@ -6,9 +6,15 @@ async function refresh(){ if(active)active.abort();active=new AbortController();text('sample','Actualisation…'); try{ const response=await fetch('/api/v1/overview',{signal:active.signal});if(!response.ok)throw new Error(`HTTP ${response.status}`);const data=await response.json(); - text('fresh',data.providers_fresh);text('total',`${data.providers_total} enregistrés`);text('cpu',Number(data.cpu_available).toFixed(1));text('memory',`${Math.round(data.memory_available_mb/1024)} GiB`);text('block',`#${data.finalized_block}`);text('chain',data.chain_syncing?'synchronisation en cours':`best #${data.best_block}`);text('workload-count',data.workloads.length);text('sample',new Date(data.generated_at).toLocaleTimeString()); + text('fresh',data.providers_fresh);text('total',`${data.providers_total} enregistrés`);text('cpu',Number(data.cpu_available).toFixed(1));text('memory',`${Math.round(data.memory_available_mb/1024)} GiB`);text('block',`#${data.finalized_block}`);text('chain',data.chain_syncing?'synchronisation en cours':`best #${data.best_block}`);text('workload-count',data.workloads.length); + // validators_active is -1 when the read failed: show that as unknown, + // never as zero, so an outage never reads as "no validators". + text('validator-count',data.validators_active<0?'—':data.validators_active); + text('sample',new Date(data.generated_at).toLocaleTimeString()); const providers=$('providers');providers.replaceChildren();for(const p of data.providers){const tr=row([p.provider_id,p.status,'',p.agent_version,p.cpu_available.toFixed(1),`${Math.round(p.memory_available_mb/1024)} GiB`,p.chain_state]);tr.children[2].append(status(p.liveness));providers.append(tr)} const workloads=$('workloads');workloads.replaceChildren();for(const w of data.workloads)workloads.append(row([w.workload_id,w.state,w.provider_id||'—',w.lease_id||'—',new Date(w.created_at).toLocaleString()])); + const validators=$('validators');validators.replaceChildren();for(const v of data.validators||[])validators.append(row([v])); + $('validators-warning').hidden=data.validators_active>=0; const warning=$('warning');warning.hidden=!data.partial;warning.textContent=data.partial?'Certaines sources sont momentanément indisponibles. Les données affichées restent partielles.':''; }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.'}} } diff --git a/control-plane/internal/dashboard/assets/index.html b/control-plane/internal/dashboard/assets/index.html index aa4fc14..0524aee 100644 --- a/control-plane/internal/dashboard/assets/index.html +++ b/control-plane/internal/dashboard/assets/index.html @@ -15,10 +15,15 @@
Mémoire disponiblesur heartbeats frais
Blockchaintête finalisée
Workloadspersistés dans PostgreSQL
+
Network Validatorsactifs on-chain
WORKLOADS

Orchestration durable

WorkloadÉtatProviderLeaseCréé
+
VALIDATORS

Scoring décentralisé

+ +
Validator
+
PROVIDERS

État du réseau

ProviderÉtat durableConnexionAgentCPURAMOn-chain
diff --git a/control-plane/internal/dashboard/dashboard.go b/control-plane/internal/dashboard/dashboard.go index 0d06269..7a36047 100644 --- a/control-plane/internal/dashboard/dashboard.go +++ b/control-plane/internal/dashboard/dashboard.go @@ -3,6 +3,7 @@ package dashboard import ( "context" "embed" + "encoding/hex" "encoding/json" "io/fs" "math" @@ -54,6 +55,11 @@ type Overview struct { Errors []string `json:"errors,omitempty"` Providers []Provider `json:"providers"` Workloads []Workload `json:"workloads"` + // ValidatorsActive is -1 when the read failed, so a client can tell + // "no validators registered" (0) apart from "unavailable" (-1) -- + // ADR-011 requires never reporting false success on a degraded read. + ValidatorsActive int `json:"validators_active"` + Validators []string `json:"validators,omitempty"` } type Workload struct { @@ -118,7 +124,12 @@ func (s *Server) overview(w http.ResponseWriter, r *http.Request) { } func (s *Server) loadOverview(ctx context.Context) (Overview, error) { - result := Overview{GeneratedAt: s.now().UTC().Format(time.RFC3339), Providers: []Provider{}, Workloads: []Workload{}} + result := Overview{ + GeneratedAt: s.now().UTC().Format(time.RFC3339), + Providers: []Provider{}, + Workloads: []Workload{}, + ValidatorsActive: -1, + } rows, err := s.pool.Query(ctx, ` SELECT p.provider_id, p.status, p.agent_version, p.registered_at, COALESCE(c.state, 'UNKNOWN') @@ -233,6 +244,17 @@ func (s *Server) loadOverview(ctx context.Context) (Overview, error) { result.ChainSyncing = health.IsSyncing result.BestBlock, _ = best.BlockNumber() result.FinalizedBlock, _ = final.BlockNumber() + + validators, err := s.chain.ActiveNetworkValidators(chainCtx, finalHash) + if err != nil { + result.Partial = true + appendError(&result, "validator set unavailable") + return result, nil + } + result.ValidatorsActive = len(validators) + for _, account := range validators { + result.Validators = append(result.Validators, abbreviate(hex.EncodeToString(account[:]))) + } return result, nil } diff --git a/control-plane/internal/dashboard/dashboard_test.go b/control-plane/internal/dashboard/dashboard_test.go index 06d6aa7..6a362fd 100644 --- a/control-plane/internal/dashboard/dashboard_test.go +++ b/control-plane/internal/dashboard/dashboard_test.go @@ -1,6 +1,7 @@ package dashboard import ( + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -27,3 +28,39 @@ func TestAbbreviateProviderIdentity(t *testing.T) { t.Fatalf("short value changed to %q", got) } } + +// A degraded validator-set read must serialize as -1, never 0: the +// dashboard's whole point is not reporting false success on a failed +// chain read (ADR-011). This pins the JSON contract so a refactor can't +// silently drop the sentinel back to Go's int zero value. +func TestOverviewReportsUnavailableValidatorSetDistinctlyFromZero(t *testing.T) { + degraded := Overview{ValidatorsActive: -1, Providers: []Provider{}, Workloads: []Workload{}} + encoded, err := json.Marshal(degraded) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + got, ok := decoded["validators_active"] + if !ok { + t.Fatal("expected a validators_active field even when zero-valued/unavailable") + } + if got != float64(-1) { + t.Fatalf("validators_active = %v, want -1", got) + } + + healthy := Overview{ValidatorsActive: 0, Providers: []Provider{}, Workloads: []Workload{}} + encoded, err = json.Marshal(healthy) + if err != nil { + t.Fatalf("marshal: %v", err) + } + decoded = nil + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded["validators_active"] != float64(0) { + t.Fatalf("a genuinely empty validator set must still read as 0, got %v", decoded["validators_active"]) + } +}