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
89 changes: 89 additions & 0 deletions control-plane/internal/blockchainbridge/networkvalidator.go
Original file line number Diff line number Diff line change
@@ -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<AccountId32, _>:
// 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
}
}
98 changes: 98 additions & 0 deletions control-plane/internal/blockchainbridge/networkvalidator_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
8 changes: 7 additions & 1 deletion control-plane/internal/dashboard/assets/app.js

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

5 changes: 5 additions & 0 deletions control-plane/internal/dashboard/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@
<article><span>Mémoire disponible</span><strong id="memory">—</strong><small>sur heartbeats frais</small></article>
<article><span>Blockchain</span><strong id="block">—</strong><small id="chain">tête finalisée</small></article>
<article><span>Workloads</span><strong id="workload-count">—</strong><small>persistés dans PostgreSQL</small></article>
<article><span>Network Validators</span><strong id="validator-count">—</strong><small>actifs on-chain</small></article>
</section>
<section class="panel"><div class="panel-head"><div><span class="eyebrow">WORKLOADS</span><h2>Orchestration durable</h2></div></div>
<div class="table-wrap"><table><thead><tr><th>Workload</th><th>État</th><th>Provider</th><th>Lease</th><th>Créé</th></tr></thead><tbody id="workloads"></tbody></table></div>
</section>
<section class="panel"><div class="panel-head"><div><span class="eyebrow">VALIDATORS</span><h2>Scoring décentralisé</h2></div></div>
<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"><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>On-chain</th></tr></thead><tbody id="providers"></tbody></table></div>
Expand Down
24 changes: 23 additions & 1 deletion control-plane/internal/dashboard/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package dashboard
import (
"context"
"embed"
"encoding/hex"
"encoding/json"
"io/fs"
"math"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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
}

Expand Down
37 changes: 37 additions & 0 deletions control-plane/internal/dashboard/dashboard_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dashboard

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
Expand All @@ -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"])
}
}